@nestjs/platform-fastify 12.0.3 → 12.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/Readme.md +4 -0
- package/adapters/fastify-adapter.d.ts +48 -5
- package/adapters/fastify-adapter.js +137 -9
- package/index.d.ts +1 -0
- package/index.js +1 -0
- package/interfaces/external/fastify-multipart-options.interface.d.ts +53 -0
- package/interfaces/external/fastify-multipart-options.interface.js +1 -0
- package/interfaces/external/index.d.ts +1 -0
- package/interfaces/external/index.js +1 -0
- package/multipart/files.constants.d.ts +1 -0
- package/multipart/files.constants.js +1 -0
- package/multipart/index.d.ts +4 -0
- package/multipart/index.js +4 -0
- package/multipart/interceptors/any-files.interceptor.d.ts +12 -0
- package/multipart/interceptors/any-files.interceptor.js +13 -0
- package/multipart/interceptors/file-fields.interceptor.d.ts +13 -0
- package/multipart/interceptors/file-fields.interceptor.js +14 -0
- package/multipart/interceptors/file-stream.interceptor.d.ts +31 -0
- package/multipart/interceptors/file-stream.interceptor.js +32 -0
- package/multipart/interceptors/file.interceptor.d.ts +17 -0
- package/multipart/interceptors/file.interceptor.js +18 -0
- package/multipart/interceptors/files.interceptor.d.ts +14 -0
- package/multipart/interceptors/files.interceptor.js +15 -0
- package/multipart/interceptors/index.d.ts +6 -0
- package/multipart/interceptors/index.js +6 -0
- package/multipart/interceptors/no-files.interceptor.d.ts +12 -0
- package/multipart/interceptors/no-files.interceptor.js +13 -0
- package/multipart/interfaces/files-upload-module.interface.d.ts +18 -0
- package/multipart/interfaces/files-upload-module.interface.js +1 -0
- package/multipart/interfaces/index.d.ts +3 -0
- package/multipart/interfaces/index.js +3 -0
- package/multipart/interfaces/multipart-file.interface.d.ts +70 -0
- package/multipart/interfaces/multipart-file.interface.js +1 -0
- package/multipart/interfaces/multipart-options.interface.d.ts +77 -0
- package/multipart/interfaces/multipart-options.interface.js +1 -0
- package/multipart/multipart/append-field.util.d.ts +6 -0
- package/multipart/multipart/append-field.util.js +112 -0
- package/multipart/multipart/multipart-interceptor.factory.d.ts +13 -0
- package/multipart/multipart/multipart-interceptor.factory.js +130 -0
- package/multipart/multipart/multipart.constants.d.ts +55 -0
- package/multipart/multipart/multipart.constants.js +74 -0
- package/multipart/multipart/multipart.error.d.ts +10 -0
- package/multipart/multipart/multipart.error.js +15 -0
- package/multipart/multipart/multipart.parser.d.ts +39 -0
- package/multipart/multipart/multipart.parser.js +369 -0
- package/multipart/multipart/multipart.utils.d.ts +28 -0
- package/multipart/multipart/multipart.utils.js +54 -0
- package/multipart/multipart.constants.d.ts +1 -0
- package/multipart/multipart.constants.js +1 -0
- package/multipart/multipart.module.d.ts +18 -0
- package/multipart/multipart.module.js +75 -0
- package/multipart/storage/disk.storage.d.ts +24 -0
- package/multipart/storage/disk.storage.js +58 -0
- package/multipart/storage/index.d.ts +2 -0
- package/multipart/storage/index.js +2 -0
- package/multipart/storage/memory.storage.d.ts +8 -0
- package/multipart/storage/memory.storage.js +23 -0
- package/package.json +8 -3
|
@@ -0,0 +1,369 @@
|
|
|
1
|
+
import { BadRequestException, PayloadTooLargeException } from '@nestjs/common';
|
|
2
|
+
import { diskStorage } from '../storage/disk.storage.js';
|
|
3
|
+
import { memoryStorage } from '../storage/memory.storage.js';
|
|
4
|
+
import { appendField } from './append-field.util.js';
|
|
5
|
+
import { busboyExceptions, MISSING_PLUGIN_MESSAGE, multerExceptions, } from './multipart.constants.js';
|
|
6
|
+
import { MultipartError } from './multipart.error.js';
|
|
7
|
+
import { transformException } from './multipart.utils.js';
|
|
8
|
+
/** Same resolution order as multer: `storage`, then `dest`, then memory. */
|
|
9
|
+
export function resolveStorage(options) {
|
|
10
|
+
if (options.storage) {
|
|
11
|
+
return options.storage;
|
|
12
|
+
}
|
|
13
|
+
if (options.dest) {
|
|
14
|
+
return diskStorage({ destination: options.dest });
|
|
15
|
+
}
|
|
16
|
+
return memoryStorage();
|
|
17
|
+
}
|
|
18
|
+
function assertPluginRegistered(req) {
|
|
19
|
+
if (typeof req.isMultipart !== 'function') {
|
|
20
|
+
throw new Error(MISSING_PLUGIN_MESSAGE);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Per-route options handed to `req.parts()`; `@fastify/multipart` deep-merges
|
|
25
|
+
* them over the options the plugin was registered with. `@fastify/busboy`
|
|
26
|
+
* treats `fileSize` and `parts` as "max allowed" (it fires once a limit is
|
|
27
|
+
* exceeded), so unlike multer there is no `+1` adjustment. Truncation is
|
|
28
|
+
* detected here (`file.truncated`), so the plugin's own throw is disabled.
|
|
29
|
+
*/
|
|
30
|
+
function toBusboyConfig(limits, options) {
|
|
31
|
+
const config = { throwFileSizeLimit: false };
|
|
32
|
+
if (limits) {
|
|
33
|
+
config.limits = { ...limits };
|
|
34
|
+
}
|
|
35
|
+
if (options.preservePath !== undefined) {
|
|
36
|
+
config.preservePath = options.preservePath;
|
|
37
|
+
}
|
|
38
|
+
if (options.defParamCharset) {
|
|
39
|
+
config.defParamCharset = options.defParamCharset;
|
|
40
|
+
}
|
|
41
|
+
return config;
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* `file()` and `files()` are methods `@fastify/multipart` decorates the
|
|
45
|
+
* request prototype with. An own property shadows them for this request only,
|
|
46
|
+
* which is where `@UploadedFile()` / `@UploadedFiles()` read from on Fastify.
|
|
47
|
+
*/
|
|
48
|
+
function setOnRequest(req, key, value) {
|
|
49
|
+
req[key] = value;
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Shadows both methods up front, so that the one this interceptor does not
|
|
53
|
+
* populate reads `undefined`, as on Express, rather than the plugin's method.
|
|
54
|
+
*/
|
|
55
|
+
function resetFiles(req) {
|
|
56
|
+
setOnRequest(req, 'file', undefined);
|
|
57
|
+
setOnRequest(req, 'files', undefined);
|
|
58
|
+
}
|
|
59
|
+
function toIncomingFile(part) {
|
|
60
|
+
const file = {
|
|
61
|
+
fieldname: part.fieldname,
|
|
62
|
+
originalname: decodeFormDataName(part.filename),
|
|
63
|
+
encoding: part.encoding,
|
|
64
|
+
mimetype: part.mimetype,
|
|
65
|
+
};
|
|
66
|
+
Object.defineProperty(file, 'stream', {
|
|
67
|
+
value: part.file,
|
|
68
|
+
enumerable: false,
|
|
69
|
+
configurable: true,
|
|
70
|
+
});
|
|
71
|
+
return file;
|
|
72
|
+
}
|
|
73
|
+
function applyFileFilter(options, req, file) {
|
|
74
|
+
if (!options.fileFilter) {
|
|
75
|
+
return Promise.resolve(true);
|
|
76
|
+
}
|
|
77
|
+
return new Promise((resolve, reject) => options.fileFilter(req, file, (err, accept) => err ? reject(err) : resolve(accept)));
|
|
78
|
+
}
|
|
79
|
+
const ABORTED = Symbol('aborted');
|
|
80
|
+
/**
|
|
81
|
+
* Runs the storage engine. Resolves `ABORTED` when `@fastify/multipart`
|
|
82
|
+
* destroys the file stream mid-way (it does so, without an error, when the
|
|
83
|
+
* client disconnects or a limit is hit), because a storage engine waiting
|
|
84
|
+
* for `end` would otherwise never call back. The actual reason is then read
|
|
85
|
+
* from the parts iterator. A stream destroyed with an error was destroyed by
|
|
86
|
+
* the engine (e.g. by a `pipeline()` to a file that cannot be written), which
|
|
87
|
+
* reports that error itself.
|
|
88
|
+
*
|
|
89
|
+
* Whatever the engine stores after an abort is removed once it calls back,
|
|
90
|
+
* as multer does: what it stored when it succeeds, and the file at `path`
|
|
91
|
+
* (which engines set before writing, by multer's convention) when it fails.
|
|
92
|
+
*/
|
|
93
|
+
function storeFile(storage, req, file) {
|
|
94
|
+
return new Promise((resolve, reject) => {
|
|
95
|
+
const stream = file.stream;
|
|
96
|
+
if (stream.destroyed) {
|
|
97
|
+
return resolve(ABORTED);
|
|
98
|
+
}
|
|
99
|
+
let aborted = false;
|
|
100
|
+
const onClose = () => {
|
|
101
|
+
if (!stream.readableEnded && !stream.errored) {
|
|
102
|
+
aborted = true;
|
|
103
|
+
resolve(ABORTED);
|
|
104
|
+
}
|
|
105
|
+
};
|
|
106
|
+
stream.once('close', onClose);
|
|
107
|
+
storage._handleFile(req, file, (err, info) => {
|
|
108
|
+
stream.off('close', onClose);
|
|
109
|
+
if (!aborted) {
|
|
110
|
+
return err ? reject(err) : resolve(info ?? {});
|
|
111
|
+
}
|
|
112
|
+
const stored = Object.assign(file, err ? {} : info);
|
|
113
|
+
if (!err || stored.path) {
|
|
114
|
+
removeFiles(storage, req, [stored]).catch(() => undefined);
|
|
115
|
+
}
|
|
116
|
+
});
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
function removeFiles(storage, req, files) {
|
|
120
|
+
return Promise.all(files.map(file => new Promise(resolve => storage._removeFile(req, file, () => resolve()))));
|
|
121
|
+
}
|
|
122
|
+
/**
|
|
123
|
+
* Stops parsing and discards the rest of the body, so that an error response
|
|
124
|
+
* does not race a client that is still uploading (multer does the same
|
|
125
|
+
* before it reports an error).
|
|
126
|
+
*/
|
|
127
|
+
export function drainRequest(raw) {
|
|
128
|
+
raw.unpipe();
|
|
129
|
+
if (raw.readableEnded || raw.destroyed) {
|
|
130
|
+
return Promise.resolve();
|
|
131
|
+
}
|
|
132
|
+
return new Promise(resolve => {
|
|
133
|
+
const done = () => resolve();
|
|
134
|
+
raw.once('end', done);
|
|
135
|
+
raw.once('close', done);
|
|
136
|
+
raw.once('error', done);
|
|
137
|
+
raw.resume();
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
/**
|
|
141
|
+
* Rejects limits that are not a non-negative integer or `Infinity`, as
|
|
142
|
+
* multer does: busboy compares most limits with strict equality, so such a
|
|
143
|
+
* value would silently disable the limit.
|
|
144
|
+
*/
|
|
145
|
+
export function validateLimits(limits) {
|
|
146
|
+
for (const [key, value] of Object.entries(limits ?? {})) {
|
|
147
|
+
if (value == null ||
|
|
148
|
+
(Number.isInteger(value) && value >= 0) ||
|
|
149
|
+
value === Infinity) {
|
|
150
|
+
continue;
|
|
151
|
+
}
|
|
152
|
+
throw new TypeError(`Expected limits.${key} to be a non-negative integer or Infinity`);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
function resolveLimits(req, options) {
|
|
156
|
+
if (typeof options.limits !== 'function') {
|
|
157
|
+
return options.limits;
|
|
158
|
+
}
|
|
159
|
+
const limits = options.limits(req);
|
|
160
|
+
validateLimits(limits);
|
|
161
|
+
return limits;
|
|
162
|
+
}
|
|
163
|
+
/**
|
|
164
|
+
* Reverses the escapes browsers apply to form field names (`%0A`, `%0D`,
|
|
165
|
+
* `%22`), as multer does.
|
|
166
|
+
*/
|
|
167
|
+
function decodeFormDataName(name) {
|
|
168
|
+
return name.replace(/%0A|%0D|%22/gi, match => {
|
|
169
|
+
switch (match.toUpperCase()) {
|
|
170
|
+
case '%0A':
|
|
171
|
+
return '\n';
|
|
172
|
+
case '%0D':
|
|
173
|
+
return '\r';
|
|
174
|
+
default:
|
|
175
|
+
return '"';
|
|
176
|
+
}
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
/**
|
|
180
|
+
* Decodes the part's field name, and returns the name as it arrived.
|
|
181
|
+
*/
|
|
182
|
+
function decodeFieldName(part) {
|
|
183
|
+
if (part.fieldname == null) {
|
|
184
|
+
throw new MultipartError('MISSING_FIELD_NAME');
|
|
185
|
+
}
|
|
186
|
+
const rawName = part.fieldname;
|
|
187
|
+
part.fieldname = decodeFormDataName(rawName);
|
|
188
|
+
return rawName;
|
|
189
|
+
}
|
|
190
|
+
/**
|
|
191
|
+
* busboy does not enforce `fieldNameSize` for multipart bodies, so (like
|
|
192
|
+
* multer) it is checked here, against the name as it arrived.
|
|
193
|
+
*/
|
|
194
|
+
function checkFieldNameSize(rawName, limits) {
|
|
195
|
+
if (typeof limits?.fieldNameSize === 'number' &&
|
|
196
|
+
rawName.length > limits.fieldNameSize) {
|
|
197
|
+
throw new MultipartError('LIMIT_FIELD_KEY');
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
/**
|
|
201
|
+
* Adds a text field to `body`, with multer's checks, in multer's order.
|
|
202
|
+
*/
|
|
203
|
+
function appendTextField(body, part, limits) {
|
|
204
|
+
const rawName = decodeFieldName(part);
|
|
205
|
+
if (part.fieldnameTruncated) {
|
|
206
|
+
throw new MultipartError('LIMIT_FIELD_KEY');
|
|
207
|
+
}
|
|
208
|
+
if (part.valueTruncated) {
|
|
209
|
+
throw new MultipartError('LIMIT_FIELD_VALUE', part.fieldname);
|
|
210
|
+
}
|
|
211
|
+
checkFieldNameSize(rawName, limits);
|
|
212
|
+
if (!appendField(body, part.fieldname, part.value)) {
|
|
213
|
+
throw new MultipartError('INVALID_FIELD_NAME', part.fieldname);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
/**
|
|
217
|
+
* Returns the stream of a file part, with multer's checks, in multer's
|
|
218
|
+
* order, or `undefined` for a part without a file name (an empty file
|
|
219
|
+
* input), which multer ignores.
|
|
220
|
+
*/
|
|
221
|
+
function readFilePart(part, limits) {
|
|
222
|
+
const rawName = decodeFieldName(part);
|
|
223
|
+
if (!part.filename) {
|
|
224
|
+
part.file.resume();
|
|
225
|
+
return undefined;
|
|
226
|
+
}
|
|
227
|
+
checkFieldNameSize(rawName, limits);
|
|
228
|
+
return part.file;
|
|
229
|
+
}
|
|
230
|
+
/**
|
|
231
|
+
* Parses the multipart body with multer's semantics and populates
|
|
232
|
+
* `req.body` and `req.file` / `req.files`. Requests that are not multipart
|
|
233
|
+
* are passed through untouched, as multer does.
|
|
234
|
+
*/
|
|
235
|
+
export async function processMultipart(request, spec, options, storage) {
|
|
236
|
+
const req = request;
|
|
237
|
+
assertPluginRegistered(req);
|
|
238
|
+
resetFiles(req);
|
|
239
|
+
if (!req.isMultipart()) {
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
const filesLeft = new Map();
|
|
243
|
+
if (spec.fields !== 'ANY') {
|
|
244
|
+
for (const field of spec.fields) {
|
|
245
|
+
filesLeft.set(field.name, field.maxCount ?? Infinity);
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
const body = Object.create(null);
|
|
249
|
+
setOnRequest(req, 'body', body);
|
|
250
|
+
const stored = [];
|
|
251
|
+
try {
|
|
252
|
+
const limits = resolveLimits(req, options);
|
|
253
|
+
const iterator = req
|
|
254
|
+
.parts(toBusboyConfig(limits, options))[Symbol.asyncIterator]();
|
|
255
|
+
for (let result = await iterator.next(); !result.done; result = await iterator.next()) {
|
|
256
|
+
const part = result.value;
|
|
257
|
+
if (part.type === 'field') {
|
|
258
|
+
appendTextField(body, part, limits);
|
|
259
|
+
continue;
|
|
260
|
+
}
|
|
261
|
+
const stream = readFilePart(part, limits);
|
|
262
|
+
if (!stream) {
|
|
263
|
+
continue;
|
|
264
|
+
}
|
|
265
|
+
const left = spec.fields === 'ANY' ? Infinity : (filesLeft.get(part.fieldname) ?? 0);
|
|
266
|
+
if (left <= 0) {
|
|
267
|
+
throw new MultipartError('LIMIT_UNEXPECTED_FILE', part.fieldname);
|
|
268
|
+
}
|
|
269
|
+
const incoming = toIncomingFile(part);
|
|
270
|
+
if (!(await applyFileFilter(options, req, incoming))) {
|
|
271
|
+
stream.resume();
|
|
272
|
+
continue;
|
|
273
|
+
}
|
|
274
|
+
filesLeft.set(part.fieldname, left - 1);
|
|
275
|
+
const info = await storeFile(storage, req, incoming);
|
|
276
|
+
if (info === ABORTED) {
|
|
277
|
+
// Rejects with what aborted the stream: a limit, or the client
|
|
278
|
+
// disconnecting.
|
|
279
|
+
await iterator.next();
|
|
280
|
+
throw new BadRequestException(busboyExceptions.MULTIPART_UNEXPECTED_END_OF_FILE);
|
|
281
|
+
}
|
|
282
|
+
const file = { ...incoming, ...info };
|
|
283
|
+
stored.push(file);
|
|
284
|
+
// busboy truncates the file instead of failing; multer rejects it.
|
|
285
|
+
if (stream.truncated) {
|
|
286
|
+
throw new MultipartError('LIMIT_FILE_SIZE', part.fieldname);
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
catch (err) {
|
|
291
|
+
await removeFiles(storage, req, stored);
|
|
292
|
+
await drainRequest(req.raw);
|
|
293
|
+
throw transformException(err);
|
|
294
|
+
}
|
|
295
|
+
switch (spec.strategy) {
|
|
296
|
+
case 'VALUE':
|
|
297
|
+
setOnRequest(req, 'file', stored[0]);
|
|
298
|
+
break;
|
|
299
|
+
case 'ARRAY':
|
|
300
|
+
setOnRequest(req, 'files', stored);
|
|
301
|
+
break;
|
|
302
|
+
case 'OBJECT': {
|
|
303
|
+
const grouped = Object.create(null);
|
|
304
|
+
for (const file of stored) {
|
|
305
|
+
(grouped[file.fieldname] ??= []).push(file);
|
|
306
|
+
}
|
|
307
|
+
setOnRequest(req, 'files', grouped);
|
|
308
|
+
break;
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
/**
|
|
313
|
+
* Parses up to and including the first file in `fieldName`, then stops and
|
|
314
|
+
* hands that file's stream to the route handler (`req.file.stream`). Text
|
|
315
|
+
* fields sent before the file are in `req.body`; anything after it is not
|
|
316
|
+
* parsed.
|
|
317
|
+
*/
|
|
318
|
+
export async function processStream(request, fieldName, options) {
|
|
319
|
+
const req = request;
|
|
320
|
+
assertPluginRegistered(req);
|
|
321
|
+
resetFiles(req);
|
|
322
|
+
if (!req.isMultipart()) {
|
|
323
|
+
return;
|
|
324
|
+
}
|
|
325
|
+
const body = Object.create(null);
|
|
326
|
+
setOnRequest(req, 'body', body);
|
|
327
|
+
try {
|
|
328
|
+
const limits = resolveLimits(req, options);
|
|
329
|
+
const iterator = req
|
|
330
|
+
.parts(toBusboyConfig(limits, options))[Symbol.asyncIterator]();
|
|
331
|
+
for (let result = await iterator.next(); !result.done; result = await iterator.next()) {
|
|
332
|
+
const part = result.value;
|
|
333
|
+
if (part.type === 'field') {
|
|
334
|
+
appendTextField(body, part, limits);
|
|
335
|
+
continue;
|
|
336
|
+
}
|
|
337
|
+
const stream = readFilePart(part, limits);
|
|
338
|
+
if (!stream) {
|
|
339
|
+
continue;
|
|
340
|
+
}
|
|
341
|
+
if (part.fieldname !== fieldName) {
|
|
342
|
+
throw new MultipartError('LIMIT_UNEXPECTED_FILE', part.fieldname);
|
|
343
|
+
}
|
|
344
|
+
const incoming = toIncomingFile(part);
|
|
345
|
+
if (!(await applyFileFilter(options, req, incoming))) {
|
|
346
|
+
stream.resume();
|
|
347
|
+
continue;
|
|
348
|
+
}
|
|
349
|
+
// busboy would silently truncate; fail the consumer's read instead.
|
|
350
|
+
// The limit may have been hit already: busboy parses the part as far
|
|
351
|
+
// as the chunk at hand goes before the part reaches this loop.
|
|
352
|
+
const rejectFile = () => stream.destroy(new PayloadTooLargeException(multerExceptions.LIMIT_FILE_SIZE));
|
|
353
|
+
if (stream.truncated) {
|
|
354
|
+
rejectFile();
|
|
355
|
+
}
|
|
356
|
+
else {
|
|
357
|
+
stream.once('limit', rejectFile);
|
|
358
|
+
}
|
|
359
|
+
const file = { ...incoming, stream };
|
|
360
|
+
setOnRequest(req, 'file', file);
|
|
361
|
+
// The iterator is left suspended: the handler now owns the stream.
|
|
362
|
+
return;
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
catch (err) {
|
|
366
|
+
await drainRequest(req.raw);
|
|
367
|
+
throw transformException(err);
|
|
368
|
+
}
|
|
369
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { HttpException } from '@nestjs/common';
|
|
2
|
+
import type { MultipartModuleOptions, MultipartOptions } from '../interfaces/index.js';
|
|
3
|
+
/**
|
|
4
|
+
* Merges module-level (global) options with interceptor-level (local)
|
|
5
|
+
* options, with the same rule as platform-express's `mergeMulterOptions`:
|
|
6
|
+
* `limits` is merged key-by-key when both sides define it as an object, and
|
|
7
|
+
* the local value wins outright otherwise.
|
|
8
|
+
*/
|
|
9
|
+
export declare function mergeMultipartOptions(options?: MultipartModuleOptions, localOptions?: MultipartOptions): MultipartModuleOptions;
|
|
10
|
+
/**
|
|
11
|
+
* Maps parser errors to the same HTTP exceptions (status and message)
|
|
12
|
+
* platform-express's `transformException` produces for multer, so clients
|
|
13
|
+
* cannot tell the adapters apart.
|
|
14
|
+
*
|
|
15
|
+
* Errors raised by `@fastify/multipart` are identified by their `code`; only
|
|
16
|
+
* `@fastify/busboy`'s errors, which carry no code, are matched by message.
|
|
17
|
+
*/
|
|
18
|
+
export declare function transformException(error: (Error & {
|
|
19
|
+
code?: string;
|
|
20
|
+
part?: {
|
|
21
|
+
fieldname?: string;
|
|
22
|
+
};
|
|
23
|
+
}) | undefined): HttpException | (Error & {
|
|
24
|
+
code?: string;
|
|
25
|
+
part?: {
|
|
26
|
+
fieldname?: string;
|
|
27
|
+
};
|
|
28
|
+
}) | undefined;
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { BadRequestException, HttpException, PayloadTooLargeException, } from '@nestjs/common';
|
|
2
|
+
import { isPlainObject } from '@nestjs/common/internal';
|
|
3
|
+
import { busboyExceptions, fastifyBusboyMessages, fastifyMultipartBadRequestCodes, fastifyMultipartErrorCodes, } from './multipart.constants.js';
|
|
4
|
+
import { MultipartError } from './multipart.error.js';
|
|
5
|
+
/**
|
|
6
|
+
* Merges module-level (global) options with interceptor-level (local)
|
|
7
|
+
* options, with the same rule as platform-express's `mergeMulterOptions`:
|
|
8
|
+
* `limits` is merged key-by-key when both sides define it as an object, and
|
|
9
|
+
* the local value wins outright otherwise.
|
|
10
|
+
*/
|
|
11
|
+
export function mergeMultipartOptions(options = {}, localOptions = {}) {
|
|
12
|
+
const merged = { ...options, ...localOptions };
|
|
13
|
+
if (isPlainObject(options.limits) && isPlainObject(localOptions.limits)) {
|
|
14
|
+
merged.limits = { ...options.limits, ...localOptions.limits };
|
|
15
|
+
}
|
|
16
|
+
return merged;
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Maps parser errors to the same HTTP exceptions (status and message)
|
|
20
|
+
* platform-express's `transformException` produces for multer, so clients
|
|
21
|
+
* cannot tell the adapters apart.
|
|
22
|
+
*
|
|
23
|
+
* Errors raised by `@fastify/multipart` are identified by their `code`; only
|
|
24
|
+
* `@fastify/busboy`'s errors, which carry no code, are matched by message.
|
|
25
|
+
*/
|
|
26
|
+
export function transformException(error) {
|
|
27
|
+
if (!error || error instanceof HttpException) {
|
|
28
|
+
return error;
|
|
29
|
+
}
|
|
30
|
+
if (isFastifyMultipartLimitCode(error.code)) {
|
|
31
|
+
error = new MultipartError(fastifyMultipartErrorCodes[error.code], error.part?.fieldname);
|
|
32
|
+
}
|
|
33
|
+
if (error instanceof MultipartError) {
|
|
34
|
+
if (error.code === 'LIMIT_FILE_SIZE') {
|
|
35
|
+
return new PayloadTooLargeException(error.message);
|
|
36
|
+
}
|
|
37
|
+
return new BadRequestException(error.field ? `${error.message} - ${error.field}` : error.message);
|
|
38
|
+
}
|
|
39
|
+
if (error.code && fastifyMultipartBadRequestCodes.includes(error.code)) {
|
|
40
|
+
return new BadRequestException(error.message);
|
|
41
|
+
}
|
|
42
|
+
const message = error.message ?? '';
|
|
43
|
+
if (message === fastifyBusboyMessages.BOUNDARY_NOT_FOUND) {
|
|
44
|
+
return new BadRequestException(busboyExceptions.MULTIPART_BOUNDARY_NOT_FOUND);
|
|
45
|
+
}
|
|
46
|
+
if (message === fastifyBusboyMessages.UNEXPECTED_END_OF_FORM ||
|
|
47
|
+
message.includes(fastifyBusboyMessages.PART_TERMINATED_EARLY)) {
|
|
48
|
+
return new BadRequestException(busboyExceptions.MULTIPART_UNEXPECTED_END_OF_FORM);
|
|
49
|
+
}
|
|
50
|
+
return error;
|
|
51
|
+
}
|
|
52
|
+
function isFastifyMultipartLimitCode(code) {
|
|
53
|
+
return (typeof code === 'string' && Object.hasOwn(fastifyMultipartErrorCodes, code));
|
|
54
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare const MULTIPART_MODULE_ID = "MULTIPART_MODULE_ID";
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export const MULTIPART_MODULE_ID = 'MULTIPART_MODULE_ID';
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { type DynamicModule } from '@nestjs/common';
|
|
2
|
+
import type { MultipartModuleAsyncOptions, MultipartModuleOptions } from './interfaces/files-upload-module.interface.js';
|
|
3
|
+
/**
|
|
4
|
+
* Provides default options for every upload interceptor in the importing
|
|
5
|
+
* module. The Fastify counterpart of platform-express's `MulterModule`.
|
|
6
|
+
*
|
|
7
|
+
* It does not configure the `@fastify/multipart` plugin itself (which the
|
|
8
|
+
* FastifyAdapter registers); pass plugin-wide options through the
|
|
9
|
+
* adapter's `multipart` option.
|
|
10
|
+
*
|
|
11
|
+
* @publicApi
|
|
12
|
+
*/
|
|
13
|
+
export declare class MultipartModule {
|
|
14
|
+
static register(options?: MultipartModuleOptions): DynamicModule;
|
|
15
|
+
static registerAsync(options: MultipartModuleAsyncOptions): DynamicModule;
|
|
16
|
+
private static createAsyncProviders;
|
|
17
|
+
private static createAsyncOptionsProvider;
|
|
18
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
var MultipartModule_1;
|
|
2
|
+
import { __decorate } from "tslib";
|
|
3
|
+
import { Module } from '@nestjs/common';
|
|
4
|
+
import { randomStringGenerator } from '@nestjs/common/internal';
|
|
5
|
+
import { MULTIPART_MODULE_OPTIONS } from './files.constants.js';
|
|
6
|
+
import { MULTIPART_MODULE_ID } from './multipart.constants.js';
|
|
7
|
+
/**
|
|
8
|
+
* Provides default options for every upload interceptor in the importing
|
|
9
|
+
* module. The Fastify counterpart of platform-express's `MulterModule`.
|
|
10
|
+
*
|
|
11
|
+
* It does not configure the `@fastify/multipart` plugin itself (which the
|
|
12
|
+
* FastifyAdapter registers); pass plugin-wide options through the
|
|
13
|
+
* adapter's `multipart` option.
|
|
14
|
+
*
|
|
15
|
+
* @publicApi
|
|
16
|
+
*/
|
|
17
|
+
let MultipartModule = MultipartModule_1 = class MultipartModule {
|
|
18
|
+
static register(options = {}) {
|
|
19
|
+
return {
|
|
20
|
+
module: MultipartModule_1,
|
|
21
|
+
providers: [
|
|
22
|
+
{ provide: MULTIPART_MODULE_OPTIONS, useFactory: () => options },
|
|
23
|
+
{
|
|
24
|
+
provide: MULTIPART_MODULE_ID,
|
|
25
|
+
useValue: randomStringGenerator(),
|
|
26
|
+
},
|
|
27
|
+
],
|
|
28
|
+
exports: [MULTIPART_MODULE_OPTIONS],
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
static registerAsync(options) {
|
|
32
|
+
return {
|
|
33
|
+
module: MultipartModule_1,
|
|
34
|
+
imports: options.imports,
|
|
35
|
+
providers: [
|
|
36
|
+
...this.createAsyncProviders(options),
|
|
37
|
+
{
|
|
38
|
+
provide: MULTIPART_MODULE_ID,
|
|
39
|
+
useValue: randomStringGenerator(),
|
|
40
|
+
},
|
|
41
|
+
],
|
|
42
|
+
exports: [MULTIPART_MODULE_OPTIONS],
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
static createAsyncProviders(options) {
|
|
46
|
+
if (options.useExisting || options.useFactory) {
|
|
47
|
+
return [this.createAsyncOptionsProvider(options)];
|
|
48
|
+
}
|
|
49
|
+
return [
|
|
50
|
+
this.createAsyncOptionsProvider(options),
|
|
51
|
+
{
|
|
52
|
+
provide: options.useClass,
|
|
53
|
+
useClass: options.useClass,
|
|
54
|
+
},
|
|
55
|
+
];
|
|
56
|
+
}
|
|
57
|
+
static createAsyncOptionsProvider(options) {
|
|
58
|
+
if (options.useFactory) {
|
|
59
|
+
return {
|
|
60
|
+
provide: MULTIPART_MODULE_OPTIONS,
|
|
61
|
+
useFactory: options.useFactory,
|
|
62
|
+
inject: options.inject || [],
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
return {
|
|
66
|
+
provide: MULTIPART_MODULE_OPTIONS,
|
|
67
|
+
useFactory: async (optionsFactory) => optionsFactory.createMultipartOptions(),
|
|
68
|
+
inject: [options.useExisting || options.useClass],
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
};
|
|
72
|
+
MultipartModule = MultipartModule_1 = __decorate([
|
|
73
|
+
Module({})
|
|
74
|
+
], MultipartModule);
|
|
75
|
+
export { MultipartModule };
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { IncomingMultipartFile, MultipartStorageEngine } from '../interfaces/index.js';
|
|
2
|
+
type NameCallback = (error: Error | null, value: string) => void;
|
|
3
|
+
type NameResolver = (req: any, file: IncomingMultipartFile, callback: NameCallback) => void;
|
|
4
|
+
/**
|
|
5
|
+
* @publicApi
|
|
6
|
+
*/
|
|
7
|
+
export interface DiskStorageOptions {
|
|
8
|
+
/**
|
|
9
|
+
* The folder files are written to (Default: `os.tmpdir()`). A string is
|
|
10
|
+
* created when the storage engine is; a function must return a folder that
|
|
11
|
+
* already exists.
|
|
12
|
+
*/
|
|
13
|
+
destination?: string | NameResolver;
|
|
14
|
+
/** The file name within `destination` (Default: 32 random hex characters, no extension). */
|
|
15
|
+
filename?: NameResolver;
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Writes each file to disk, with multer's `diskStorage` defaults and file
|
|
19
|
+
* shape (`destination`, `filename`, `path`, `size`).
|
|
20
|
+
*
|
|
21
|
+
* @publicApi
|
|
22
|
+
*/
|
|
23
|
+
export declare function diskStorage(options?: DiskStorageOptions): MultipartStorageEngine;
|
|
24
|
+
export {};
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { randomBytes } from 'crypto';
|
|
2
|
+
import { createWriteStream, mkdirSync } from 'fs';
|
|
3
|
+
import { unlink } from 'fs/promises';
|
|
4
|
+
import { tmpdir } from 'os';
|
|
5
|
+
import { join } from 'path';
|
|
6
|
+
import { pipeline } from 'stream/promises';
|
|
7
|
+
function resolveName(resolver, req, file) {
|
|
8
|
+
return new Promise((resolve, reject) => resolver(req, file, (err, value) => (err ? reject(err) : resolve(value))));
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* Writes each file to disk, with multer's `diskStorage` defaults and file
|
|
12
|
+
* shape (`destination`, `filename`, `path`, `size`).
|
|
13
|
+
*
|
|
14
|
+
* @publicApi
|
|
15
|
+
*/
|
|
16
|
+
export function diskStorage(options = {}) {
|
|
17
|
+
let resolveDestination;
|
|
18
|
+
if (typeof options.destination === 'string') {
|
|
19
|
+
const destination = options.destination;
|
|
20
|
+
mkdirSync(destination, { recursive: true });
|
|
21
|
+
resolveDestination = (_req, _file, callback) => callback(null, destination);
|
|
22
|
+
}
|
|
23
|
+
else {
|
|
24
|
+
resolveDestination =
|
|
25
|
+
options.destination ??
|
|
26
|
+
((_req, _file, callback) => callback(null, tmpdir()));
|
|
27
|
+
}
|
|
28
|
+
const resolveFilename = options.filename ??
|
|
29
|
+
((_req, _file, callback) => callback(null, randomBytes(16).toString('hex')));
|
|
30
|
+
const store = async (req, file) => {
|
|
31
|
+
const destination = await resolveName(resolveDestination, req, file);
|
|
32
|
+
const filename = await resolveName(resolveFilename, req, file);
|
|
33
|
+
const path = join(destination, filename);
|
|
34
|
+
// As in multer, `path` is known to the file before it is written.
|
|
35
|
+
file.path = path;
|
|
36
|
+
const out = createWriteStream(path);
|
|
37
|
+
try {
|
|
38
|
+
await pipeline(file.stream, out);
|
|
39
|
+
}
|
|
40
|
+
catch (err) {
|
|
41
|
+
await unlink(path).catch(() => undefined);
|
|
42
|
+
throw err;
|
|
43
|
+
}
|
|
44
|
+
return { destination, filename, path, size: out.bytesWritten };
|
|
45
|
+
};
|
|
46
|
+
return {
|
|
47
|
+
_handleFile(req, file, callback) {
|
|
48
|
+
store(req, file).then(info => callback(null, info), err => callback(err));
|
|
49
|
+
},
|
|
50
|
+
_removeFile(_req, file, callback) {
|
|
51
|
+
const path = file.path;
|
|
52
|
+
delete file.destination;
|
|
53
|
+
delete file.filename;
|
|
54
|
+
delete file.path;
|
|
55
|
+
unlink(path).then(() => callback(null), err => callback(err));
|
|
56
|
+
},
|
|
57
|
+
};
|
|
58
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Buffers each file into `file.buffer`. The default storage engine, as in
|
|
3
|
+
* multer.
|
|
4
|
+
*
|
|
5
|
+
* @publicApi
|
|
6
|
+
*/
|
|
7
|
+
export function memoryStorage() {
|
|
8
|
+
return {
|
|
9
|
+
_handleFile(_req, file, callback) {
|
|
10
|
+
const chunks = [];
|
|
11
|
+
file.stream.on('data', (chunk) => chunks.push(chunk));
|
|
12
|
+
file.stream.once('error', callback);
|
|
13
|
+
file.stream.once('end', () => {
|
|
14
|
+
const buffer = Buffer.concat(chunks);
|
|
15
|
+
callback(null, { buffer, size: buffer.length });
|
|
16
|
+
});
|
|
17
|
+
},
|
|
18
|
+
_removeFile(_req, file, callback) {
|
|
19
|
+
delete file.buffer;
|
|
20
|
+
callback(null);
|
|
21
|
+
},
|
|
22
|
+
};
|
|
23
|
+
}
|