diff options
Diffstat (limited to 'together/node_modules/formidable/src/Formidable.js')
-rw-r--r-- | together/node_modules/formidable/src/Formidable.js | 617 |
1 files changed, 617 insertions, 0 deletions
diff --git a/together/node_modules/formidable/src/Formidable.js b/together/node_modules/formidable/src/Formidable.js new file mode 100644 index 0000000..0542700 --- /dev/null +++ b/together/node_modules/formidable/src/Formidable.js @@ -0,0 +1,617 @@ +/* eslint-disable class-methods-use-this */ +/* eslint-disable no-underscore-dangle */ + +'use strict'; + +const os = require('os'); +const path = require('path'); +const hexoid = require('hexoid'); +const once = require('once'); +const dezalgo = require('dezalgo'); +const { EventEmitter } = require('events'); +const { StringDecoder } = require('string_decoder'); +const qs = require('qs'); + +const toHexoId = hexoid(25); +const DEFAULT_OPTIONS = { + maxFields: 1000, + maxFieldsSize: 20 * 1024 * 1024, + maxFileSize: 200 * 1024 * 1024, + minFileSize: 1, + allowEmptyFiles: true, + keepExtensions: false, + encoding: 'utf-8', + hashAlgorithm: false, + uploadDir: os.tmpdir(), + multiples: false, + enabledPlugins: ['octetstream', 'querystring', 'multipart', 'json'], + fileWriteStreamHandler: null, + defaultInvalidName: 'invalid-name', + filter: function () { + return true; + }, +}; + +const PersistentFile = require('./PersistentFile'); +const VolatileFile = require('./VolatileFile'); +const DummyParser = require('./parsers/Dummy'); +const MultipartParser = require('./parsers/Multipart'); +const errors = require('./FormidableError.js'); + +const { FormidableError } = errors; + +function hasOwnProp(obj, key) { + return Object.prototype.hasOwnProperty.call(obj, key); +} + +class IncomingForm extends EventEmitter { + constructor(options = {}) { + super(); + + this.options = { ...DEFAULT_OPTIONS, ...options }; + + const dir = path.resolve( + this.options.uploadDir || this.options.uploaddir || os.tmpdir(), + ); + + this.uploaddir = dir; + this.uploadDir = dir; + + // initialize with null + [ + 'error', + 'headers', + 'type', + 'bytesExpected', + 'bytesReceived', + '_parser', + ].forEach((key) => { + this[key] = null; + }); + + this._setUpRename(); + + this._flushing = 0; + this._fieldsSize = 0; + this._fileSize = 0; + this._plugins = []; + this.openedFiles = []; + + this.options.enabledPlugins = [] + .concat(this.options.enabledPlugins) + .filter(Boolean); + + if (this.options.enabledPlugins.length === 0) { + throw new FormidableError( + 'expect at least 1 enabled builtin plugin, see options.enabledPlugins', + errors.missingPlugin, + ); + } + + this.options.enabledPlugins.forEach((pluginName) => { + const plgName = pluginName.toLowerCase(); + // eslint-disable-next-line import/no-dynamic-require, global-require + this.use(require(path.join(__dirname, 'plugins', `${plgName}.js`))); + }); + + this._setUpMaxFields(); + } + + use(plugin) { + if (typeof plugin !== 'function') { + throw new FormidableError( + '.use: expect `plugin` to be a function', + errors.pluginFunction, + ); + } + this._plugins.push(plugin.bind(this)); + return this; + } + + parse(req, cb) { + this.pause = () => { + try { + req.pause(); + } catch (err) { + // the stream was destroyed + if (!this.ended) { + // before it was completed, crash & burn + this._error(err); + } + return false; + } + return true; + }; + + this.resume = () => { + try { + req.resume(); + } catch (err) { + // the stream was destroyed + if (!this.ended) { + // before it was completed, crash & burn + this._error(err); + } + return false; + } + + return true; + }; + + // Setup callback first, so we don't miss anything from data events emitted immediately. + if (cb) { + const callback = once(dezalgo(cb)); + const fields = {}; + let mockFields = ''; + const files = {}; + + this.on('field', (name, value) => { + if ( + this.options.multiples && + (this.type === 'multipart' || this.type === 'urlencoded') + ) { + const mObj = { [name]: value }; + mockFields = mockFields + ? `${mockFields}&${qs.stringify(mObj)}` + : `${qs.stringify(mObj)}`; + } else { + fields[name] = value; + } + }); + this.on('file', (name, file) => { + // TODO: too much nesting + if (this.options.multiples) { + if (hasOwnProp(files, name)) { + if (!Array.isArray(files[name])) { + files[name] = [files[name]]; + } + files[name].push(file); + } else { + files[name] = file; + } + } else { + files[name] = file; + } + }); + this.on('error', (err) => { + callback(err, fields, files); + }); + this.on('end', () => { + if (this.options.multiples) { + Object.assign(fields, qs.parse(mockFields)); + } + callback(null, fields, files); + }); + } + + // Parse headers and setup the parser, ready to start listening for data. + this.writeHeaders(req.headers); + + // Start listening for data. + req + .on('error', (err) => { + this._error(err); + }) + .on('aborted', () => { + this.emit('aborted'); + this._error(new FormidableError('Request aborted', errors.aborted)); + }) + .on('data', (buffer) => { + try { + this.write(buffer); + } catch (err) { + this._error(err); + } + }) + .on('end', () => { + if (this.error) { + return; + } + if (this._parser) { + this._parser.end(); + } + this._maybeEnd(); + }); + + return this; + } + + writeHeaders(headers) { + this.headers = headers; + this._parseContentLength(); + this._parseContentType(); + + if (!this._parser) { + this._error( + new FormidableError( + 'no parser found', + errors.noParser, + 415, // Unsupported Media Type + ), + ); + return; + } + + this._parser.once('error', (error) => { + this._error(error); + }); + } + + write(buffer) { + if (this.error) { + return null; + } + if (!this._parser) { + this._error( + new FormidableError('uninitialized parser', errors.uninitializedParser), + ); + return null; + } + + this.bytesReceived += buffer.length; + this.emit('progress', this.bytesReceived, this.bytesExpected); + + this._parser.write(buffer); + + return this.bytesReceived; + } + + pause() { + // this does nothing, unless overwritten in IncomingForm.parse + return false; + } + + resume() { + // this does nothing, unless overwritten in IncomingForm.parse + return false; + } + + onPart(part) { + // this method can be overwritten by the user + this._handlePart(part); + } + + _handlePart(part) { + if (part.originalFilename && typeof part.originalFilename !== 'string') { + this._error( + new FormidableError( + `the part.originalFilename should be string when it exists`, + errors.filenameNotString, + ), + ); + return; + } + + // This MUST check exactly for undefined. You can not change it to !part.originalFilename. + + // todo: uncomment when switch tests to Jest + // console.log(part); + + // ? NOTE(@tunnckocore): no it can be any falsey value, it most probably depends on what's returned + // from somewhere else. Where recently I changed the return statements + // and such thing because code style + // ? NOTE(@tunnckocore): or even better, if there is no mimetype, then it's for sure a field + // ? NOTE(@tunnckocore): originalFilename is an empty string when a field? + if (!part.mimetype) { + let value = ''; + const decoder = new StringDecoder( + part.transferEncoding || this.options.encoding, + ); + + part.on('data', (buffer) => { + this._fieldsSize += buffer.length; + if (this._fieldsSize > this.options.maxFieldsSize) { + this._error( + new FormidableError( + `options.maxFieldsSize (${this.options.maxFieldsSize} bytes) exceeded, received ${this._fieldsSize} bytes of field data`, + errors.maxFieldsSizeExceeded, + 413, // Payload Too Large + ), + ); + return; + } + value += decoder.write(buffer); + }); + + part.on('end', () => { + this.emit('field', part.name, value); + }); + return; + } + + if (!this.options.filter(part)) { + return; + } + + this._flushing += 1; + + const newFilename = this._getNewName(part); + const filepath = this._joinDirectoryName(newFilename); + const file = this._newFile({ + newFilename, + filepath, + originalFilename: part.originalFilename, + mimetype: part.mimetype, + }); + file.on('error', (err) => { + this._error(err); + }); + this.emit('fileBegin', part.name, file); + + file.open(); + this.openedFiles.push(file); + + part.on('data', (buffer) => { + this._fileSize += buffer.length; + if (this._fileSize < this.options.minFileSize) { + this._error( + new FormidableError( + `options.minFileSize (${this.options.minFileSize} bytes) inferior, received ${this._fileSize} bytes of file data`, + errors.smallerThanMinFileSize, + 400, + ), + ); + return; + } + if (this._fileSize > this.options.maxFileSize) { + this._error( + new FormidableError( + `options.maxFileSize (${this.options.maxFileSize} bytes) exceeded, received ${this._fileSize} bytes of file data`, + errors.biggerThanMaxFileSize, + 413, + ), + ); + return; + } + if (buffer.length === 0) { + return; + } + this.pause(); + file.write(buffer, () => { + this.resume(); + }); + }); + + part.on('end', () => { + if (!this.options.allowEmptyFiles && this._fileSize === 0) { + this._error( + new FormidableError( + `options.allowEmptyFiles is false, file size should be greather than 0`, + errors.noEmptyFiles, + 400, + ), + ); + return; + } + + file.end(() => { + this._flushing -= 1; + this.emit('file', part.name, file); + this._maybeEnd(); + }); + }); + } + + // eslint-disable-next-line max-statements + _parseContentType() { + if (this.bytesExpected === 0) { + this._parser = new DummyParser(this, this.options); + return; + } + + if (!this.headers['content-type']) { + this._error( + new FormidableError( + 'bad content-type header, no content-type', + errors.missingContentType, + 400, + ), + ); + return; + } + + const results = []; + const _dummyParser = new DummyParser(this, this.options); + + // eslint-disable-next-line no-plusplus + for (let idx = 0; idx < this._plugins.length; idx++) { + const plugin = this._plugins[idx]; + + let pluginReturn = null; + + try { + pluginReturn = plugin(this, this.options) || this; + } catch (err) { + // directly throw from the `form.parse` method; + // there is no other better way, except a handle through options + const error = new FormidableError( + `plugin on index ${idx} failed with: ${err.message}`, + errors.pluginFailed, + 500, + ); + error.idx = idx; + throw error; + } + + Object.assign(this, pluginReturn); + + // todo: use Set/Map and pass plugin name instead of the `idx` index + this.emit('plugin', idx, pluginReturn); + results.push(pluginReturn); + } + + this.emit('pluginsResults', results); + + // NOTE: probably not needed, because we check options.enabledPlugins in the constructor + // if (results.length === 0 /* && results.length !== this._plugins.length */) { + // this._error( + // new Error( + // `bad content-type header, unknown content-type: ${this.headers['content-type']}`, + // ), + // ); + // } + } + + _error(err, eventName = 'error') { + // if (!err && this.error) { + // this.emit('error', this.error); + // return; + // } + if (this.error || this.ended) { + return; + } + + this.error = err; + this.emit(eventName, err); + + if (Array.isArray(this.openedFiles)) { + this.openedFiles.forEach((file) => { + file.destroy(); + }); + } + } + + _parseContentLength() { + this.bytesReceived = 0; + if (this.headers['content-length']) { + this.bytesExpected = parseInt(this.headers['content-length'], 10); + } else if (this.headers['transfer-encoding'] === undefined) { + this.bytesExpected = 0; + } + + if (this.bytesExpected !== null) { + this.emit('progress', this.bytesReceived, this.bytesExpected); + } + } + + _newParser() { + return new MultipartParser(this.options); + } + + _newFile({ filepath, originalFilename, mimetype, newFilename }) { + return this.options.fileWriteStreamHandler + ? new VolatileFile({ + newFilename, + filepath, + originalFilename, + mimetype, + createFileWriteStream: this.options.fileWriteStreamHandler, + hashAlgorithm: this.options.hashAlgorithm, + }) + : new PersistentFile({ + newFilename, + filepath, + originalFilename, + mimetype, + hashAlgorithm: this.options.hashAlgorithm, + }); + } + + _getFileName(headerValue) { + // matches either a quoted-string or a token (RFC 2616 section 19.5.1) + const m = headerValue.match( + /\bfilename=("(.*?)"|([^()<>{}[\]@,;:"?=\s/\t]+))($|;\s)/i, + ); + if (!m) return null; + + const match = m[2] || m[3] || ''; + let originalFilename = match.substr(match.lastIndexOf('\\') + 1); + originalFilename = originalFilename.replace(/%22/g, '"'); + originalFilename = originalFilename.replace(/&#([\d]{4});/g, (_, code) => + String.fromCharCode(code), + ); + + return originalFilename; + } + + _getExtension(str) { + if (!str) { + return ''; + } + + const basename = path.basename(str); + const firstDot = basename.indexOf('.'); + const lastDot = basename.lastIndexOf('.'); + const extname = path.extname(basename).replace(/(\.[a-z0-9]+).*/i, '$1'); + + if (firstDot === lastDot) { + return extname; + } + + return basename.slice(firstDot, lastDot) + extname; + } + + + + _joinDirectoryName(name) { + const newPath = path.join(this.uploadDir, name); + + // prevent directory traversal attacks + if (!newPath.startsWith(this.uploadDir)) { + return path.join(this.uploadDir, this.options.defaultInvalidName); + } + + return newPath; + } + + _setUpRename() { + const hasRename = typeof this.options.filename === 'function'; + if (hasRename) { + this._getNewName = (part) => { + let ext = ''; + let name = this.options.defaultInvalidName; + if (part.originalFilename) { + // can be null + ({ ext, name } = path.parse(part.originalFilename)); + if (this.options.keepExtensions !== true) { + ext = ''; + } + } + return this.options.filename.call(this, name, ext, part, this); + }; + } else { + this._getNewName = (part) => { + const name = toHexoId(); + + if (part && this.options.keepExtensions) { + const originalFilename = typeof part === 'string' ? part : part.originalFilename; + return `${name}${this._getExtension(originalFilename)}`; + } + + return name; + } + } + } + + _setUpMaxFields() { + if (this.options.maxFields !== 0) { + let fieldsCount = 0; + this.on('field', () => { + fieldsCount += 1; + if (fieldsCount > this.options.maxFields) { + this._error( + new FormidableError( + `options.maxFields (${this.options.maxFields}) exceeded`, + errors.maxFieldsExceeded, + 413, + ), + ); + } + }); + } + } + + _maybeEnd() { + // console.log('ended', this.ended); + // console.log('_flushing', this._flushing); + // console.log('error', this.error); + if (!this.ended || this._flushing || this.error) { + return; + } + + this.emit('end'); + } +} + +IncomingForm.DEFAULT_OPTIONS = DEFAULT_OPTIONS; +module.exports = IncomingForm; |