commit
aa57dc43aa
7 changed files with 351 additions and 237 deletions
@ -0,0 +1,63 @@ |
||||
'use strict'; |
||||
|
||||
const fs = require('fs'); |
||||
const path = require('path'); |
||||
const streamifier = require('streamifier'); |
||||
const md5 = require('md5'); |
||||
|
||||
module.exports = function(options, fileUploadOptions = null) { |
||||
return { |
||||
name: options.name, |
||||
data: options.buffer, |
||||
encoding: options.encoding, |
||||
truncated: options.truncated, |
||||
mimetype: options.mimetype, |
||||
md5: md5(options.buffer), |
||||
mv: function(filePath, callback) { |
||||
// Callback is passed in, use the callback API
|
||||
if (callback) { |
||||
doMove( |
||||
() => { |
||||
callback(null); |
||||
}, |
||||
(error) => { |
||||
callback(error); |
||||
} |
||||
); |
||||
|
||||
// Otherwise, return a promise
|
||||
} else { |
||||
return new Promise((resolve, reject) => { |
||||
doMove(resolve, reject); |
||||
}); |
||||
} |
||||
|
||||
/** |
||||
* Local function that moves the file to a different location on the filesystem |
||||
* Takes two function arguments to make it compatible w/ Promise or Callback APIs |
||||
* @param {Function} successFunc |
||||
* @param {Function} errorFunc |
||||
*/ |
||||
function doMove(successFunc, errorFunc) { |
||||
if (fileUploadOptions && fileUploadOptions.createParentPath) { |
||||
const parentPath = path.dirname(filePath); |
||||
if (!fs.existsSync(parentPath)) { |
||||
fs.mkdirSync(parentPath); |
||||
} |
||||
} |
||||
|
||||
const fstream = fs.createWriteStream(filePath); |
||||
|
||||
streamifier.createReadStream(options.buffer).pipe(fstream); |
||||
|
||||
fstream.on('error', function(error) { |
||||
errorFunc(error); |
||||
}); |
||||
|
||||
fstream.on('close', function() { |
||||
successFunc(); |
||||
}); |
||||
} |
||||
} |
||||
}; |
||||
}; |
@ -1,250 +1,30 @@ |
||||
'use strict'; |
||||
|
||||
const Busboy = require('busboy'); |
||||
const fs = require('fs'); |
||||
const path = require('path'); |
||||
const streamifier = require('streamifier'); |
||||
const md5 = require('md5'); |
||||
const fileFactory = require('./fileFactory'); |
||||
const processMultipart = require('./processMultipart'); |
||||
const isEligibleRequest = require('./isEligibleRequest'); |
||||
|
||||
const ACCEPTABLE_MIME = /^(?:multipart\/.+)$/i; |
||||
const UNACCEPTABLE_METHODS = [ |
||||
'GET', |
||||
'HEAD' |
||||
]; |
||||
|
||||
module.exports = function(options) { |
||||
options = options || {}; |
||||
/** |
||||
* Expose the file upload middleware |
||||
*/ |
||||
module.exports = function(fileUploadOptions) { |
||||
fileUploadOptions = Object.assign({}, fileUploadOptions || {}, { |
||||
safeFileNames: false, |
||||
preserveExtension: false, |
||||
abortOnLimit: false, |
||||
createParentPath: false |
||||
}); |
||||
|
||||
return function(req, res, next) { |
||||
if (!hasBody(req) || !hasAcceptableMethod(req) || !hasAcceptableMime(req)) { |
||||
if (!isEligibleRequest(req)) { |
||||
return next(); |
||||
} |
||||
|
||||
processMultipart(options, req, res, next); |
||||
processMultipart(fileUploadOptions, req, res, next); |
||||
}; |
||||
}; |
||||
|
||||
/** |
||||
* Processes multipart request |
||||
* Builds a req.body object for fields |
||||
* Builds a req.files object for files |
||||
* @param {Object} options expressFileupload and Busboy options |
||||
* @param {Object} req Express request object |
||||
* @param {Object} res Express response object |
||||
* @param {Function} next Express next method |
||||
* @return {void} |
||||
*/ |
||||
function processMultipart(options, req, res, next) { |
||||
let busboyOptions = {}; |
||||
let busboy; |
||||
|
||||
req.files = null; |
||||
|
||||
// Build busboy config
|
||||
for (let k in options) { |
||||
if (Object.prototype.hasOwnProperty.call(options, k)) { |
||||
busboyOptions[k] = options[k]; |
||||
} |
||||
} |
||||
|
||||
// Attach request headers to busboy config
|
||||
busboyOptions.headers = req.headers; |
||||
|
||||
// Init busboy instance
|
||||
busboy = new Busboy(busboyOptions); |
||||
|
||||
// Build multipart req.body fields
|
||||
busboy.on('field', function(fieldname, val) { |
||||
req.body = req.body || {}; |
||||
|
||||
let prev = req.body[fieldname]; |
||||
|
||||
if (!prev) { |
||||
return req.body[fieldname] = val; |
||||
} |
||||
|
||||
if (Array.isArray(prev)) { |
||||
return prev.push(val); |
||||
} |
||||
|
||||
req.body[fieldname] = [prev, val]; |
||||
}); |
||||
|
||||
// Build req.files fields
|
||||
busboy.on('file', function(fieldname, file, filename, encoding, mime) { |
||||
const buffers = []; |
||||
let safeFileNameRegex = /[^\w-]/g; |
||||
|
||||
file.on('limit', () => { |
||||
if (options.abortOnLimit) { |
||||
res.writeHead(413, {'Connection': 'close'}); |
||||
res.end('File size limit has been reached'); |
||||
} |
||||
}); |
||||
|
||||
file.on('data', function(data) { |
||||
buffers.push(data); |
||||
|
||||
if (options.debug) { |
||||
return console.log('Uploading %s -> %s', fieldname, filename); // eslint-disable-line
|
||||
} |
||||
}); |
||||
|
||||
file.on('end', function() { |
||||
if (!req.files) { |
||||
req.files = {}; |
||||
} |
||||
|
||||
const buf = Buffer.concat(buffers); |
||||
// see: https://github.com/richardgirges/express-fileupload/issues/14
|
||||
// firefox uploads empty file in case of cache miss when f5ing page.
|
||||
// resulting in unexpected behavior. if there is no file data, the file is invalid.
|
||||
if (!buf.length) { |
||||
return; |
||||
} |
||||
|
||||
if (options.safeFileNames) { |
||||
let maxExtensionLength = 3; |
||||
let extension = ''; |
||||
|
||||
if (typeof options.safeFileNames === 'object') { |
||||
safeFileNameRegex = options.safeFileNames; |
||||
} |
||||
|
||||
maxExtensionLength = parseInt(options.preserveExtension); |
||||
if (options.preserveExtension || maxExtensionLength === 0) { |
||||
if (isNaN(maxExtensionLength)) { |
||||
maxExtensionLength = 3; |
||||
} else { |
||||
maxExtensionLength = Math.abs(maxExtensionLength); |
||||
} |
||||
|
||||
let filenameParts = filename.split('.'); |
||||
let filenamePartsLen = filenameParts.length; |
||||
if (filenamePartsLen > 1) { |
||||
extension = filenameParts.pop(); |
||||
|
||||
if (extension.length > maxExtensionLength && maxExtensionLength > 0) { |
||||
filenameParts[filenameParts.length - 1] += |
||||
'.' + extension.substr(0, extension.length - maxExtensionLength); |
||||
extension = extension.substr(-maxExtensionLength); |
||||
} |
||||
|
||||
extension = maxExtensionLength ? '.' + extension.replace(safeFileNameRegex, '') : ''; |
||||
filename = filenameParts.join('.'); |
||||
} |
||||
} |
||||
|
||||
filename = filename.replace(safeFileNameRegex, '').concat(extension); |
||||
} |
||||
|
||||
let newFile = { |
||||
name: filename, |
||||
data: buf, |
||||
encoding: encoding, |
||||
truncated: file.truncated, |
||||
mimetype: mime, |
||||
md5: md5(buf), |
||||
mv: function(filePath, callback) { |
||||
// Callback is passed in, use the callback API
|
||||
if (callback) { |
||||
doMove( |
||||
() => { |
||||
callback(null); |
||||
}, |
||||
(error) => { |
||||
callback(error); |
||||
} |
||||
); |
||||
|
||||
// Otherwise, return a promise
|
||||
} else { |
||||
return new Promise((resolve, reject) => { |
||||
doMove(resolve, reject); |
||||
}); |
||||
} |
||||
|
||||
/** |
||||
* Local function that moves the file to a different location on the filesystem |
||||
* Takes two function arguments to make it compatible w/ Promise or Callback APIs |
||||
* @param {Function} successFunc |
||||
* @param {Function} errorFunc |
||||
*/ |
||||
function doMove(successFunc, errorFunc) { |
||||
if (options.createParentPath) { |
||||
const parentPath = path.dirname(filePath); |
||||
if (!fs.existsSync(parentPath)) { |
||||
fs.mkdirSync(parentPath); |
||||
} |
||||
} |
||||
|
||||
const fstream = fs.createWriteStream(filePath); |
||||
|
||||
streamifier.createReadStream(buf).pipe(fstream); |
||||
|
||||
fstream.on('error', function(error) { |
||||
errorFunc(error); |
||||
}); |
||||
|
||||
fstream.on('close', function() { |
||||
successFunc(); |
||||
}); |
||||
} |
||||
} |
||||
}; |
||||
|
||||
// Non-array fields
|
||||
if (!req.files.hasOwnProperty(fieldname)) { |
||||
req.files[fieldname] = newFile; |
||||
} else { |
||||
// Array fields
|
||||
if (req.files[fieldname] instanceof Array) { |
||||
req.files[fieldname].push(newFile); |
||||
} else { |
||||
req.files[fieldname] = [req.files[fieldname], newFile]; |
||||
} |
||||
} |
||||
}); |
||||
|
||||
file.on('error', next); |
||||
}); |
||||
|
||||
busboy.on('finish', next); |
||||
|
||||
busboy.on('error', next); |
||||
|
||||
req.pipe(busboy); |
||||
} |
||||
|
||||
// Methods below were copied from, or heavily inspired by the Connect and connect-busboy packages
|
||||
|
||||
/** |
||||
* Ensures the request is not using a non-compliant multipart method |
||||
* such as GET or HEAD |
||||
* @param {Object} req Express req object |
||||
* @return {Boolean} |
||||
*/ |
||||
function hasAcceptableMethod(req) { |
||||
return (UNACCEPTABLE_METHODS.indexOf(req.method) < 0); |
||||
} |
||||
|
||||
/** |
||||
* Ensures that only multipart requests are processed by express-fileupload |
||||
* @param {Object} req Express req object |
||||
* @return {Boolean} |
||||
*/ |
||||
function hasAcceptableMime(req) { |
||||
let str = (req.headers['content-type'] || '').split(';')[0]; |
||||
|
||||
return ACCEPTABLE_MIME.test(str); |
||||
} |
||||
|
||||
/** |
||||
* Ensures the request contains a content body |
||||
* @param {Object} req Express req object |
||||
* @return {Boolean} |
||||
* Quietly expose fileFactory; useful for testing |
||||
*/ |
||||
function hasBody(req) { |
||||
return ('transfer-encoding' in req.headers) || |
||||
('content-length' in req.headers && req.headers['content-length'] !== '0'); |
||||
} |
||||
module.exports.fileFactory = fileFactory; |
||||
|
@ -0,0 +1,44 @@ |
||||
const ACCEPTABLE_CONTENT_TYPE = /^(?:multipart\/.+)$/i; |
||||
const UNACCEPTABLE_METHODS = [ |
||||
'GET', |
||||
'HEAD' |
||||
]; |
||||
|
||||
/** |
||||
* Ensures that the request in question is eligible for file uploads |
||||
* @param {Object} req Express req object |
||||
*/ |
||||
module.exports = function(req) { |
||||
return hasBody(req) && hasAcceptableMethod(req) && hasAcceptableContentType(req); |
||||
}; |
||||
|
||||
/** |
||||
* Ensures the request is not using a non-compliant multipart method |
||||
* such as GET or HEAD |
||||
* @param {Object} req Express req object |
||||
* @return {Boolean} |
||||
*/ |
||||
function hasAcceptableMethod(req) { |
||||
return (UNACCEPTABLE_METHODS.indexOf(req.method) < 0); |
||||
} |
||||
|
||||
/** |
||||
* Ensures that only multipart requests are processed by express-fileupload |
||||
* @param {Object} req Express req object |
||||
* @return {Boolean} |
||||
*/ |
||||
function hasAcceptableContentType(req) { |
||||
let str = (req.headers['content-type'] || '').split(';')[0]; |
||||
|
||||
return ACCEPTABLE_CONTENT_TYPE.test(str); |
||||
} |
||||
|
||||
/** |
||||
* Ensures the request contains a content body |
||||
* @param {Object} req Express req object |
||||
* @return {Boolean} |
||||
*/ |
||||
function hasBody(req) { |
||||
return ('transfer-encoding' in req.headers) || |
||||
('content-length' in req.headers && req.headers['content-length'] !== '0'); |
||||
} |
@ -0,0 +1,147 @@ |
||||
const Busboy = require('busboy'); |
||||
const fileFactory = require('./fileFactory'); |
||||
|
||||
/** |
||||
* Processes multipart request |
||||
* Builds a req.body object for fields |
||||
* Builds a req.files object for files |
||||
* @param {Object} options expressFileupload and Busboy options |
||||
* @param {Object} req Express request object |
||||
* @param {Object} res Express response object |
||||
* @param {Function} next Express next method |
||||
* @return {void} |
||||
*/ |
||||
module.exports = function processMultipart(options, req, res, next) { |
||||
let busboyOptions = {}; |
||||
let busboy; |
||||
|
||||
req.files = null; |
||||
|
||||
// Build busboy config
|
||||
for (let k in options) { |
||||
if (Object.prototype.hasOwnProperty.call(options, k)) { |
||||
busboyOptions[k] = options[k]; |
||||
} |
||||
} |
||||
|
||||
// Attach request headers to busboy config
|
||||
busboyOptions.headers = req.headers; |
||||
|
||||
// Init busboy instance
|
||||
busboy = new Busboy(busboyOptions); |
||||
|
||||
// Build multipart req.body fields
|
||||
busboy.on('field', function(fieldname, val) { |
||||
req.body = req.body || {}; |
||||
|
||||
let prev = req.body[fieldname]; |
||||
|
||||
if (!prev) { |
||||
return req.body[fieldname] = val; |
||||
} |
||||
|
||||
if (Array.isArray(prev)) { |
||||
return prev.push(val); |
||||
} |
||||
|
||||
req.body[fieldname] = [prev, val]; |
||||
}); |
||||
|
||||
// Build req.files fields
|
||||
busboy.on('file', function(fieldname, file, filename, encoding, mime) { |
||||
const buffers = []; |
||||
let safeFileNameRegex = /[^\w-]/g; |
||||
|
||||
file.on('limit', () => { |
||||
if (options.abortOnLimit) { |
||||
res.writeHead(413, {'Connection': 'close'}); |
||||
res.end('File size limit has been reached'); |
||||
} |
||||
}); |
||||
|
||||
file.on('data', function(data) { |
||||
buffers.push(data); |
||||
|
||||
if (options.debug) { |
||||
return console.log('Uploading %s -> %s', fieldname, filename); // eslint-disable-line
|
||||
} |
||||
}); |
||||
|
||||
file.on('end', function() { |
||||
if (!req.files) { |
||||
req.files = {}; |
||||
} |
||||
|
||||
const buf = Buffer.concat(buffers); |
||||
// see: https://github.com/richardgirges/express-fileupload/issues/14
|
||||
// firefox uploads empty file in case of cache miss when f5ing page.
|
||||
// resulting in unexpected behavior. if there is no file data, the file is invalid.
|
||||
if (!buf.length) { |
||||
return; |
||||
} |
||||
|
||||
if (options.safeFileNames) { |
||||
let maxExtensionLength = 3; |
||||
let extension = ''; |
||||
|
||||
if (typeof options.safeFileNames === 'object') { |
||||
safeFileNameRegex = options.safeFileNames; |
||||
} |
||||
|
||||
maxExtensionLength = parseInt(options.preserveExtension); |
||||
if (options.preserveExtension || maxExtensionLength === 0) { |
||||
if (isNaN(maxExtensionLength)) { |
||||
maxExtensionLength = 3; |
||||
} else { |
||||
maxExtensionLength = Math.abs(maxExtensionLength); |
||||
} |
||||
|
||||
let filenameParts = filename.split('.'); |
||||
let filenamePartsLen = filenameParts.length; |
||||
if (filenamePartsLen > 1) { |
||||
extension = filenameParts.pop(); |
||||
|
||||
if (extension.length > maxExtensionLength && maxExtensionLength > 0) { |
||||
filenameParts[filenameParts.length - 1] += |
||||
'.' + extension.substr(0, extension.length - maxExtensionLength); |
||||
extension = extension.substr(-maxExtensionLength); |
||||
} |
||||
|
||||
extension = maxExtensionLength ? '.' + extension.replace(safeFileNameRegex, '') : ''; |
||||
filename = filenameParts.join('.'); |
||||
} |
||||
} |
||||
|
||||
filename = filename.replace(safeFileNameRegex, '').concat(extension); |
||||
} |
||||
|
||||
const newFile = fileFactory({ |
||||
name: filename, |
||||
buffer: buf, |
||||
encoding: encoding, |
||||
truncated: file.truncated, |
||||
mimetype: mime |
||||
}); |
||||
|
||||
// Non-array fields
|
||||
if (!req.files.hasOwnProperty(fieldname)) { |
||||
req.files[fieldname] = newFile; |
||||
} else { |
||||
// Array fields
|
||||
if (req.files[fieldname] instanceof Array) { |
||||
req.files[fieldname].push(newFile); |
||||
} else { |
||||
req.files[fieldname] = [req.files[fieldname], newFile]; |
||||
} |
||||
} |
||||
}); |
||||
|
||||
file.on('error', next); |
||||
}); |
||||
|
||||
busboy.on('finish', next); |
||||
|
||||
busboy.on('error', next); |
||||
|
||||
req.pipe(busboy); |
||||
}; |
@ -0,0 +1,78 @@ |
||||
'use strict'; |
||||
|
||||
const assert = require('assert'); |
||||
const fs = require('fs'); |
||||
const md5 = require('md5'); |
||||
const path = require('path'); |
||||
const fileFactory = require('../lib').fileFactory; |
||||
const server = require('./server'); |
||||
|
||||
const mockBuffer = fs.readFileSync(path.join(server.fileDir, 'basketball.png')); |
||||
|
||||
describe('Test of the fileFactory factory', function() { |
||||
beforeEach(function() { |
||||
server.clearUploadsDir(); |
||||
}); |
||||
|
||||
it('return a file object', function() { |
||||
assert.ok(fileFactory({ |
||||
name: 'basketball.png', |
||||
buffer: mockBuffer |
||||
})); |
||||
}); |
||||
|
||||
describe('File object behavior', function() { |
||||
const file = fileFactory({ |
||||
name: 'basketball.png', |
||||
buffer: mockBuffer |
||||
}); |
||||
it('move the file to the specified folder', function(done) { |
||||
file.mv(path.join(server.uploadDir, 'basketball.png'), function(err) { |
||||
assert.ifError(err); |
||||
done(); |
||||
}); |
||||
}); |
||||
it('reject the mv if the destination does not exists', function(done) { |
||||
file.mv(path.join(server.uploadDir, 'unknown', 'basketball.png'), function(err) { |
||||
assert.ok(err); |
||||
done(); |
||||
}); |
||||
}); |
||||
}); |
||||
|
||||
describe('Properties', function() { |
||||
it('contains the name property', function() { |
||||
assert.equal(fileFactory({ |
||||
name: 'basketball.png', |
||||
buffer: mockBuffer |
||||
}).name, 'basketball.png'); |
||||
}); |
||||
it('contains the data property', function() { |
||||
assert.ok(fileFactory({ |
||||
name: 'basketball.png', |
||||
buffer: mockBuffer |
||||
}).data); |
||||
}); |
||||
it('contains the encoding property', function() { |
||||
assert.equal(fileFactory({ |
||||
name: 'basketball.png', |
||||
buffer: mockBuffer, |
||||
encoding: 'utf-8' |
||||
}).encoding, 'utf-8'); |
||||
}); |
||||
it('contains the mimetype property', function() { |
||||
assert.equal(fileFactory({ |
||||
name: 'basketball.png', |
||||
buffer: mockBuffer, |
||||
mimetype: 'image/png' |
||||
}).mimetype, 'image/png'); |
||||
}); |
||||
it('contains the md5 property', function() { |
||||
const mockMd5 = md5(mockBuffer); |
||||
assert.equal(fileFactory({ |
||||
name: 'basketball.png', |
||||
buffer: mockBuffer |
||||
}).md5, mockMd5); |
||||
}); |
||||
}); |
||||
}); |
Loading…
Reference in new issue