UNPKG

@goa/busboy

Version:

[fork] A Streaming Parser For HTML Form Data For Node.JS.

315 lines (281 loc) 8.57 kB
// TODO: // * support 1 nested multipart level // (see second multipart example here: // http://www.w3.org/TR/html401/interact/forms.html#didx-multipartform-data) // * support limits.fieldNameSize // -- this will require modifications to utils.parseParams import { Readable } from 'stream' import Dicer from '@idio/dicer' import { parseParams, decodeBuffer, basename, getLimits, decodeText } from '../utils' import BusBoy from '../BusBoy' // eslint-disable-line const RE_BOUNDARY = /^boundary$/i const RE_FIELD = /^form-data$/i const RE_CHARSET = /^charset$/i const RE_FILENAME = /^filename$/i const RE_NAME = /^name$/i export default class Multipart { static get detect() { return /^multipart\/form-data/i } /** * @param {BusBoy} boy * @param {_goa.BusBoyParserConfig} cfg */ constructor(boy, { limits = {}, defCharset = 'utf8', preservePath, fileHwm, parsedConType = [], highWaterMark, }) { let i, len const [,boundary] = parsedConType.find((t) => { return Array.isArray(t) && RE_BOUNDARY.test(t[0]) }) || [] if (typeof boundary != 'string') throw new Error('Multipart: Boundary not found') function checkFinished() { if (nends === 0 && finished && !boy._done) { finished = false process.nextTick(() => { boy._done = true boy.emit('finish') }) } } const { partsLimit, filesLimit, fileSizeLimit, fieldsLimit, fieldSizeLimit, } = getLimits(limits) /** @type {!stream.Readable|undefined} */ let curFile let nfiles = 0, nfields = 0, nends = 0, curField, finished = false this._needDrain = false this._pause = false this._cb = undefined this._nparts = 0 this._boy = boy const parserCfg = { boundary: boundary, maxHeaderPairs: limits.headerPairs, highWaterMark, fileHwm, } this.parser = new Dicer(parserCfg) this.parser.on('drain', () => { this._needDrain = false if (this._cb && !this._pause) { const cb = this._cb this._cb = undefined cb() } }).on('error', (err) => { boy.emit('error', err) }).on('finish', () => { finished = true checkFinished() }) /** @param {stream.Readable} part */ const onPart = (part) => { if (++this._nparts > partsLimit) { this.parser.removeListener('part', onPart) this.parser.on('part', skipPart) boy.hitPartsLimit = true boy.emit('partsLimit') return skipPart(part) } // hack because streams2 _always_ doesn't emit 'end' until nextTick, so let // us emit 'end' early since we know the part has ended if we are already // seeing the next part if (curField) { const field = curField field.emit('end') field.removeAllListeners('end') } part.on('header', (/** @type {!Object<string, !Array<string>>} */ header) => { let contype = 'text/plain' let charset = defCharset let encoding = '7bit' let fieldname, filename, nsize = 0 if (header['content-type']) { const parsed = parseParams(header['content-type'][0]) if (parsed[0]) { contype = parsed[0].toLowerCase() for (i = 0, len = parsed.length; i < len; ++i) { if (RE_CHARSET.test(parsed[i][0])) { charset = parsed[i][1].toLowerCase() break } } } } if (header['content-disposition']) { const parsed = parseParams(header['content-disposition'][0]) if (!RE_FIELD.test(parsed[0])) return skipPart(part) for (i = 0, len = parsed.length; i < len; ++i) { if (RE_NAME.test(parsed[i][0])) { fieldname = parsed[i][1] } else if (RE_FILENAME.test(parsed[i][0])) { filename = parsed[i][1] if (!preservePath) filename = basename(filename) } } } else return skipPart(part) if (header['content-transfer-encoding']) encoding = header['content-transfer-encoding'][0].toLowerCase() let onData, onEnd if (contype == 'application/octet-stream' || filename !== undefined) { // file/binary field if (nfiles == filesLimit) { if (!boy.hitFilesLimit) { boy.hitFilesLimit = true boy.emit('filesLimit') } return skipPart(part) } ++nfiles if (!boy._events.file) { this.parser._ignore() return } ++nends const file = new FileStream({ highWaterMark: fileHwm }) curFile = file file.on('end', () => { --nends this._pause = false checkFinished() if (this._cb && !this._needDrain) { const cb = this._cb this._cb = undefined cb() } }) file._read = () => { if (!this._pause) return this._pause = false if (this._cb && !this._needDrain) { const cb = this._cb this._cb = undefined cb() } } boy.emit('file', fieldname, file, filename, encoding, contype, part) onData = (data) => { if ((nsize += data.length) > fileSizeLimit) { const extralen = (fileSizeLimit - (nsize - data.length)) if (extralen > 0) file.push(data.slice(0, extralen)) file.emit('limit') file.truncated = true part.removeAllListeners('data') } else if (!file.push(data)) this._pause = true } onEnd = () => { curFile = undefined file.push(null) } } else { // non-file field if (nfields == fieldsLimit) { if (!boy.hitFieldsLimit) { boy.hitFieldsLimit = true boy.emit('fieldsLimit') } return skipPart(part) } ++nfields ++nends const buffer = [] let truncated = false curField = part /** @param {Buffer} data */ onData = (data) => { let d = data nsize += data.length if (nsize > fieldSizeLimit) { d = Buffer.from(data, 0, fieldSizeLimit).slice(0, fieldSizeLimit) truncated = true part.removeAllListeners('data') } buffer.push(d) } onEnd = () => { curField = undefined const b = Buffer.concat(buffer) const bu = decodeBuffer(b, charset) boy.emit('field', fieldname, bu, false, truncated, encoding, contype) --nends checkFinished() } } // https://github.com/nodejs/node/blob/df339bccf24839da473937bd523602cf68b114af/lib/_stream_readable.js#L111 part._readableState.sync = false part.on('data', onData) part.on('end', onEnd) }).on('error', (err) => { if (curFile) curFile.emit('error', err) }) } this.parser.on('part', onPart) } end() { if (this._nparts === 0 && !this._boy._done) { process.nextTick(() => { this._boy._done = true this._boy.emit('finish') }) } else if (this.parser.writable) this.parser.end() } write(chunk, cb) { let r if ((r = this.parser.write(chunk)) && !this._pause) cb() else { this._needDrain = !r this._cb = cb } } } /** * @param {stream.Readable} part */ function skipPart(part) { part.resume() } /** * @implements {_goa.BusBoyFileStream} */ class FileStream extends Readable { constructor(opts) { super(opts) this.truncated = false } _read() {} } /** * @suppress {nonStandardJsDocs} * @typedef {import('../../types').BusBoyParserConfig} _goa.BusBoyParserConfig */ /** * @suppress {nonStandardJsDocs} * @typedef {import('../../types').BusBoyLimits} _goa.BusBoyLimits */ /** * @suppress {nonStandardJsDocs} * @typedef {import('stream').Readable} stream.Readable */ /** * @suppress {nonStandardJsDocs} * @typedef {import('http').IncomingHttpHeaders} http.IncomingHttpHeaders */