654 lines
18 KiB
Plaintext
654 lines
18 KiB
Plaintext
'use strict';
|
|
|
|
const { Readable, Writable } = require('stream');
|
|
|
|
const StreamSearch = require('streamsearch');
|
|
|
|
const {
|
|
basename,
|
|
convertToUTF8,
|
|
getDecoder,
|
|
parseContentType,
|
|
parseDisposition,
|
|
} = require('../utils.js');
|
|
|
|
const BUF_CRLF = Buffer.from('\r\n');
|
|
const BUF_CR = Buffer.from('\r');
|
|
const BUF_DASH = Buffer.from('-');
|
|
|
|
function noop() {}
|
|
|
|
const MAX_HEADER_PAIRS = 2000; // From node
|
|
const MAX_HEADER_SIZE = 16 * 1024; // From node (its default value)
|
|
|
|
const HPARSER_NAME = 0;
|
|
const HPARSER_PRE_OWS = 1;
|
|
const HPARSER_VALUE = 2;
|
|
class HeaderParser {
|
|
constructor(cb) {
|
|
this.header = Object.create(null);
|
|
this.pairCount = 0;
|
|
this.byteCount = 0;
|
|
this.state = HPARSER_NAME;
|
|
this.name = '';
|
|
this.value = '';
|
|
this.crlf = 0;
|
|
this.cb = cb;
|
|
}
|
|
|
|
reset() {
|
|
this.header = Object.create(null);
|
|
this.pairCount = 0;
|
|
this.byteCount = 0;
|
|
this.state = HPARSER_NAME;
|
|
this.name = '';
|
|
this.value = '';
|
|
this.crlf = 0;
|
|
}
|
|
|
|
push(chunk, pos, end) {
|
|
let start = pos;
|
|
while (pos < end) {
|
|
switch (this.state) {
|
|
case HPARSER_NAME: {
|
|
let done = false;
|
|
for (; pos < end; ++pos) {
|
|
if (this.byteCount === MAX_HEADER_SIZE)
|
|
return -1;
|
|
++this.byteCount;
|
|
const code = chunk[pos];
|
|
if (TOKEN[code] !== 1) {
|
|
if (code !== 58/* ':' */)
|
|
return -1;
|
|
this.name += chunk.latin1Slice(start, pos);
|
|
if (this.name.length === 0)
|
|
return -1;
|
|
++pos;
|
|
done = true;
|
|
this.state = HPARSER_PRE_OWS;
|
|
break;
|
|
}
|
|
}
|
|
if (!done) {
|
|
this.name += chunk.latin1Slice(start, pos);
|
|
break;
|
|
}
|
|
// FALLTHROUGH
|
|
}
|
|
case HPARSER_PRE_OWS: {
|
|
// Skip optional whitespace
|
|
let done = false;
|
|
for (; pos < end; ++pos) {
|
|
if (this.byteCount === MAX_HEADER_SIZE)
|
|
return -1;
|
|
++this.byteCount;
|
|
const code = chunk[pos];
|
|
if (code !== 32/* ' ' */ && code !== 9/* '\t' */) {
|
|
start = pos;
|
|
done = true;
|
|
this.state = HPARSER_VALUE;
|
|
break;
|
|
}
|
|
}
|
|
if (!done)
|
|
break;
|
|
// FALLTHROUGH
|
|
}
|
|
case HPARSER_VALUE:
|
|
switch (this.crlf) {
|
|
case 0: // Nothing yet
|
|
for (; pos < end; ++pos) {
|
|
if (this.byteCount === MAX_HEADER_SIZE)
|
|
return -1;
|
|
++this.byteCount;
|
|
const code = chunk[pos];
|
|
if (FIELD_VCHAR[code] !== 1) {
|
|
if (code !== 13/* '\r' */)
|
|
return -1;
|
|
++this.crlf;
|
|
break;
|
|
}
|
|
}
|
|
this.value += chunk.latin1Slice(start, pos++);
|
|
break;
|
|
case 1: // Received CR
|
|
if (this.byteCount === MAX_HEADER_SIZE)
|
|
return -1;
|
|
++this.byteCount;
|
|
if (chunk[pos++] !== 10/* '\n' */)
|
|
return -1;
|
|
++this.crlf;
|
|
break;
|
|
case 2: { // Received CR LF
|
|
if (this.byteCount === MAX_HEADER_SIZE)
|
|
return -1;
|
|
++this.byteCount;
|
|
const code = chunk[pos];
|
|
if (code === 32/* ' ' */ || code === 9/* '\t' */) {
|
|
// Folded value
|
|
start = pos;
|
|
this.crlf = 0;
|
|
} else {
|
|
if (++this.pairCount < MAX_HEADER_PAIRS) {
|
|
this.name = this.name.toLowerCase();
|
|
if (this.header[this.name] === undefined)
|
|
this.header[this.name] = [this.value];
|
|
else
|
|
this.header[this.name].push(this.value);
|
|
}
|
|
if (code === 13/* '\r' */) {
|
|
++this.crlf;
|
|
++pos;
|
|
} else {
|
|
// Assume start of next header field name
|
|
start = pos;
|
|
this.crlf = 0;
|
|
this.state = HPARSER_NAME;
|
|
this.name = '';
|
|
this.value = '';
|
|
}
|
|
}
|
|
break;
|
|
}
|
|
case 3: { // Received CR LF CR
|
|
if (this.byteCount === MAX_HEADER_SIZE)
|
|
return -1;
|
|
++this.byteCount;
|
|
if (chunk[pos++] !== 10/* '\n' */)
|
|
return -1;
|
|
// End of header
|
|
const header = this.header;
|
|
this.reset();
|
|
this.cb(header);
|
|
return pos;
|
|
}
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
|
|
return pos;
|
|
}
|
|
}
|
|
|
|
class FileStream extends Readable {
|
|
constructor(opts, owner) {
|
|
super(opts);
|
|
this.truncated = false;
|
|
this._readcb = null;
|
|
this.once('end', () => {
|
|
// We need to make sure that we call any outstanding _writecb() that is
|
|
// associated with this file so that processing of the rest of the form
|
|
// can continue. This may not happen if the file stream ends right after
|
|
// backpressure kicks in, so we force it here.
|
|
this._read();
|
|
if (--owner._fileEndsLeft === 0 && owner._finalcb) {
|
|
const cb = owner._finalcb;
|
|
owner._finalcb = null;
|
|
// Make sure other 'end' event handlers get a chance to be executed
|
|
// before busboy's 'finish' event is emitted
|
|
process.nextTick(cb);
|
|
}
|
|
});
|
|
}
|
|
_read(n) {
|
|
const cb = this._readcb;
|
|
if (cb) {
|
|
this._readcb = null;
|
|
cb();
|
|
}
|
|
}
|
|
}
|
|
|
|
const ignoreData = {
|
|
push: (chunk, pos) => {},
|
|
destroy: () => {},
|
|
};
|
|
|
|
function callAndUnsetCb(self, err) {
|
|
const cb = self._writecb;
|
|
self._writecb = null;
|
|
if (err)
|
|
self.destroy(err);
|
|
else if (cb)
|
|
cb();
|
|
}
|
|
|
|
function nullDecoder(val, hint) {
|
|
return val;
|
|
}
|
|
|
|
class Multipart extends Writable {
|
|
constructor(cfg) {
|
|
const streamOpts = {
|
|
autoDestroy: true,
|
|
emitClose: true,
|
|
highWaterMark: (typeof cfg.highWaterMark === 'number'
|
|
? cfg.highWaterMark
|
|
: undefined),
|
|
};
|
|
super(streamOpts);
|
|
|
|
if (!cfg.conType.params || typeof cfg.conType.params.boundary !== 'string')
|
|
throw new Error('Multipart: Boundary not found');
|
|
|
|
const boundary = cfg.conType.params.boundary;
|
|
const paramDecoder = (typeof cfg.defParamCharset === 'string'
|
|
&& cfg.defParamCharset
|
|
? getDecoder(cfg.defParamCharset)
|
|
: nullDecoder);
|
|
const defCharset = (cfg.defCharset || 'utf8');
|
|
const preservePath = cfg.preservePath;
|
|
const fileOpts = {
|
|
autoDestroy: true,
|
|
emitClose: true,
|
|
highWaterMark: (typeof cfg.fileHwm === 'number'
|
|
? cfg.fileHwm
|
|
: undefined),
|
|
};
|
|
|
|
const limits = cfg.limits;
|
|
const fieldSizeLimit = (limits && typeof limits.fieldSize === 'number'
|
|
? limits.fieldSize
|
|
: 1 * 1024 * 1024);
|
|
const fileSizeLimit = (limits && typeof limits.fileSize === 'number'
|
|
? limits.fileSize
|
|
: Infinity);
|
|
const filesLimit = (limits && typeof limits.files === 'number'
|
|
? limits.files
|
|
: Infinity);
|
|
const fieldsLimit = (limits && typeof limits.fields === 'number'
|
|
? limits.fields
|
|
: Infinity);
|
|
const partsLimit = (limits && typeof limits.parts === 'number'
|
|
? limits.parts
|
|
: Infinity);
|
|
|
|
let parts = -1; // Account for initial boundary
|
|
let fields = 0;
|
|
let files = 0;
|
|
let skipPart = false;
|
|
|
|
this._fileEndsLeft = 0;
|
|
this._fileStream = undefined;
|
|
this._complete = false;
|
|
let fileSize = 0;
|
|
|
|
let field;
|
|
let fieldSize = 0;
|
|
let partCharset;
|
|
let partEncoding;
|
|
let partType;
|
|
let partName;
|
|
let partTruncated = false;
|
|
|
|
let hitFilesLimit = false;
|
|
let hitFieldsLimit = false;
|
|
|
|
this._hparser = null;
|
|
const hparser = new HeaderParser((header) => {
|
|
this._hparser = null;
|
|
skipPart = false;
|
|
|
|
partType = 'text/plain';
|
|
partCharset = defCharset;
|
|
partEncoding = '7bit';
|
|
partName = undefined;
|
|
partTruncated = false;
|
|
|
|
let filename;
|
|
if (!header['content-disposition']) {
|
|
skipPart = true;
|
|
return;
|
|
}
|
|
|
|
const disp = parseDisposition(header['content-disposition'][0],
|
|
paramDecoder);
|
|
if (!disp || disp.type !== 'form-data') {
|
|
skipPart = true;
|
|
return;
|
|
}
|
|
|
|
if (disp.params) {
|
|
if (disp.params.name)
|
|
partName = disp.params.name;
|
|
|
|
if (disp.params['filename*'])
|
|
filename = disp.params['filename*'];
|
|
else if (disp.params.filename)
|
|
filename = disp.params.filename;
|
|
|
|
if (filename !== undefined && !preservePath)
|
|
filename = basename(filename);
|
|
}
|
|
|
|
if (header['content-type']) {
|
|
const conType = parseContentType(header['content-type'][0]);
|
|
if (conType) {
|
|
partType = `${conType.type}/${conType.subtype}`;
|
|
if (conType.params && typeof conType.params.charset === 'string')
|
|
partCharset = conType.params.charset.toLowerCase();
|
|
}
|
|
}
|
|
|
|
if (header['content-transfer-encoding'])
|
|
partEncoding = header['content-transfer-encoding'][0].toLowerCase();
|
|
|
|
if (partType === 'application/octet-stream' || filename !== undefined) {
|
|
// File
|
|
|
|
if (files === filesLimit) {
|
|
if (!hitFilesLimit) {
|
|
hitFilesLimit = true;
|
|
this.emit('filesLimit');
|
|
}
|
|
skipPart = true;
|
|
return;
|
|
}
|
|
++files;
|
|
|
|
if (this.listenerCount('file') === 0) {
|
|
skipPart = true;
|
|
return;
|
|
}
|
|
|
|
fileSize = 0;
|
|
this._fileStream = new FileStream(fileOpts, this);
|
|
++this._fileEndsLeft;
|
|
this.emit(
|
|
'file',
|
|
partName,
|
|
this._fileStream,
|
|
{ filename,
|
|
encoding: partEncoding,
|
|
mimeType: partType }
|
|
);
|
|
} else {
|
|
// Non-file
|
|
|
|
if (fields === fieldsLimit) {
|
|
if (!hitFieldsLimit) {
|
|
hitFieldsLimit = true;
|
|
this.emit('fieldsLimit');
|
|
}
|
|
skipPart = true;
|
|
return;
|
|
}
|
|
++fields;
|
|
|
|
if (this.listenerCount('field') === 0) {
|
|
skipPart = true;
|
|
return;
|
|
}
|
|
|
|
field = [];
|
|
fieldSize = 0;
|
|
}
|
|
});
|
|
|
|
let matchPostBoundary = 0;
|
|
const ssCb = (isMatch, data, start, end, isDataSafe) => {
|
|
retrydata:
|
|
while (data) {
|
|
if (this._hparser !== null) {
|
|
const ret = this._hparser.push(data, start, end);
|
|
if (ret === -1) {
|
|
this._hparser = null;
|
|
hparser.reset();
|
|
this.emit('error', new Error('Malformed part header'));
|
|
break;
|
|
}
|
|
start = ret;
|
|
}
|
|
|
|
if (start === end)
|
|
break;
|
|
|
|
if (matchPostBoundary !== 0) {
|
|
if (matchPostBoundary === 1) {
|
|
switch (data[start]) {
|
|
case 45: // '-'
|
|
// Try matching '--' after boundary
|
|
matchPostBoundary = 2;
|
|
++start;
|
|
break;
|
|
case 13: // '\r'
|
|
// Try matching CR LF before header
|
|
matchPostBoundary = 3;
|
|
++start;
|
|
break;
|
|
default:
|
|
matchPostBoundary = 0;
|
|
}
|
|
if (start === end)
|
|
return;
|
|
}
|
|
|
|
if (matchPostBoundary === 2) {
|
|
matchPostBoundary = 0;
|
|
if (data[start] === 45/* '-' */) {
|
|
// End of multipart data
|
|
this._complete = true;
|
|
this._bparser = ignoreData;
|
|
return;
|
|
}
|
|
// We saw something other than '-', so put the dash we consumed
|
|
// "back"
|
|
const writecb = this._writecb;
|
|
this._writecb = noop;
|
|
ssCb(false, BUF_DASH, 0, 1, false);
|
|
this._writecb = writecb;
|
|
} else if (matchPostBoundary === 3) {
|
|
matchPostBoundary = 0;
|
|
if (data[start] === 10/* '\n' */) {
|
|
++start;
|
|
if (parts >= partsLimit)
|
|
break;
|
|
// Prepare the header parser
|
|
this._hparser = hparser;
|
|
if (start === end)
|
|
break;
|
|
// Process the remaining data as a header
|
|
continue retrydata;
|
|
} else {
|
|
// We saw something other than LF, so put the CR we consumed
|
|
// "back"
|
|
const writecb = this._writecb;
|
|
this._writecb = noop;
|
|
ssCb(false, BUF_CR, 0, 1, false);
|
|
this._writecb = writecb;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (!skipPart) {
|
|
if (this._fileStream) {
|
|
let chunk;
|
|
const actualLen = Math.min(end - start, fileSizeLimit - fileSize);
|
|
if (!isDataSafe) {
|
|
chunk = Buffer.allocUnsafe(actualLen);
|
|
data.copy(chunk, 0, start, start + actualLen);
|
|
} else {
|
|
chunk = data.slice(start, start + actualLen);
|
|
}
|
|
|
|
fileSize += chunk.length;
|
|
if (fileSize === fileSizeLimit) {
|
|
if (chunk.length > 0)
|
|
this._fileStream.push(chunk);
|
|
this._fileStream.emit('limit');
|
|
this._fileStream.truncated = true;
|
|
skipPart = true;
|
|
} else if (!this._fileStream.push(chunk)) {
|
|
if (this._writecb)
|
|
this._fileStream._readcb = this._writecb;
|
|
this._writecb = null;
|
|
}
|
|
} else if (field !== undefined) {
|
|
let chunk;
|
|
const actualLen = Math.min(
|
|
end - start,
|
|
fieldSizeLimit - fieldSize
|
|
);
|
|
if (!isDataSafe) {
|
|
chunk = Buffer.allocUnsafe(actualLen);
|
|
data.copy(chunk, 0, start, start + actualLen);
|
|
} else {
|
|
chunk = data.slice(start, start + actualLen);
|
|
}
|
|
|
|
fieldSize += actualLen;
|
|
field.push(chunk);
|
|
if (fieldSize === fieldSizeLimit) {
|
|
skipPart = true;
|
|
partTruncated = true;
|
|
}
|
|
}
|
|
}
|
|
|
|
break;
|
|
}
|
|
|
|
if (isMatch) {
|
|
matchPostBoundary = 1;
|
|
|
|
if (this._fileStream) {
|
|
// End the active file stream if the previous part was a file
|
|
this._fileStream.push(null);
|
|
this._fileStream = null;
|
|
} else if (field !== undefined) {
|
|
let data;
|
|
switch (field.length) {
|
|
case 0:
|
|
data = '';
|
|
break;
|
|
case 1:
|
|
data = convertToUTF8(field[0], partCharset, 0);
|
|
break;
|
|
default:
|
|
data = convertToUTF8(
|
|
Buffer.concat(field, fieldSize),
|
|
partCharset,
|
|
0
|
|
);
|
|
}
|
|
field = undefined;
|
|
fieldSize = 0;
|
|
this.emit(
|
|
'field',
|
|
partName,
|
|
data,
|
|
{ nameTruncated: false,
|
|
valueTruncated: partTruncated,
|
|
encoding: partEncoding,
|
|
mimeType: partType }
|
|
);
|
|
}
|
|
|
|
if (++parts === partsLimit)
|
|
this.emit('partsLimit');
|
|
}
|
|
};
|
|
this._bparser = new StreamSearch(`\r\n--${boundary}`, ssCb);
|
|
|
|
this._writecb = null;
|
|
this._finalcb = null;
|
|
|
|
// Just in case there is no preamble
|
|
this.write(BUF_CRLF);
|
|
}
|
|
|
|
static detect(conType) {
|
|
return (conType.type === 'multipart' && conType.subtype === 'form-data');
|
|
}
|
|
|
|
_write(chunk, enc, cb) {
|
|
this._writecb = cb;
|
|
this._bparser.push(chunk, 0);
|
|
if (this._writecb)
|
|
callAndUnsetCb(this);
|
|
}
|
|
|
|
_destroy(err, cb) {
|
|
this._hparser = null;
|
|
this._bparser = ignoreData;
|
|
if (!err)
|
|
err = checkEndState(this);
|
|
const fileStream = this._fileStream;
|
|
if (fileStream) {
|
|
this._fileStream = null;
|
|
fileStream.destroy(err);
|
|
}
|
|
cb(err);
|
|
}
|
|
|
|
_final(cb) {
|
|
this._bparser.destroy();
|
|
if (!this._complete)
|
|
return cb(new Error('Unexpected end of form'));
|
|
if (this._fileEndsLeft)
|
|
this._finalcb = finalcb.bind(null, this, cb);
|
|
else
|
|
finalcb(this, cb);
|
|
}
|
|
}
|
|
|
|
function finalcb(self, cb, err) {
|
|
if (err)
|
|
return cb(err);
|
|
err = checkEndState(self);
|
|
cb(err);
|
|
}
|
|
|
|
function checkEndState(self) {
|
|
if (self._hparser)
|
|
return new Error('Malformed part header');
|
|
const fileStream = self._fileStream;
|
|
if (fileStream) {
|
|
self._fileStream = null;
|
|
fileStream.destroy(new Error('Unexpected end of file'));
|
|
}
|
|
if (!self._complete)
|
|
return new Error('Unexpected end of form');
|
|
}
|
|
|
|
const TOKEN = [
|
|
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
|
|
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
|
|
0, 1, 0, 1, 1, 1, 1, 1, 0, 0, 1, 1, 0, 1, 1, 0,
|
|
1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0,
|
|
0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1,
|
|
1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 1, 1,
|
|
1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1,
|
|
1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 1, 0, 1, 0,
|
|
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
|
|
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
|
|
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
|
|
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
|
|
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
|
|
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
|
|
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
|
|
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
|
|
];
|
|
|
|
const FIELD_VCHAR = [
|
|
0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0,
|
|
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
|
|
1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1,
|
|
1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1,
|
|
1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1,
|
|
1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1,
|
|
1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1,
|
|
1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0,
|
|
1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1,
|
|
1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1,
|
|
1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1,
|
|
1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1,
|
|
1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1,
|
|
1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1,
|
|
1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1,
|
|
1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1,
|
|
];
|
|
|
|
module.exports = Multipart;
|