@usehenri/uploads 0.0.0 → 1.2.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/CHANGELOG.md +117 -0
- package/LICENSE +21 -0
- package/README.md +8 -1
- package/index.js +42 -0
- package/module.js +8 -0
- package/package.json +56 -10
- package/src/bytes.js +86 -0
- package/src/config.js +243 -0
- package/src/download.js +126 -0
- package/src/errors.js +92 -0
- package/src/file.js +204 -0
- package/src/module.js +694 -0
- package/src/multipart.js +696 -0
- package/src/names.js +191 -0
- package/src/signing.js +333 -0
- package/src/sniff.js +292 -0
- package/src/storage/index.js +125 -0
- package/src/storage/local.js +345 -0
- package/src/variants.js +392 -0
package/src/multipart.js
ADDED
|
@@ -0,0 +1,696 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reading a multipart body, within limits that exist before the first byte.
|
|
3
|
+
*
|
|
4
|
+
* Every bound below is handed to the parser, not checked afterwards. That is
|
|
5
|
+
* the only arrangement that means anything: a size checked once the file is
|
|
6
|
+
* on disk has already let the disk fill, and a count checked once the parts
|
|
7
|
+
* are parsed has already paid for parsing them. busboy stops at the limit
|
|
8
|
+
* and says which one it hit; henri turns that into a refusal and removes
|
|
9
|
+
* what had been written so far.
|
|
10
|
+
*
|
|
11
|
+
* Three of the bounds are henri's own rather than busboy's:
|
|
12
|
+
*
|
|
13
|
+
* - the **total** size of the body. `Content-Length` is checked before the
|
|
14
|
+
* parser is even built, and then counted again as the bytes arrive, because
|
|
15
|
+
* a chunked request has no `Content-Length` to check and a request that has
|
|
16
|
+
* one is under no obligation to be honest about it.
|
|
17
|
+
* - the **type** of each file, from its bytes (see `sniff.js`).
|
|
18
|
+
* - the **name** of each file, which never reaches a path (see `names.js`).
|
|
19
|
+
*
|
|
20
|
+
* The parser runs before sessions and CSRF, because it has to: the `_csrf`
|
|
21
|
+
* field of a `multipart/form-data` form is inside the body, and the CSRF
|
|
22
|
+
* middleware reads `req.body`. That ordering is what makes an ordinary HTML
|
|
23
|
+
* upload form work at all, and it is why the limits above matter as much as
|
|
24
|
+
* they do -- they are what an unauthenticated request meets first.
|
|
25
|
+
*/
|
|
26
|
+
const busboy = require('busboy');
|
|
27
|
+
const { Transform } = require('node:stream');
|
|
28
|
+
const crypto = require('node:crypto');
|
|
29
|
+
const fs = require('node:fs');
|
|
30
|
+
const fsp = require('node:fs/promises');
|
|
31
|
+
const { pipeline } = require('node:stream/promises');
|
|
32
|
+
const debug = require('debug')('henri:uploads');
|
|
33
|
+
|
|
34
|
+
const { UploadError } = require('./errors');
|
|
35
|
+
const { allowed, SAMPLE, sniff } = require('./sniff');
|
|
36
|
+
const { covers } = require('./config');
|
|
37
|
+
const { fileOf } = require('./file');
|
|
38
|
+
const { orInfinity } = require('./bytes');
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Keys a form field never becomes, whatever it is called.
|
|
42
|
+
*
|
|
43
|
+
* The same list `req.permit()` refuses in core (`base/params.js`): a body
|
|
44
|
+
* ends up merged, assigned and serialized, and `__proto__` arriving as a
|
|
45
|
+
* form field is the oldest way to make all three interesting.
|
|
46
|
+
*/
|
|
47
|
+
const FORBIDDEN = new Set(['__proto__', 'constructor', 'prototype']);
|
|
48
|
+
|
|
49
|
+
/** How many header pairs one part may carry (busboy allows 2000) */
|
|
50
|
+
const HEADER_PAIRS = 100;
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* The size limits busboy takes are "stop at", not "refuse past": it
|
|
54
|
+
* truncates a part the moment it *reaches* the number, so a 5mb file under a
|
|
55
|
+
* 5mb limit is reported as truncated. henri hands it one byte more and reads
|
|
56
|
+
* the truncation as "larger than the limit", which is what the configuration
|
|
57
|
+
* says and what an application expects.
|
|
58
|
+
*
|
|
59
|
+
* @param {(number|false|null)} limit the configured limit
|
|
60
|
+
* @returns {number} what busboy is given
|
|
61
|
+
*/
|
|
62
|
+
const stopAt = (limit) => orInfinity(limit) + 1;
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Is this field name longer than the configuration allows?
|
|
66
|
+
*
|
|
67
|
+
* busboy only enforces `fieldNameSize` on urlencoded bodies -- in a
|
|
68
|
+
* multipart body the name comes out of a part header, and `nameTruncated`
|
|
69
|
+
* is always false there. So henri measures it.
|
|
70
|
+
*
|
|
71
|
+
* @param {string} name the field name
|
|
72
|
+
* @param {(number|false)} limit the configured limit
|
|
73
|
+
* @returns {boolean} true when it is too long
|
|
74
|
+
*/
|
|
75
|
+
const nameTooLong = (name, limit) =>
|
|
76
|
+
limit !== false && Buffer.byteLength(String(name), 'utf8') > limit;
|
|
77
|
+
|
|
78
|
+
/** How long a refused request is drained before its socket is closed (ms) */
|
|
79
|
+
const DRAIN_TIMEOUT = 5000;
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* How much more than the total limit a refused request may still send before
|
|
83
|
+
* its socket is closed. A form whose file was a little too big is drained to
|
|
84
|
+
* the end and reads its `413`; a deliberate flood is hung up on.
|
|
85
|
+
*/
|
|
86
|
+
const DRAIN_FACTOR = 2;
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Reads and throws away what is left of a refused request.
|
|
90
|
+
*
|
|
91
|
+
* A client told to stop is usually still sending, and answering while it
|
|
92
|
+
* writes is what turns a `413` into a connection reset on its side -- the
|
|
93
|
+
* status nobody sees is the status nobody fixes. So the rest of the body is
|
|
94
|
+
* read and discarded, which costs no memory and no disk, and the wait is
|
|
95
|
+
* bounded twice: `cap` bytes, and `ms` milliseconds. Past either, the socket
|
|
96
|
+
* is closed and the client is left to work out what happened, which is the
|
|
97
|
+
* right answer for something that was told no and kept sending.
|
|
98
|
+
*
|
|
99
|
+
* @param {Express.Request} req the request
|
|
100
|
+
* @param {object} [options={}] `{ cap, ms }`
|
|
101
|
+
* @param {(number|false)} [options.cap] how many more bytes to accept
|
|
102
|
+
* @param {number} [options.ms=DRAIN_TIMEOUT] how long to wait
|
|
103
|
+
* @returns {Promise<void>} resolves once the body is over, or the wait is
|
|
104
|
+
*/
|
|
105
|
+
function drain(req, { cap = false, ms = DRAIN_TIMEOUT } = {}) {
|
|
106
|
+
if (req.complete || req.destroyed || req.readableEnded) {
|
|
107
|
+
return Promise.resolve();
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
return new Promise((resolve) => {
|
|
111
|
+
const timer = setTimeout(() => {
|
|
112
|
+
req.destroy();
|
|
113
|
+
resolve();
|
|
114
|
+
}, ms);
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Stops waiting
|
|
118
|
+
*
|
|
119
|
+
* @returns {void}
|
|
120
|
+
*/
|
|
121
|
+
const done = () => {
|
|
122
|
+
clearTimeout(timer);
|
|
123
|
+
resolve();
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
let seen = 0;
|
|
127
|
+
|
|
128
|
+
timer.unref();
|
|
129
|
+
req.on('data', (chunk) => {
|
|
130
|
+
seen += chunk.length;
|
|
131
|
+
|
|
132
|
+
if (cap !== false && seen > cap) {
|
|
133
|
+
debug('a refused request kept sending: closing the socket');
|
|
134
|
+
req.destroy();
|
|
135
|
+
done();
|
|
136
|
+
}
|
|
137
|
+
});
|
|
138
|
+
req.on('end', done);
|
|
139
|
+
req.on('close', done);
|
|
140
|
+
req.on('error', done);
|
|
141
|
+
req.resume();
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Is this a body a multipart parser should read?
|
|
147
|
+
*
|
|
148
|
+
* @param {Express.Request} req the request
|
|
149
|
+
* @returns {boolean} true when the content type is multipart
|
|
150
|
+
*/
|
|
151
|
+
const isMultipart = (req) =>
|
|
152
|
+
/^multipart\//iu.test(String(req.headers['content-type'] || ''));
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* A stream that counts what goes through it and stops past a limit
|
|
156
|
+
*
|
|
157
|
+
* @param {(number|false)} limit the number of bytes allowed, or false
|
|
158
|
+
* @returns {Transform} the stream
|
|
159
|
+
*/
|
|
160
|
+
function meter(limit) {
|
|
161
|
+
let seen = 0;
|
|
162
|
+
|
|
163
|
+
return new Transform({
|
|
164
|
+
transform(chunk, encoding, done) {
|
|
165
|
+
seen += chunk.length;
|
|
166
|
+
|
|
167
|
+
if (limit !== false && seen > limit) {
|
|
168
|
+
return done(
|
|
169
|
+
new UploadError(
|
|
170
|
+
'TOTAL_TOO_LARGE',
|
|
171
|
+
`the request body is larger than the ${limit} bytes this application accepts`,
|
|
172
|
+
{ limit }
|
|
173
|
+
)
|
|
174
|
+
);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
return done(null, chunk);
|
|
178
|
+
},
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* A stream that hashes, measures and samples what goes through it
|
|
184
|
+
*
|
|
185
|
+
* @param {object} state where to record what it saw
|
|
186
|
+
* @returns {Transform} the stream
|
|
187
|
+
*/
|
|
188
|
+
function inspector(state) {
|
|
189
|
+
const hash = crypto.createHash('sha256');
|
|
190
|
+
const head = [];
|
|
191
|
+
let sampled = 0;
|
|
192
|
+
|
|
193
|
+
return new Transform({
|
|
194
|
+
flush(done) {
|
|
195
|
+
state.checksum = hash.digest('hex');
|
|
196
|
+
state.sample = Buffer.concat(head);
|
|
197
|
+
done();
|
|
198
|
+
},
|
|
199
|
+
transform(chunk, encoding, done) {
|
|
200
|
+
hash.update(chunk);
|
|
201
|
+
state.size += chunk.length;
|
|
202
|
+
|
|
203
|
+
if (sampled < SAMPLE) {
|
|
204
|
+
const wanted = chunk.subarray(0, SAMPLE - sampled);
|
|
205
|
+
|
|
206
|
+
head.push(wanted);
|
|
207
|
+
sampled += wanted.length;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
done(null, chunk);
|
|
211
|
+
},
|
|
212
|
+
});
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Adds a value to a body, the way a repeated form field becomes a list
|
|
217
|
+
*
|
|
218
|
+
* @param {object} body the body being built
|
|
219
|
+
* @param {string} name the field name
|
|
220
|
+
* @param {string} value the value
|
|
221
|
+
* @returns {void}
|
|
222
|
+
*/
|
|
223
|
+
function addField(body, name, value) {
|
|
224
|
+
if (FORBIDDEN.has(name)) {
|
|
225
|
+
debug('dropping the field %s', name);
|
|
226
|
+
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
if (!Object.prototype.hasOwnProperty.call(body, name)) {
|
|
231
|
+
body[name] = value;
|
|
232
|
+
|
|
233
|
+
return;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
body[name] = Array.isArray(body[name])
|
|
237
|
+
? [...body[name], value]
|
|
238
|
+
: [body[name], value];
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Reads one file part into the storage's temporary area
|
|
243
|
+
*
|
|
244
|
+
* @param {object} options the options
|
|
245
|
+
* @param {string} options.field the form field
|
|
246
|
+
* @param {stream.Readable} options.stream the part
|
|
247
|
+
* @param {object} options.info what busboy read from the part headers
|
|
248
|
+
* @param {number} options.order which part of the body it was
|
|
249
|
+
* @param {object} options.settings the normalized settings
|
|
250
|
+
* @param {object} options.storage the storage
|
|
251
|
+
* @returns {Promise<UploadedFile>} the file, on disk and typed
|
|
252
|
+
* @throws {UploadError} when the file is too large or of a refused type
|
|
253
|
+
*/
|
|
254
|
+
async function readFile({ field, info, order, settings, storage, stream }) {
|
|
255
|
+
const state = { checksum: '', sample: Buffer.alloc(0), size: 0 };
|
|
256
|
+
const temp = await storage.temp();
|
|
257
|
+
const target = fs.createWriteStream(temp.path, { flags: 'wx', mode: 0o600 });
|
|
258
|
+
let truncated = false;
|
|
259
|
+
|
|
260
|
+
stream.on('limit', () => {
|
|
261
|
+
truncated = true;
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
try {
|
|
265
|
+
await pipeline(stream, inspector(state), target);
|
|
266
|
+
} catch (error) {
|
|
267
|
+
await fsp.unlink(temp.path).catch(() => {});
|
|
268
|
+
throw error;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
if (truncated) {
|
|
272
|
+
await fsp.unlink(temp.path).catch(() => {});
|
|
273
|
+
throw new UploadError(
|
|
274
|
+
'FILE_TOO_LARGE',
|
|
275
|
+
`"${field}" is larger than the ${settings.maxFileSize} bytes this application accepts`,
|
|
276
|
+
{ field, limit: settings.maxFileSize }
|
|
277
|
+
);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
const declaredType = String(info.mimeType || '')
|
|
281
|
+
.toLowerCase()
|
|
282
|
+
.split(';')[0]
|
|
283
|
+
.trim();
|
|
284
|
+
const found = settings.sniff
|
|
285
|
+
? sniff(state.sample, state.size <= SAMPLE)
|
|
286
|
+
: { sniffed: false, type: declaredType || 'application/octet-stream' };
|
|
287
|
+
|
|
288
|
+
if (!allowed(found.type, settings.allow)) {
|
|
289
|
+
await fsp.unlink(temp.path).catch(() => {});
|
|
290
|
+
throw new UploadError(
|
|
291
|
+
'TYPE_NOT_ALLOWED',
|
|
292
|
+
`"${field}" is ${found.type}, which this application does not accept`,
|
|
293
|
+
{ allow: settings.allow, field, type: found.type }
|
|
294
|
+
);
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
return fileOf({
|
|
298
|
+
checksum: state.checksum,
|
|
299
|
+
declaredType: declaredType || null,
|
|
300
|
+
field,
|
|
301
|
+
maxFilenameLength: settings.maxFilenameLength,
|
|
302
|
+
name: info.filename,
|
|
303
|
+
order,
|
|
304
|
+
path: temp.path,
|
|
305
|
+
size: state.size,
|
|
306
|
+
sniffed: found.sniffed,
|
|
307
|
+
storage,
|
|
308
|
+
type: found.type,
|
|
309
|
+
});
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/**
|
|
313
|
+
* Reads a whole multipart body
|
|
314
|
+
*
|
|
315
|
+
* @param {Express.Request} req the request
|
|
316
|
+
* @param {object} options `{ collected, settings, storage }`
|
|
317
|
+
* @returns {Promise<object>} the fields, once every file is on disk
|
|
318
|
+
* @throws {UploadError} on any of the limits, or on a malformed body
|
|
319
|
+
*/
|
|
320
|
+
function collect(req, { collected, settings, storage }) {
|
|
321
|
+
return new Promise((resolve, reject) => {
|
|
322
|
+
const fields = {};
|
|
323
|
+
const pending = [];
|
|
324
|
+
let failure = null;
|
|
325
|
+
let done = false;
|
|
326
|
+
let parts = 0;
|
|
327
|
+
|
|
328
|
+
/**
|
|
329
|
+
* The first refusal wins; the rest of the body is drained so the client
|
|
330
|
+
* can read the answer instead of seeing its socket reset
|
|
331
|
+
*
|
|
332
|
+
* @param {Error} error what went wrong
|
|
333
|
+
* @returns {void}
|
|
334
|
+
*/
|
|
335
|
+
const fail = (error) => {
|
|
336
|
+
if (failure) {
|
|
337
|
+
return;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
failure = error;
|
|
341
|
+
req.unpipe(counter);
|
|
342
|
+
counter.unpipe(bus);
|
|
343
|
+
// Destroying the parser makes it emit `close`, which is the ordinary
|
|
344
|
+
// way this ends: it has to stop meaning "answer now", or the refusal
|
|
345
|
+
// goes out while the client is still writing and it reads a connection
|
|
346
|
+
// reset instead of the status
|
|
347
|
+
bus.off('close', finish);
|
|
348
|
+
bus.destroy();
|
|
349
|
+
drain(req, { cap: settings.maxTotalSize }).then(finish, finish);
|
|
350
|
+
};
|
|
351
|
+
|
|
352
|
+
/**
|
|
353
|
+
* Answers once every file has been written (or removed)
|
|
354
|
+
*
|
|
355
|
+
* @returns {void}
|
|
356
|
+
*/
|
|
357
|
+
const finish = () => {
|
|
358
|
+
if (done) {
|
|
359
|
+
return;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
done = true;
|
|
363
|
+
Promise.allSettled(pending).then(() =>
|
|
364
|
+
failure ? reject(failure) : resolve(fields)
|
|
365
|
+
);
|
|
366
|
+
};
|
|
367
|
+
|
|
368
|
+
let bus;
|
|
369
|
+
|
|
370
|
+
try {
|
|
371
|
+
bus = busboy({
|
|
372
|
+
defParamCharset: 'utf8',
|
|
373
|
+
headers: req.headers,
|
|
374
|
+
limits: {
|
|
375
|
+
fieldNameSize: stopAt(settings.maxFieldNameSize),
|
|
376
|
+
fieldSize: stopAt(settings.maxFieldSize),
|
|
377
|
+
fields: orInfinity(settings.maxFields),
|
|
378
|
+
fileSize: stopAt(settings.maxFileSize),
|
|
379
|
+
files: orInfinity(settings.maxFiles),
|
|
380
|
+
headerPairs: HEADER_PAIRS,
|
|
381
|
+
parts: orInfinity(
|
|
382
|
+
settings.maxFiles === false || settings.maxFields === false
|
|
383
|
+
? false
|
|
384
|
+
: settings.maxFiles + settings.maxFields
|
|
385
|
+
),
|
|
386
|
+
},
|
|
387
|
+
});
|
|
388
|
+
} catch (error) {
|
|
389
|
+
return reject(
|
|
390
|
+
new UploadError('MALFORMED_MULTIPART', error.message, {
|
|
391
|
+
cause: error.message,
|
|
392
|
+
})
|
|
393
|
+
);
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
const counter = meter(settings.maxTotalSize);
|
|
397
|
+
|
|
398
|
+
bus.on('field', (name, value, info) => {
|
|
399
|
+
if (info.nameTruncated || nameTooLong(name, settings.maxFieldNameSize)) {
|
|
400
|
+
return fail(
|
|
401
|
+
new UploadError(
|
|
402
|
+
'FIELD_NAME_TOO_LONG',
|
|
403
|
+
`a field name is longer than the ${settings.maxFieldNameSize} bytes this application accepts`,
|
|
404
|
+
{ limit: settings.maxFieldNameSize }
|
|
405
|
+
)
|
|
406
|
+
);
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
if (info.valueTruncated) {
|
|
410
|
+
return fail(
|
|
411
|
+
new UploadError(
|
|
412
|
+
'VALUE_TOO_LARGE',
|
|
413
|
+
`"${name}" is larger than the ${settings.maxFieldSize} bytes this application accepts`,
|
|
414
|
+
{ field: name, limit: settings.maxFieldSize }
|
|
415
|
+
)
|
|
416
|
+
);
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
return addField(fields, name, value);
|
|
420
|
+
});
|
|
421
|
+
|
|
422
|
+
bus.on('file', (name, stream, info) => {
|
|
423
|
+
if (failure || FORBIDDEN.has(name)) {
|
|
424
|
+
return stream.resume();
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
if (nameTooLong(name, settings.maxFieldNameSize)) {
|
|
428
|
+
stream.resume();
|
|
429
|
+
|
|
430
|
+
return fail(
|
|
431
|
+
new UploadError(
|
|
432
|
+
'FIELD_NAME_TOO_LONG',
|
|
433
|
+
`a field name is longer than the ${settings.maxFieldNameSize} bytes this application accepts`,
|
|
434
|
+
{ limit: settings.maxFieldNameSize }
|
|
435
|
+
)
|
|
436
|
+
);
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
const order = parts++;
|
|
440
|
+
|
|
441
|
+
return pending.push(
|
|
442
|
+
readFile({ field: name, info, order, settings, storage, stream }).then(
|
|
443
|
+
(file) => collected.push(file),
|
|
444
|
+
(error) => {
|
|
445
|
+
stream.resume();
|
|
446
|
+
fail(error);
|
|
447
|
+
}
|
|
448
|
+
)
|
|
449
|
+
);
|
|
450
|
+
});
|
|
451
|
+
|
|
452
|
+
bus.on('fieldsLimit', () =>
|
|
453
|
+
fail(
|
|
454
|
+
new UploadError(
|
|
455
|
+
'TOO_MANY_FIELDS',
|
|
456
|
+
`this application accepts ${settings.maxFields} fields in a form`,
|
|
457
|
+
{ limit: settings.maxFields }
|
|
458
|
+
)
|
|
459
|
+
)
|
|
460
|
+
);
|
|
461
|
+
|
|
462
|
+
bus.on('filesLimit', () =>
|
|
463
|
+
fail(
|
|
464
|
+
new UploadError(
|
|
465
|
+
'TOO_MANY_FILES',
|
|
466
|
+
`this application accepts ${settings.maxFiles} files in a request`,
|
|
467
|
+
{ limit: settings.maxFiles }
|
|
468
|
+
)
|
|
469
|
+
)
|
|
470
|
+
);
|
|
471
|
+
|
|
472
|
+
bus.on('partsLimit', () =>
|
|
473
|
+
fail(
|
|
474
|
+
new UploadError(
|
|
475
|
+
'TOO_MANY_FIELDS',
|
|
476
|
+
'this application accepts fewer parts in a request',
|
|
477
|
+
{ limit: settings.maxFields }
|
|
478
|
+
)
|
|
479
|
+
)
|
|
480
|
+
);
|
|
481
|
+
|
|
482
|
+
bus.on('error', (error) =>
|
|
483
|
+
fail(
|
|
484
|
+
error instanceof UploadError
|
|
485
|
+
? error
|
|
486
|
+
: new UploadError(
|
|
487
|
+
'MALFORMED_MULTIPART',
|
|
488
|
+
'the multipart body could not be read',
|
|
489
|
+
{
|
|
490
|
+
cause: error.message,
|
|
491
|
+
}
|
|
492
|
+
)
|
|
493
|
+
)
|
|
494
|
+
);
|
|
495
|
+
|
|
496
|
+
bus.on('close', finish);
|
|
497
|
+
|
|
498
|
+
counter.on('error', fail);
|
|
499
|
+
|
|
500
|
+
// A client that goes away half-way: the socket closes before the body is
|
|
501
|
+
// complete. `close` fires at the end of every request too, which is why
|
|
502
|
+
// `complete` is what is asked about rather than the event.
|
|
503
|
+
req.on('close', () => {
|
|
504
|
+
if (!req.complete) {
|
|
505
|
+
fail(
|
|
506
|
+
new UploadError('MALFORMED_MULTIPART', 'the request was abandoned', {
|
|
507
|
+
aborted: true,
|
|
508
|
+
})
|
|
509
|
+
);
|
|
510
|
+
}
|
|
511
|
+
});
|
|
512
|
+
|
|
513
|
+
return req.pipe(counter).pipe(bus);
|
|
514
|
+
});
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
/**
|
|
518
|
+
* `req.files`, `req.file()` and `req.permitFiles()`, on every request.
|
|
519
|
+
*
|
|
520
|
+
* They exist whether or not anything was uploaded, so a controller reads
|
|
521
|
+
* them without asking first -- the same reason `req.permit()` exists on a
|
|
522
|
+
* `GET`.
|
|
523
|
+
*
|
|
524
|
+
* @param {Express.Request} req the request
|
|
525
|
+
* @returns {Array<UploadedFile>} the list the sweep reads
|
|
526
|
+
*/
|
|
527
|
+
function decorate(req) {
|
|
528
|
+
const collected = [];
|
|
529
|
+
|
|
530
|
+
req.files = {};
|
|
531
|
+
req._uploads = collected;
|
|
532
|
+
|
|
533
|
+
/**
|
|
534
|
+
* The first file of a field, or null
|
|
535
|
+
*
|
|
536
|
+
* @param {string} field the form field
|
|
537
|
+
* @returns {?UploadedFile} the file
|
|
538
|
+
*/
|
|
539
|
+
req.file = (field) => (req.files[field] || [])[0] || null;
|
|
540
|
+
|
|
541
|
+
/**
|
|
542
|
+
* The listed file fields, as `req.permit()` does for the body: what was
|
|
543
|
+
* not asked for is not returned, and -- unlike a body field, which only
|
|
544
|
+
* costs memory until the request ends -- it is removed from the disk on
|
|
545
|
+
* the spot.
|
|
546
|
+
*
|
|
547
|
+
* @param {...(string|Array<string>)} fields the file fields to accept
|
|
548
|
+
* @returns {object} `{ [field]: Array<UploadedFile> }`, only the listed ones
|
|
549
|
+
*/
|
|
550
|
+
req.permitFiles = (...fields) => {
|
|
551
|
+
const wanted = new Set(
|
|
552
|
+
fields
|
|
553
|
+
.flat(Infinity)
|
|
554
|
+
.filter((field) => typeof field === 'string' && !FORBIDDEN.has(field))
|
|
555
|
+
);
|
|
556
|
+
const kept = {};
|
|
557
|
+
|
|
558
|
+
for (const [field, list] of Object.entries(req.files)) {
|
|
559
|
+
if (wanted.has(field)) {
|
|
560
|
+
kept[field] = list;
|
|
561
|
+
continue;
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
for (const file of list) {
|
|
565
|
+
file
|
|
566
|
+
.discard()
|
|
567
|
+
.catch((error) => debug('unable to discard: %s', error.message));
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
req.files = kept;
|
|
572
|
+
|
|
573
|
+
return kept;
|
|
574
|
+
};
|
|
575
|
+
|
|
576
|
+
return collected;
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
/**
|
|
580
|
+
* Removes, when the response closes, every file the request did not keep.
|
|
581
|
+
*
|
|
582
|
+
* `close` fires on an answered request, on a refused one, on one the
|
|
583
|
+
* timeout answered `503` and on one whose client went away, which is the
|
|
584
|
+
* whole list of ways a request ends. A file `store()` moved into the
|
|
585
|
+
* storage is already released and is not touched.
|
|
586
|
+
*
|
|
587
|
+
* @param {Express.Response} res the response
|
|
588
|
+
* @param {Array<UploadedFile>} collected the files of this request
|
|
589
|
+
* @returns {void}
|
|
590
|
+
*/
|
|
591
|
+
function sweepOnClose(res, collected) {
|
|
592
|
+
res.on('close', () => {
|
|
593
|
+
for (const file of collected) {
|
|
594
|
+
if (!file.released) {
|
|
595
|
+
file
|
|
596
|
+
.discard()
|
|
597
|
+
.catch((error) => debug('unable to sweep: %s', error.message));
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
});
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
/**
|
|
604
|
+
* The middleware the module mounts.
|
|
605
|
+
*
|
|
606
|
+
* It reads the settings and the storage off the module on every request
|
|
607
|
+
* rather than closing over them, because an express app has no way to
|
|
608
|
+
* remove a middleware: a reload changes what this one does, never where it
|
|
609
|
+
* sits in the chain -- and where it sits, before sessions and CSRF, is the
|
|
610
|
+
* part that cannot move.
|
|
611
|
+
*
|
|
612
|
+
* @param {object} module the uploads module (`henri.uploads`)
|
|
613
|
+
* @returns {function} express middleware
|
|
614
|
+
*/
|
|
615
|
+
function middleware(module) {
|
|
616
|
+
return function uploads(req, res, next) {
|
|
617
|
+
const collected = decorate(req);
|
|
618
|
+
const { pen } = module.henri || {};
|
|
619
|
+
const { settings, storage } = module;
|
|
620
|
+
|
|
621
|
+
if (!module.enabled || !settings || !storage) {
|
|
622
|
+
return next();
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
if (!isMultipart(req) || !covers(req, settings.paths)) {
|
|
626
|
+
return next();
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
const declared = Number(req.headers['content-length']);
|
|
630
|
+
|
|
631
|
+
if (
|
|
632
|
+
settings.maxTotalSize !== false &&
|
|
633
|
+
Number.isFinite(declared) &&
|
|
634
|
+
declared > settings.maxTotalSize
|
|
635
|
+
) {
|
|
636
|
+
const refusal = new UploadError(
|
|
637
|
+
'TOTAL_TOO_LARGE',
|
|
638
|
+
`the request body is larger than the ${settings.maxTotalSize} bytes this application accepts`,
|
|
639
|
+
{ limit: settings.maxTotalSize }
|
|
640
|
+
);
|
|
641
|
+
|
|
642
|
+
// Nothing has been read, so the whole budget is still there: a body a
|
|
643
|
+
// little over the limit is drained and the client reads its 413
|
|
644
|
+
return drain(req, {
|
|
645
|
+
cap: settings.maxTotalSize * DRAIN_FACTOR,
|
|
646
|
+
}).then(() => next(refusal));
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
sweepOnClose(res, collected);
|
|
650
|
+
|
|
651
|
+
return collect(req, { collected, settings, storage })
|
|
652
|
+
.then((fields) => {
|
|
653
|
+
req.body = Object.assign({}, req.body, fields);
|
|
654
|
+
// The reads finish in whatever order the disk answers in; the body
|
|
655
|
+
// had an order, and that is the one an application sees
|
|
656
|
+
collected.sort((one, two) => one.order - two.order);
|
|
657
|
+
|
|
658
|
+
for (const file of collected) {
|
|
659
|
+
req.files[file.field] = req.files[file.field] || [];
|
|
660
|
+
req.files[file.field].push(file);
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
next();
|
|
664
|
+
})
|
|
665
|
+
.catch(async (error) => {
|
|
666
|
+
// A refusal frees the disk now, rather than waiting for the sweep:
|
|
667
|
+
// what was already read is what a flood would have left lying around
|
|
668
|
+
await Promise.all(collected.map((file) => file.discard()));
|
|
669
|
+
|
|
670
|
+
pen &&
|
|
671
|
+
pen.warn(
|
|
672
|
+
'uploads',
|
|
673
|
+
`${req.method} ${req.path}`,
|
|
674
|
+
error.code || 'refused',
|
|
675
|
+
error.message
|
|
676
|
+
);
|
|
677
|
+
next(error);
|
|
678
|
+
});
|
|
679
|
+
};
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
module.exports = {
|
|
683
|
+
DRAIN_FACTOR,
|
|
684
|
+
DRAIN_TIMEOUT,
|
|
685
|
+
FORBIDDEN,
|
|
686
|
+
HEADER_PAIRS,
|
|
687
|
+
collect,
|
|
688
|
+
covers,
|
|
689
|
+
decorate,
|
|
690
|
+
drain,
|
|
691
|
+
isMultipart,
|
|
692
|
+
meter,
|
|
693
|
+
middleware,
|
|
694
|
+
readFile,
|
|
695
|
+
sweepOnClose,
|
|
696
|
+
};
|