diff --git a/README.md b/README.md index ab9538a..627f8d7 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,8 @@ [download-image]: https://img.shields.io/npm/dm/egg-multipart.svg?style=flat-square [download-url]: https://npmjs.org/package/egg-multipart -Use [co-busboy](https://github.com/cojs/busboy) to upload file by streaming and process it without save to disk. +Use [co-busboy](https://github.com/cojs/busboy) to upload file by streaming and +process it without save to disk(using the `stream` mode). Just use `ctx.multipart()` to got file stream, then pass to image processing liberary such as `gm` or upload to cloud storage such as `oss`. @@ -107,9 +108,135 @@ exports.multipart = { ## Examples -[More Examples](https://github.com/eggjs/examples/tree/master/multipart) +More examples please follow: -### Upload File +- [Handle multipart request in `stream` mode](https://github.com/eggjs/examples/tree/master/multipart) +- [Handle multipart request in `file` mode](https://github.com/eggjs/examples/tree/master/multipart-file-mode) + +## `file` mode: the easy way + +If you don't know the [Node.js Stream](https://nodejs.org/dist/latest-v10.x/docs/api/stream.html) work, maybe you should use the `file` mode to get started. + +The usage very similar to [bodyParser](https://eggjs.org/en/basics/controller.html#body). + +- `ctx.request.body`: Get all the multipart fields and values, except `file`. +- `ctx.request.files`: Contains all `file` from the multipart request, it's an Array object. + +**WARNING: you should remove the temporary upload file after you use it** + +### Enable `file` mode on config + +You need to set `config.multipart.mode = 'file'` to enable `file` mode: + +```js +// config/config.default.js +exports.multipart = { + mode: 'file', +}; +``` + +After `file` mode enable, egg will remove the old temporary files(don't include today's files) on `04:30 AM` every day by default. + +```js +config.multipart = { + mode: 'file', + tmpdir: path.join(os.tmpdir(), 'egg-multipart-tmp', appInfo.name), + cleanSchedule: { + // run tmpdir clean job on every day 04:30 am + // cron style see https://github.com/eggjs/egg-schedule#cron-style-scheduling + cron: '0 30 4 * * *', + }, +}; +``` + +### Upload One File + +```html +
+ title: + file: + +
+``` + +Controller which hanlder `POST /upload`: + +```js +// app/controller/upload.js +const Controller = require('egg').Controller; +const fs = require('mz/fs'); + +module.exports = class extends Controller { + async upload() { + const { ctx } = this; + const file = ctx.request.files[0]; + const name = 'egg-multipart-test/' + path.basename(file.filename); + let result; + try { + // process file or upload to cloud storage + result = await ctx.oss.put(name, file.filepath); + } finally { + // need to remove the tmp file + await fs.unlink(file.filepath); + } + + ctx.body = { + url: result.url, + // get all field values + requestBody: ctx.request.body, + }; + } +}; +``` + +### Upload Multiple Files + +```html +
+ title: + file1: + file2: + +
+``` + +Controller which hanlder `POST /upload`: + +```js +// app/controller/upload.js +const Controller = require('egg').Controller; +const fs = require('mz/fs'); + +module.exports = class extends Controller { + async upload() { + const { ctx } = this; + console.log(ctx.request.body); + console.log('got %d files', ctx.request.files.length); + for (const file of ctx.request.files) { + console.log('field: ' + file.fieldname); + console.log('filename: ' + file.filename); + console.log('encoding: ' + file.encoding); + console.log('mime: ' + file.mime); + console.log('tmp filepath: ' + file.filepath); + let result; + try { + // process file or upload to cloud storage + result = await ctx.oss.put('egg-multipart-test/' + file.filename, file.filepath); + } finally { + // need to remove the tmp file + await fs.unlink(file.filepath); + } + console.log(result); + } + } +}; +``` + +## `stream` mode: the hard way + +If you're well-known about know the Node.js Stream work, you should use the `stream` mode. + +### Upload One File You can got upload stream by `ctx.getFileStream*()`. @@ -129,9 +256,9 @@ const path = require('path'); const sendToWormhole = require('stream-wormhole'); const Controller = require('egg').Controller; -module.exports = Class UploadController extends Controller { +module.exports = class extends Controller { async upload() { - const ctx = this.ctx; + const { ctx } = this; // file not exists will response 400 error const stream = await ctx.getFileStream(); const name = 'egg-multipart-test/' + path.basename(stream.filename); @@ -146,7 +273,7 @@ module.exports = Class UploadController extends Controller { } async uploadNotRequiredFile() { - const ctx = this.ctx; + const { ctx } = this; // file not required const stream = await ctx.getFileStream({ requireFile: false }); let result; @@ -173,7 +300,8 @@ module.exports = Class UploadController extends Controller { ```html
title: - file: + file1: + file2:
``` @@ -184,9 +312,9 @@ Controller which hanlder `POST /upload`: // app/controller/upload.js const Controller = require('egg').Controller; -module.exports = Class UploadController extends Controller { +module.exports = class extends Controller { async upload() { - const ctx = this.ctx; + const { ctx } = this; const parts = ctx.multipart(); let part; while ((part = await parts()) != null) { @@ -201,7 +329,7 @@ module.exports = Class UploadController extends Controller { // user click `upload` before choose a file, // `part` will be file stream, but `part.filename` is empty // must handler this, such as log error. - return; + continue; } // otherwise, it's a stream console.log('field: ' + part.fieldname); @@ -216,3 +344,7 @@ module.exports = Class UploadController extends Controller { } }; ``` + +## License + +[MIT](LICENSE) diff --git a/app.js b/app.js index e88c901..f249028 100644 --- a/app.js +++ b/app.js @@ -79,4 +79,17 @@ module.exports = app => { } }, }; + + options.mode = options.mode || 'stream'; + if (![ 'stream', 'file' ].includes(options.mode)) { + throw new TypeError(`Expect mode to be 'stream' or 'file', but got '${options.mode}'`); + } + + app.coreLogger.info('[egg-multipart] %s mode enable', options.mode); + if (options.mode === 'file') { + app.coreLogger.info('[egg-multipart] will save temporary files to %j, cleanup job cron: %j', + options.tmpdir, options.cleanSchedule.cron); + // enable multipart middleware + app.config.coreMiddleware.push('multipart'); + } }; diff --git a/app/extend/context.js b/app/extend/context.js index 8998111..fbe7223 100644 --- a/app/extend/context.js +++ b/app/extend/context.js @@ -9,6 +9,8 @@ class EmptyStream extends Readable { } } +const HAS_CONSUMED = Symbol('Context#multipartHasConsumed'); + module.exports = { /** * create multipart.parts instance, to get separated files. @@ -21,6 +23,9 @@ module.exports = { if (!this.is('multipart')) { this.throw(400, 'Content-Type must be multipart/*'); } + if (this[HAS_CONSUMED]) throw new TypeError('the multipart request can\'t be consumed twice'); + + this[HAS_CONSUMED] = true; const parseOptions = {}; Object.assign(parseOptions, this.app.config.multipartParseOptions, options); return parse(this, parseOptions); diff --git a/app/middleware/multipart.js b/app/middleware/multipart.js new file mode 100644 index 0000000..c342212 --- /dev/null +++ b/app/middleware/multipart.js @@ -0,0 +1,108 @@ +'use strict'; + +const path = require('path'); +const fs = require('mz/fs'); +const uuid = require('uuid'); +const mkdirp = require('mz-modules/mkdirp'); +const pump = require('mz-modules/pump'); +const sendToWormhole = require('stream-wormhole'); +const moment = require('moment'); + +module.exports = options => { + async function cleanup(requestFiles) { + for (const file of requestFiles) { + try { + await fs.unlink(file.filepath); + } catch (_) { + // do nothing + } + } + } + + async function limit(requestFiles, code, message) { + // cleanup requestFiles + await cleanup(requestFiles); + + // throw 413 error + const err = new Error(message); + err.code = code; + err.status = 413; + throw err; + } + + return async function multipart(ctx, next) { + if (!ctx.is('multipart')) return next(); + + let storedir; + + const requestBody = {}; + const requestFiles = []; + + const parts = ctx.multipart({ autoFields: false }); + let part; + do { + try { + part = await parts(); + } catch (err) { + await cleanup(requestFiles); + throw err; + } + + if (!part) break; + + if (part.length) { + ctx.coreLogger.debug('[egg-multipart:storeMultipart] handle value part: %j', part); + const fieldnameTruncated = part[2]; + const valueTruncated = part[3]; + if (valueTruncated) { + return await limit(requestFiles, 'Request_fieldSize_limit', 'Reach fieldSize limit'); + } + if (fieldnameTruncated) { + return await limit(requestFiles, 'Request_fieldNameSize_limit', 'Reach fieldNameSize limit'); + } + + // arrays are busboy fields + requestBody[part[0]] = part[1]; + continue; + } + + // otherwise, it's a stream + const meta = { + field: part.fieldname, + filename: part.filename, + encoding: part.encoding, + mime: part.mime, + }; + ctx.coreLogger.debug('[egg-multipart:storeMultipart] handle stream part: %j', meta); + // empty part, ignore it + if (!part.filename) { + await sendToWormhole(part); + continue; + } + + if (!storedir) { + // ${tmpdir}/YYYY/MM/DD/HH + storedir = path.join(options.tmpdir, moment().format('YYYY/MM/DD/HH')); + const exists = await fs.exists(storedir); + if (!exists) { + await mkdirp(storedir); + } + } + const filepath = path.join(storedir, uuid.v4() + path.extname(meta.filename)); + const target = fs.createWriteStream(filepath); + await pump(part, target); + // https://github.com/mscdex/busboy/blob/master/lib/types/multipart.js#L221 + meta.filepath = filepath; + requestFiles.push(meta); + + // https://github.com/mscdex/busboy/blob/master/lib/types/multipart.js#L221 + if (part.truncated) { + return await limit(requestFiles, 'Request_fileSize_limit', 'Reach fileSize limit'); + } + } while (part != null); + + ctx.request.body = requestBody; + ctx.request.files = requestFiles; + return next(); + }; +}; diff --git a/app/schedule/clean_tmpdir.js b/app/schedule/clean_tmpdir.js new file mode 100644 index 0000000..13bf23d --- /dev/null +++ b/app/schedule/clean_tmpdir.js @@ -0,0 +1,58 @@ +'use strict'; + +const path = require('path'); +const fs = require('mz/fs'); +const rimraf = require('mz-modules/rimraf'); +const moment = require('moment'); + +module.exports = app => { + return class CleanTmpdir extends (app.Subscription || app.BaseContextClass) { + static get schedule() { + return { + type: 'worker', + cron: app.config.multipart.cleanSchedule.cron, + immediate: false, + // disable on stream mode + disable: app.config.multipart.mode === 'stream', + }; + } + + async _remove(dir) { + const { ctx } = this; + if (await fs.exists(dir)) { + ctx.coreLogger.info('[egg-multipart:CleanTmpdir] removing tmpdir: %j', dir); + try { + await rimraf(dir); + ctx.coreLogger.info('[egg-multipart:CleanTmpdir:success] tmpdir: %j has been removed', dir); + } catch (err) { + ctx.coreLogger.error('[egg-multipart:CleanTmpdir:error] remove tmpdir: %j error: %s', + dir, err); + ctx.coreLogger.error(err); + } + } + } + + async subscribe() { + const { ctx } = this; + const config = ctx.app.config; + ctx.coreLogger.info('[egg-multipart:CleanTmpdir] start clean tmpdir: %j', config.multipart.tmpdir); + // last year + const lastYear = moment().subtract(1, 'years'); + const lastYearDir = path.join(config.multipart.tmpdir, lastYear.format('YYYY')); + await this._remove(lastYearDir); + // 3 months + for (let i = 1; i <= 3; i++) { + const date = moment().subtract(i, 'months'); + const dir = path.join(config.multipart.tmpdir, date.format('YYYY/MM')); + await this._remove(dir); + } + // 7 days + for (let i = 1; i <= 7; i++) { + const date = moment().subtract(i, 'days'); + const dir = path.join(config.multipart.tmpdir, date.format('YYYY/MM/DD')); + await this._remove(dir); + } + ctx.coreLogger.info('[egg-multipart:CleanTmpdir] end'); + } + }; +}; diff --git a/appveyor.yml b/appveyor.yml deleted file mode 100644 index 981e82b..0000000 --- a/appveyor.yml +++ /dev/null @@ -1,15 +0,0 @@ -environment: - matrix: - - nodejs_version: '8' - - nodejs_version: '10' - -install: - - ps: Install-Product node $env:nodejs_version - - npm i npminstall && node_modules\.bin\npminstall - -test_script: - - node --version - - npm --version - - npm run test - -build: off diff --git a/azure-pipelines.template.yml b/azure-pipelines.template.yml new file mode 100644 index 0000000..b468c90 --- /dev/null +++ b/azure-pipelines.template.yml @@ -0,0 +1,47 @@ +# Node.js +# Build a general Node.js application with npm. +# Add steps that analyze code, save build artifacts, deploy, and more: +# https://docs.microsoft.com/vsts/pipelines/languages/javascript +# demo: https://github.com/parcel-bundler/parcel/blob/master/azure-pipelines-template.yml + +jobs: +- job: ${{ parameters.name }} + pool: + vmImage: ${{ parameters.vmImage }} + strategy: + matrix: + node_8: + node_version: 8 + node_10: + node_version: 10 + maxParallel: 2 + steps: + - task: NodeTool@0 + inputs: + versionSpec: $(node_version) + displayName: 'Install Node.js' + + # Set ENV + - ${{ if ne(parameters.name, 'windows') }}: + - script: | + echo $PWD + export PATH="$PATH:$PWD/node_modules/.bin" + echo "##vso[task.setvariable variable=PATH]$PATH" + displayName: Set ENV + - ${{ if eq(parameters.name, 'windows') }}: + - script: | + echo %cd% + set PATH=%PATH%;%cd%\node_modules\.bin + echo "##vso[task.setvariable variable=PATH]%PATH%" + displayName: Set ENV + + - script: | + npm i npminstall && npminstall + displayName: 'Install Packages' + - script: | + npm run ci-windows + displayName: 'Build & Unit Test' + - ${{ if ne(parameters.name, 'windows') }}: + - script: | + npminstall codecov && codecov + displayName: 'Report Coverage' diff --git a/azure-pipelines.yml b/azure-pipelines.yml new file mode 100644 index 0000000..554935f --- /dev/null +++ b/azure-pipelines.yml @@ -0,0 +1,16 @@ +jobs: +- template: azure-pipelines.template.yml + parameters: + name: linux + vmImage: 'ubuntu-16.04' + +- template: azure-pipelines.template.yml + parameters: + name: windows + vmImage: 'vs2017-win2016' + +- template: azure-pipelines.template.yml + parameters: + name: macos + vmImage: 'xcode9-macos10.13' + diff --git a/config/config.default.js b/config/config.default.js index 64e87f1..de94497 100644 --- a/config/config.default.js +++ b/config/config.default.js @@ -1,27 +1,47 @@ 'use strict'; -/** - * multipart parser options - * @member Config#multipart - * @property {Boolean} autoFields - Auto set fields to parts, default is `false`. - * If set true,all fields will be auto handle and can acces by `parts.fields` - * @property {String} defaultCharset - Default charset encoding, don't change it before you real know about it - * @property {Integer} fieldNameSize - Max field name size (in bytes), default is `100` - * @property {String|Integer} fieldSize - Max field value size (in bytes), default is `100kb` - * @property {Integer} fields - Max number of non-file fields, default is `10` - * @property {String|Integer} fileSize - Max file size (in bytes), default is `10mb` - * @property {Integer} files - Max number of file fields, default is `10` - * @property {Array|Function} whitelist - The white ext file names, default is `null` - * @property {Array} fileExtensions - Add more ext file names to the `whitelist`, default is `[]` - */ -exports.multipart = { - autoFields: false, - defaultCharset: 'utf8', - fieldNameSize: 100, - fieldSize: '100kb', - fields: 10, - fileSize: '10mb', - files: 10, - fileExtensions: [], - whitelist: null, +const os = require('os'); +const path = require('path'); + +module.exports = appInfo => { + const config = {}; + + /** + * multipart parser options + * @member Config#multipart + * @property {String} mode - which mode to handle multipart request, default is `stream`, the hard way. + * If set mode to `file`, it's the easy way to handle multipart request and save it to local files. + * If you don't know the Node.js Stream work, maybe you should use the `file` mode to get started. + * @property {Boolean} autoFields - Auto set fields to parts, default is `false`. Only work on `stream` mode. + * If set true,all fields will be auto handle and can acces by `parts.fields` + * @property {String} defaultCharset - Default charset encoding, don't change it before you real know about it + * @property {Integer} fieldNameSize - Max field name size (in bytes), default is `100` + * @property {String|Integer} fieldSize - Max field value size (in bytes), default is `100kb` + * @property {Integer} fields - Max number of non-file fields, default is `10` + * @property {String|Integer} fileSize - Max file size (in bytes), default is `10mb` + * @property {Integer} files - Max number of file fields, default is `10` + * @property {Array|Function} whitelist - The white ext file names, default is `null` + * @property {Array} fileExtensions - Add more ext file names to the `whitelist`, default is `[]` + * @property {String} tmpdir - The directory for temporary files. Only work on `file` mode. + */ + config.multipart = { + mode: 'stream', + autoFields: false, + defaultCharset: 'utf8', + fieldNameSize: 100, + fieldSize: '100kb', + fields: 10, + fileSize: '10mb', + files: 10, + fileExtensions: [], + whitelist: null, + tmpdir: path.join(os.tmpdir(), 'egg-multipart-tmp', appInfo.name), + cleanSchedule: { + // run tmpdir clean job on every day 04:30 am + // cron style see https://github.com/eggjs/egg-schedule#cron-style-scheduling + cron: '0 30 4 * * *', + }, + }; + + return config; }; diff --git a/package.json b/package.json index bbd448a..99369bc 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,10 @@ "name": "egg-multipart", "version": "2.1.0", "eggPlugin": { - "name": "multipart" + "name": "multipart", + "optionalDependencies": [ + "schedule" + ] }, "description": "multipart plugin for egg", "main": "index.js", @@ -13,6 +16,7 @@ "test-local": "egg-bin test", "cov": "egg-bin cov", "ci": "egg-bin pkgfiles && npm run lint && npm run cov", + "ci-windows": "egg-bin pkgfiles && npm run lint -- --fix && npm run cov", "pkgfiles": "egg-bin pkgfiles" }, "repository": { @@ -28,7 +32,7 @@ "author": "gxcsoccer ", "license": "MIT", "bugs": { - "url": "https://github.com/eggjs/egg-multipart/issues" + "url": "https://github.com/eggjs/egg/issues" }, "homepage": "https://github.com/eggjs/egg-multipart#readme", "engines": { @@ -40,29 +44,35 @@ "app.js" ], "ci": { + "type": "travis, azure-pipelines", + "command": { + "azure-pipelines": "ci-windows" + }, "version": "8, 10", - "license": true + "license": { + "year": 2017 + } }, "dependencies": { "co-busboy": "^1.4.0", - "humanize-bytes": "^1.0.1" + "humanize-bytes": "^1.0.1", + "moment": "^2.22.2", + "mz": "^2.7.0", + "mz-modules": "^2.1.0", + "stream-wormhole": "^1.1.0", + "uuid": "^3.3.2" }, "devDependencies": { "autod": "^3.0.1", - "egg": "^2.9.1", - "egg-bin": "^4.8.1", - "egg-ci": "^1.8.0", - "egg-mock": "^3.18.0", - "eslint": "^5.2.0", - "eslint-config-egg": "^7.0.0", + "egg": "^2.11.2", + "egg-bin": "^4.9.0", + "egg-ci": "^1.9.2", + "egg-mock": "^3.20.1", + "eslint": "^5.6.0", + "eslint-config-egg": "^7.1.0", "formstream": "^1.1.0", "is-type-of": "^1.0.0", - "mkdirp": "^0.5.1", - "mz": "^2.7.0", - "mz-modules": "^2.1.0", - "stream-wormhole": "^1.0.3", - "supertest": "^3.0.0", - "urllib": "^2.29.1", + "urllib": "^2.30.0", "webstorm-disable-index": "^1.2.0" } } diff --git a/test/file-mode.test.js b/test/file-mode.test.js new file mode 100644 index 0000000..6ac1d10 --- /dev/null +++ b/test/file-mode.test.js @@ -0,0 +1,337 @@ +'use strict'; + +const assert = require('assert'); +const formstream = require('formstream'); +const urllib = require('urllib'); +const path = require('path'); +const mock = require('egg-mock'); +const rimraf = require('mz-modules/rimraf'); +const mkdirp = require('mz-modules/mkdirp'); +const fs = require('mz/fs'); +const moment = require('moment'); + +describe('test/file-mode.test.js', () => { + let app; + let server; + let host; + before(() => { + app = mock.app({ + baseDir: 'apps/file-mode', + }); + return app.ready(); + }); + before(() => { + server = app.listen(); + host = 'http://127.0.0.1:' + server.address().port; + }); + after(() => { + return rimraf(app.config.multipart.tmpdir); + }); + after(() => app.close()); + after(() => server.close()); + beforeEach(() => app.mockCsrf()); + afterEach(mock.restore); + + it('should ignore non multipart request', async () => { + const res = await app.httpRequest() + .post('/upload') + .send({ + foo: 'bar', + n: 1, + }); + assert(res.status === 200); + assert.deepStrictEqual(res.body, { + body: { + foo: 'bar', + n: 1, + }, + }); + }); + + it('should upload', async () => { + const form = formstream(); + form.field('foo', 'fengmk2').field('love', 'egg'); + form.file('file1', __filename, 'foooooooo.js'); + form.file('file2', __filename); + // will ignore empty file + form.buffer('file3', Buffer.from(''), '', 'application/octet-stream'); + form.file('bigfile', path.join(__dirname, 'fixtures', 'bigfile.js')); + // other form fields + form.field('work', 'with Node.js'); + + const headers = form.headers(); + const res = await urllib.request(host + '/upload', { + method: 'POST', + headers, + stream: form, + }); + + assert(res.status === 200); + const data = JSON.parse(res.data); + assert.deepStrictEqual(data.body, { foo: 'fengmk2', love: 'egg', work: 'with Node.js' }); + assert(data.files.length === 3); + assert(data.files[0].field === 'file1'); + assert(data.files[0].filename === 'foooooooo.js'); + assert(data.files[0].encoding === '7bit'); + assert(data.files[0].mime === 'application/javascript'); + assert(data.files[0].filepath.startsWith(app.config.multipart.tmpdir)); + + assert(data.files[1].field === 'file2'); + assert(data.files[1].filename === 'file-mode.test.js'); + assert(data.files[1].encoding === '7bit'); + assert(data.files[1].mime === 'application/javascript'); + assert(data.files[1].filepath.startsWith(app.config.multipart.tmpdir)); + + assert(data.files[2].field === 'bigfile'); + assert(data.files[2].filename === 'bigfile.js'); + assert(data.files[2].encoding === '7bit'); + assert(data.files[2].mime === 'application/javascript'); + assert(data.files[2].filepath.startsWith(app.config.multipart.tmpdir)); + }); + + it('should 200 when file size just 10mb', async () => { + const form = formstream(); + form.buffer('file', Buffer.alloc(10 * 1024 * 1024), '10mb.js', 'application/octet-stream'); + + const headers = form.headers(); + const res = await urllib.request(host + '/upload', { + method: 'POST', + headers, + stream: form, + }); + + assert(res.status === 200); + const data = JSON.parse(res.data); + assert(data.files.length === 1); + assert(data.files[0].field === 'file'); + assert(data.files[0].filename === '10mb.js'); + assert(data.files[0].encoding === '7bit'); + assert(data.files[0].mime === 'application/octet-stream'); + assert(data.files[0].filepath.startsWith(app.config.multipart.tmpdir)); + const stat = await fs.stat(data.files[0].filepath); + assert(stat.size === 10 * 1024 * 1024); + }); + + it('should 200 when field size just 100kb', async () => { + const form = formstream(); + form.field('foo', 'a'.repeat(100 * 1024)); + + const headers = form.headers(); + const res = await urllib.request(host + '/upload', { + method: 'POST', + headers, + stream: form, + }); + + assert(res.status === 200); + const data = JSON.parse(res.data); + assert(data.body.foo === 'a'.repeat(100 * 1024)); + }); + + it('should 200 when request fields equal 10', async () => { + const form = formstream(); + for (let i = 0; i < 10; i++) { + form.field('foo' + i, 'a' + i); + } + + const headers = form.headers(); + const res = await urllib.request(host + '/upload', { + method: 'POST', + headers, + stream: form, + }); + + assert(res.status === 200); + const data = JSON.parse(res.data); + assert(Object.keys(data.body).length === 10); + }); + + it('should 200 when request files equal 10', async () => { + const form = formstream(); + for (let i = 0; i < 10; i++) { + form.file('foo' + i, __filename); + } + + const headers = form.headers(); + const res = await urllib.request(host + '/upload', { + method: 'POST', + headers, + stream: form, + }); + + assert(res.status === 200); + const data = JSON.parse(res.data); + assert(data.files.length === 10); + }); + + it('should throw error when request fields limit', async () => { + const form = formstream(); + for (let i = 0; i < 11; i++) { + form.field('foo' + i, 'a' + i); + } + + const headers = form.headers(); + const res = await urllib.request(host + '/upload', { + method: 'POST', + headers, + stream: form, + }); + + assert(res.status === 413); + assert(res.data.toString().includes('Request_fields_limitError: Reach fields limit')); + }); + + it('should throw error when request files limit', async () => { + const form = formstream(); + form.setMaxListeners(11); + for (let i = 0; i < 11; i++) { + form.file('foo' + i, __filename); + } + + const headers = form.headers(); + const res = await urllib.request(host + '/upload', { + method: 'POST', + headers, + stream: form, + }); + + assert(res.status === 413); + assert(res.data.toString().includes('Request_files_limitError: Reach files limit')); + }); + + it('should throw error when request field size limit', async () => { + const form = formstream(); + form.field('foo', 'a'.repeat(100 * 1024 + 1)); + + const headers = form.headers(); + const res = await urllib.request(host + '/upload', { + method: 'POST', + headers, + stream: form, + }); + + assert(res.status === 413); + assert(res.data.toString().includes('Request_fieldSize_limitError: Reach fieldSize limit')); + }); + + // fieldNameSize is TODO on busboy + // see https://github.com/mscdex/busboy/blob/master/lib/types/multipart.js#L5 + it.skip('should throw error when request field name size limit', async () => { + const form = formstream(); + form.field('b'.repeat(101), 'a'); + + const headers = form.headers(); + const res = await urllib.request(host + '/upload', { + method: 'POST', + headers, + stream: form, + }); + + assert(res.status === 413); + assert(res.data.toString().includes('Request_fieldSize_limitError: Reach fieldSize limit')); + }); + + it('should throw error when request file size limit', async () => { + const form = formstream(); + form.field('foo', 'fengmk2').field('love', 'egg'); + form.file('file1', __filename, 'foooooooo.js'); + form.file('file2', __filename); + form.buffer('file3', Buffer.alloc(10 * 1024 * 1024 + 1), 'toobigfile.js', 'application/octet-stream'); + form.file('bigfile', path.join(__dirname, 'fixtures', 'bigfile.js')); + // other form fields + form.field('work', 'with Node.js'); + + const headers = form.headers(); + const res = await urllib.request(host + '/upload', { + method: 'POST', + headers, + stream: form, + }); + + assert(res.status === 413); + assert(res.data.toString().includes('Request_fileSize_limitError: Reach fileSize limit')); + }); + + it('should throw error when file name invalid', async () => { + const form = formstream(); + form.field('foo', 'fengmk2').field('love', 'egg'); + form.file('file1', __filename, 'foooooooo.js.rar'); + form.file('file2', __filename); + // other form fields + form.field('work', 'with Node.js'); + + const headers = form.headers(); + const res = await urllib.request(host + '/upload', { + method: 'POST', + headers, + stream: form, + }); + + assert(res.status === 400); + assert(res.data.toString().includes('Error: Invalid filename: foooooooo.js.rar')); + }); + + it('should throw error on multipart() invoke twice', async () => { + const form = formstream(); + form.field('foo', 'fengmk2').field('love', 'egg'); + form.file('file2', __filename); + // other form fields + form.field('work', 'with Node.js'); + + const headers = form.headers(); + const res = await urllib.request(host + '/upload?call_multipart_twice=1', { + method: 'POST', + headers, + stream: form, + }); + + assert(res.status === 500); + assert(res.data.toString().includes('TypeError: the multipart request can\'t be consumed twice')); + }); + + describe('schedule/clean_tmpdir', () => { + it('should remove nothing', async () => { + app.mockLog(); + await app.runSchedule(path.join(__dirname, '../app/schedule/clean_tmpdir')); + app.expectLog('[egg-multipart:CleanTmpdir] start clean tmpdir: "', 'coreLogger'); + app.expectLog('[egg-multipart:CleanTmpdir] end', 'coreLogger'); + }); + + it('should remove old dirs', async () => { + const oldDirs = [ + path.join(app.config.multipart.tmpdir, moment().subtract(1, 'years').format('YYYY/MM/DD/HH')), + path.join(app.config.multipart.tmpdir, moment().subtract(1, 'months').format('YYYY/MM/DD/HH')), + path.join(app.config.multipart.tmpdir, moment().subtract(2, 'months').format('YYYY/MM/DD/HH')), + path.join(app.config.multipart.tmpdir, moment().subtract(3, 'months').format('YYYY/MM/DD/HH')), + path.join(app.config.multipart.tmpdir, moment().subtract(1, 'days').format('YYYY/MM/DD/HH')), + path.join(app.config.multipart.tmpdir, moment().subtract(7, 'days').format('YYYY/MM/DD/HH')), + ]; + const shouldKeepDirs = [ + path.join(app.config.multipart.tmpdir, moment().subtract(2, 'years').format('YYYY/MM/DD/HH')), + path.join(app.config.multipart.tmpdir, moment().subtract(4, 'months').format('YYYY/MM/DD/HH')), + path.join(app.config.multipart.tmpdir, moment().subtract(8, 'days').format('YYYY/MM/DD/HH')), + path.join(app.config.multipart.tmpdir, moment().format('YYYY/MM/DD/HH')), + ]; + await Promise.all(oldDirs.map(dir => mkdirp(dir))); + await Promise.all(shouldKeepDirs.map(dir => mkdirp(dir))); + + await Promise.all(oldDirs.map(dir => { + // create files + return fs.writeFile(path.join(dir, Date.now() + ''), new Date()); + })); + + app.mockLog(); + await app.runSchedule(path.join(__dirname, '../app/schedule/clean_tmpdir')); + for (const dir of oldDirs) { + const exists = await fs.exists(dir); + assert(!exists, dir); + } + for (const dir of shouldKeepDirs) { + const exists = await fs.exists(dir); + assert(exists, dir); + } + app.expectLog('[egg-multipart:CleanTmpdir] removing tmpdir: "', 'coreLogger'); + app.expectLog('[egg-multipart:CleanTmpdir:success] tmpdir: "', 'coreLogger'); + }); + }); +}); diff --git a/test/fixtures/apps/file-mode/app/controller/upload.js b/test/fixtures/apps/file-mode/app/controller/upload.js new file mode 100644 index 0000000..c21278e --- /dev/null +++ b/test/fixtures/apps/file-mode/app/controller/upload.js @@ -0,0 +1,12 @@ +'use strict'; + +module.exports = async ctx => { + ctx.body = { + body: ctx.request.body, + files: ctx.request.files, + }; + + if (ctx.query.call_multipart_twice) { + ctx.multipart(); + } +}; diff --git a/test/fixtures/apps/file-mode/app/router.js b/test/fixtures/apps/file-mode/app/router.js new file mode 100644 index 0000000..ea637f5 --- /dev/null +++ b/test/fixtures/apps/file-mode/app/router.js @@ -0,0 +1,5 @@ +'use strict'; + +module.exports = app => { + app.post('/upload', app.controller.upload); +}; diff --git a/test/fixtures/apps/file-mode/app/views/home.html b/test/fixtures/apps/file-mode/app/views/home.html new file mode 100644 index 0000000..c3991fb --- /dev/null +++ b/test/fixtures/apps/file-mode/app/views/home.html @@ -0,0 +1,8 @@ +
+ title: + file1: + file2: + file3: + other: + +
diff --git a/test/fixtures/apps/file-mode/config/config.default.js b/test/fixtures/apps/file-mode/config/config.default.js new file mode 100644 index 0000000..5f9cd8c --- /dev/null +++ b/test/fixtures/apps/file-mode/config/config.default.js @@ -0,0 +1,7 @@ +'use strict'; + +exports.multipart = { + mode: 'file', +}; + +exports.keys = 'multipart'; diff --git a/test/fixtures/apps/file-mode/config/config.unittest.js b/test/fixtures/apps/file-mode/config/config.unittest.js new file mode 100644 index 0000000..868ad8d --- /dev/null +++ b/test/fixtures/apps/file-mode/config/config.unittest.js @@ -0,0 +1,8 @@ +'use strict'; + +exports.logger = { + consoleLevel: 'NONE', + coreLogger: { + // consoleLevel: 'DEBUG', + }, +}; diff --git a/test/fixtures/apps/file-mode/package.json b/test/fixtures/apps/file-mode/package.json new file mode 100644 index 0000000..77eb670 --- /dev/null +++ b/test/fixtures/apps/file-mode/package.json @@ -0,0 +1,3 @@ +{ + "name": "multipart-file-mode-demo" +} diff --git a/test/fixtures/apps/upload-limit/app/router.js b/test/fixtures/apps/upload-limit/app/router.js index b0dff02..2353b05 100644 --- a/test/fixtures/apps/upload-limit/app/router.js +++ b/test/fixtures/apps/upload-limit/app/router.js @@ -4,7 +4,7 @@ const path = require('path'); const fs = require('fs'); const is = require('is-type-of'); const os = require('os'); -const mkdirp = require('mkdirp'); +const mkdirp = require('mz-modules/mkdirp'); module.exports = app => { // mock oss diff --git a/test/fixtures/apps/wrong-mode/config/config.default.js b/test/fixtures/apps/wrong-mode/config/config.default.js new file mode 100644 index 0000000..1798364 --- /dev/null +++ b/test/fixtures/apps/wrong-mode/config/config.default.js @@ -0,0 +1,7 @@ +'use strict'; + +exports.multipart = { + mode: 'foo', +}; + +exports.keys = 'multipart'; diff --git a/test/fixtures/apps/wrong-mode/package.json b/test/fixtures/apps/wrong-mode/package.json new file mode 100644 index 0000000..457ede3 --- /dev/null +++ b/test/fixtures/apps/wrong-mode/package.json @@ -0,0 +1,3 @@ +{ + "name": "multipart-wrong-mode-demo" +} diff --git a/test/multipart.test.js b/test/multipart.test.js index cde5e8c..b42998b 100644 --- a/test/multipart.test.js +++ b/test/multipart.test.js @@ -128,7 +128,7 @@ describe('test/multipart.test.js', () => { it('should not throw 400 when file not speicified', function* () { const form = formstream(); // 模拟用户未选择文件点击了上传,这时 cotroller 是有 file stream 的,因为指定了 MIME application/octet-stream - form.buffer('file', new Buffer(''), '', 'application/octet-stream'); + form.buffer('file', Buffer.from(''), '', 'application/octet-stream'); const headers = form.headers(); const res = yield urllib.request(host + '/upload.json', { method: 'POST', @@ -145,7 +145,7 @@ describe('test/multipart.test.js', () => { const form = formstream(); form.field('foo', 'bar'); // 模拟用户未选择文件点击了上传,这时 cotroller 是有 file stream 的,因为指定了 MIME application/octet-stream - // form.buffer('file', new Buffer(''), '', 'application/octet-stream'); + // form.buffer('file', Buffer.from(''), '', 'application/octet-stream'); const headers = form.headers(); const res = yield urllib.request(host + '/upload.json', { method: 'POST', @@ -473,7 +473,7 @@ describe('test/multipart.test.js', () => { it('should 400 when no file speicified', function* () { const form = formstream(); - form.buffer('file', new Buffer(''), '', 'application/octet-stream'); + form.buffer('file', Buffer.from(''), '', 'application/octet-stream'); const headers = form.headers(); const url = host + '/upload'; const res = yield urllib.request(url, { @@ -520,7 +520,7 @@ describe('test/multipart.test.js', () => { return app.ready(); }); before(function* () { - yield fs.writeFile(bigfile, new Buffer(1024 * 1024 * 2)); + yield fs.writeFile(bigfile, Buffer.alloc(1024 * 1024 * 2)); server = app.listen(); host = 'http://127.0.0.1:' + server.address().port; yield app.httpRequest() diff --git a/test/wrong-mode.test.js b/test/wrong-mode.test.js new file mode 100644 index 0000000..25493ba --- /dev/null +++ b/test/wrong-mode.test.js @@ -0,0 +1,19 @@ +'use strict'; + +const assert = require('assert'); +const mock = require('egg-mock'); + +describe('test/wrong-mode.test.js', () => { + it('should start fail', () => { + const app = mock.app({ + baseDir: 'apps/wrong-mode', + }); + return app.ready() + .then(() => { + throw new Error('should not run this'); + }, err => { + assert(err.name === 'TypeError'); + assert(err.message === 'Expect mode to be \'stream\' or \'file\', but got \'foo\''); + }); + }); +});