├── test ├── fixtures │ ├── 中文名.js │ ├── apps │ │ ├── ts │ │ │ ├── .gitignore │ │ │ ├── package.json │ │ │ ├── app │ │ │ │ ├── router.ts │ │ │ │ └── controller │ │ │ │ │ └── home.ts │ │ │ ├── typings │ │ │ │ └── index.d.ts │ │ │ ├── tsconfig.json │ │ │ └── config │ │ │ │ └── config.default.ts │ │ ├── upload-limit │ │ │ ├── package.json │ │ │ ├── config │ │ │ │ ├── config.unittest.js │ │ │ │ └── config.default.js │ │ │ └── app │ │ │ │ └── router.js │ │ ├── multipart │ │ │ ├── package.json │ │ │ ├── config │ │ │ │ ├── config.unittest.js │ │ │ │ └── config.default.js │ │ │ └── app │ │ │ │ ├── router.js │ │ │ │ ├── views │ │ │ │ └── home.html │ │ │ │ └── controller │ │ │ │ └── upload.js │ │ ├── upload-one-file │ │ │ ├── package.json │ │ │ ├── config │ │ │ │ ├── config.default.js │ │ │ │ └── config.unittest.js │ │ │ └── app │ │ │ │ ├── controller │ │ │ │ └── async.js │ │ │ │ └── router.js │ │ ├── limit-filesize-per-request │ │ │ ├── package.json │ │ │ ├── config │ │ │ │ ├── config.unittest.js │ │ │ │ └── config.default.js │ │ │ └── app │ │ │ │ └── router.js │ │ ├── dynamic-option │ │ │ ├── package.json │ │ │ ├── app │ │ │ │ ├── router.js │ │ │ │ ├── views │ │ │ │ │ └── home.html │ │ │ │ └── controller │ │ │ │ │ └── upload.js │ │ │ └── config │ │ │ │ ├── config.default.js │ │ │ │ └── config.unittest.js │ │ ├── file-mode │ │ │ ├── package.json │ │ │ ├── app │ │ │ │ ├── router.js │ │ │ │ ├── views │ │ │ │ │ └── home.html │ │ │ │ └── controller │ │ │ │ │ └── upload.js │ │ │ └── config │ │ │ │ ├── config.default.js │ │ │ │ └── config.unittest.js │ │ ├── fileModeMatch │ │ │ ├── package.json │ │ │ ├── config │ │ │ │ ├── config.unittest.js │ │ │ │ └── config.default.js │ │ │ └── app │ │ │ │ ├── controller │ │ │ │ ├── upload.js │ │ │ │ └── save.js │ │ │ │ ├── router.js │ │ │ │ └── views │ │ │ │ └── home.html │ │ ├── multipart-with-whitelist │ │ │ ├── package.json │ │ │ ├── config │ │ │ │ ├── config.unittest.js │ │ │ │ └── config.default.js │ │ │ └── app │ │ │ │ ├── router.js │ │ │ │ └── controller │ │ │ │ └── upload.js │ │ ├── whitelist-function │ │ │ ├── package.json │ │ │ ├── app │ │ │ │ ├── router.js │ │ │ │ └── controller │ │ │ │ │ └── upload.js │ │ │ └── config │ │ │ │ ├── config.unittest.js │ │ │ │ └── config.default.js │ │ ├── wrong-mode │ │ │ ├── package.json │ │ │ └── config │ │ │ │ └── config.default.js │ │ ├── fileModeMatch-glob │ │ │ ├── package.json │ │ │ ├── config │ │ │ │ ├── config.unittest.js │ │ │ │ └── config.default.js │ │ │ └── app │ │ │ │ ├── controller │ │ │ │ ├── upload.js │ │ │ │ └── save.js │ │ │ │ ├── router.js │ │ │ │ └── views │ │ │ │ └── home.html │ │ ├── multipart-for-await │ │ │ ├── package.json │ │ │ ├── app │ │ │ │ ├── router.js │ │ │ │ ├── views │ │ │ │ │ └── home.html │ │ │ │ └── controller │ │ │ │ │ └── upload.js │ │ │ └── config │ │ │ │ └── config.default.js │ │ ├── wrong-fileModeMatch │ │ │ ├── package.json │ │ │ └── config │ │ │ │ └── config.default.js │ │ ├── wrong-fileModeMatch-value │ │ │ ├── package.json │ │ │ └── config │ │ │ │ └── config.default.js │ │ └── fileModeMatch-glob-with-pathToRegexpModule │ │ │ ├── package.json │ │ │ ├── config │ │ │ ├── config.default.js │ │ │ └── config.unittest.js │ │ │ └── app │ │ │ ├── controller │ │ │ ├── upload.js │ │ │ └── save.js │ │ │ ├── router.js │ │ │ └── views │ │ │ └── home.html │ └── testfile.js ├── .setup.ts ├── wrong-mode.test.ts ├── dynamic-option.test.ts ├── ts.test.ts ├── file-mode-limit-filesize-per-request.test.ts ├── multipart-for-await.test.ts ├── enable-pathToRegexpModule.test.ts ├── stream-mode-with-filematch-glob.test.ts ├── stream-mode-with-filematch.test.ts ├── file-mode.test.ts └── multipart.test.ts ├── .eslintignore ├── src ├── index.ts ├── typings │ └── index.d.ts ├── lib │ ├── LimitError.ts │ ├── MultipartFileTooLargeError.ts │ └── utils.ts ├── app │ ├── middleware │ │ └── multipart.ts │ ├── schedule │ │ └── clean_tmpdir.ts │ └── extend │ │ └── context.ts ├── app.ts └── config │ └── config.default.ts ├── .eslintrc ├── tsconfig.json ├── .gitignore ├── .github └── workflows │ ├── release.yml │ └── nodejs.yml ├── LICENSE ├── package.json ├── CHANGELOG.md └── README.md /test/fixtures/中文名.js: -------------------------------------------------------------------------------- 1 | hello 2 | -------------------------------------------------------------------------------- /test/fixtures/apps/ts/.gitignore: -------------------------------------------------------------------------------- 1 | *.js -------------------------------------------------------------------------------- /test/fixtures/testfile.js: -------------------------------------------------------------------------------- 1 | this is a test file 2 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | test/fixtures 2 | coverage 3 | __snapshots__ 4 | -------------------------------------------------------------------------------- /test/fixtures/apps/upload-limit/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "oss" 3 | } 4 | -------------------------------------------------------------------------------- /test/fixtures/apps/multipart/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "multipart" 3 | } 4 | -------------------------------------------------------------------------------- /test/fixtures/apps/ts/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "multipart-typescript-demo" 3 | } -------------------------------------------------------------------------------- /test/fixtures/apps/upload-one-file/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "oss" 3 | } 4 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import './config/config.default.js'; 2 | import './app/extend/context.js'; 3 | -------------------------------------------------------------------------------- /test/fixtures/apps/limit-filesize-per-request/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "oss" 3 | } 4 | -------------------------------------------------------------------------------- /test/fixtures/apps/dynamic-option/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "dynamic-options-demo" 3 | } 4 | -------------------------------------------------------------------------------- /test/fixtures/apps/file-mode/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "multipart-file-mode-demo" 3 | } 4 | -------------------------------------------------------------------------------- /test/fixtures/apps/fileModeMatch/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "multipart-file-mode-demo" 3 | } 4 | -------------------------------------------------------------------------------- /test/fixtures/apps/multipart-with-whitelist/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "multipart" 3 | } 4 | -------------------------------------------------------------------------------- /test/fixtures/apps/whitelist-function/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "whitelist-function" 3 | } 4 | -------------------------------------------------------------------------------- /test/fixtures/apps/wrong-mode/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "multipart-wrong-mode-demo" 3 | } 4 | -------------------------------------------------------------------------------- /test/fixtures/apps/fileModeMatch-glob/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "multipart-file-mode-demo" 3 | } 4 | -------------------------------------------------------------------------------- /test/fixtures/apps/multipart-for-await/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "multipart-for-await" 3 | } 4 | -------------------------------------------------------------------------------- /test/fixtures/apps/wrong-fileModeMatch/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "multipart-wrong-mode-demo" 3 | } 4 | -------------------------------------------------------------------------------- /test/fixtures/apps/wrong-fileModeMatch-value/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "multipart-wrong-mode-demo" 3 | } 4 | -------------------------------------------------------------------------------- /test/fixtures/apps/upload-one-file/config/config.default.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | exports.keys = 'multipart'; 4 | -------------------------------------------------------------------------------- /test/fixtures/apps/whitelist-function/app/router.js: -------------------------------------------------------------------------------- 1 | module.exports = app => { 2 | app.post('/upload.json', 'upload'); 3 | }; 4 | -------------------------------------------------------------------------------- /test/.setup.ts: -------------------------------------------------------------------------------- 1 | import { whitelist } from '../src/lib/utils.js'; 2 | 3 | // add ts to whitelist for test 4 | whitelist.push('.ts'); 5 | -------------------------------------------------------------------------------- /test/fixtures/apps/multipart/config/config.unittest.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | exports.logger = { 4 | consoleLevel: 'NONE', 5 | }; 6 | -------------------------------------------------------------------------------- /test/fixtures/apps/upload-limit/config/config.unittest.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | exports.logger = { 4 | consoleLevel: 'NONE', 5 | }; 6 | -------------------------------------------------------------------------------- /test/fixtures/apps/upload-one-file/config/config.unittest.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | exports.logger = { 4 | consoleLevel: 'NONE', 5 | }; 6 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "eslint-config-egg/typescript", 4 | "eslint-config-egg/lib/rules/enforce-node-prefix" 5 | ] 6 | } 7 | -------------------------------------------------------------------------------- /test/fixtures/apps/fileModeMatch-glob-with-pathToRegexpModule/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "fileModeMatch-glob-with-pathToRegexpModule" 3 | } 4 | -------------------------------------------------------------------------------- /test/fixtures/apps/whitelist-function/config/config.unittest.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | exports.logger = { 4 | consoleLevel: 'NONE', 5 | }; 6 | -------------------------------------------------------------------------------- /test/fixtures/apps/file-mode/app/router.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | module.exports = app => { 4 | app.post('/upload', app.controller.upload); 5 | }; 6 | -------------------------------------------------------------------------------- /test/fixtures/apps/limit-filesize-per-request/config/config.unittest.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | exports.logger = { 4 | consoleLevel: 'NONE', 5 | }; 6 | -------------------------------------------------------------------------------- /test/fixtures/apps/multipart-with-whitelist/config/config.unittest.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | exports.logger = { 4 | consoleLevel: 'NONE', 5 | }; 6 | -------------------------------------------------------------------------------- /test/fixtures/apps/dynamic-option/app/router.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | module.exports = app => { 4 | app.post('/upload', app.controller.upload.index); 5 | }; 6 | -------------------------------------------------------------------------------- /test/fixtures/apps/multipart-with-whitelist/app/router.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | module.exports = app => { 4 | app.post('/upload.json', 'upload'); 5 | }; 6 | -------------------------------------------------------------------------------- /test/fixtures/apps/multipart-for-await/app/router.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | module.exports = app => { 4 | app.post('/upload', app.controller.upload.index); 5 | }; 6 | -------------------------------------------------------------------------------- /test/fixtures/apps/wrong-mode/config/config.default.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | exports.multipart = { 4 | mode: 'foo', 5 | }; 6 | 7 | exports.keys = 'multipart'; 8 | -------------------------------------------------------------------------------- /test/fixtures/apps/dynamic-option/config/config.default.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | exports.multipart = { 4 | mode: 'stream', 5 | }; 6 | 7 | exports.keys = 'multipart'; 8 | -------------------------------------------------------------------------------- /test/fixtures/apps/file-mode/config/config.default.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | exports.multipart = { 4 | mode: 'file', 5 | // fileSize: 10, 6 | }; 7 | 8 | exports.keys = 'multipart'; 9 | -------------------------------------------------------------------------------- /test/fixtures/apps/file-mode/config/config.unittest.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | exports.logger = { 4 | consoleLevel: 'NONE', 5 | coreLogger: { 6 | // consoleLevel: 'DEBUG', 7 | }, 8 | }; 9 | -------------------------------------------------------------------------------- /test/fixtures/apps/multipart/config/config.default.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | exports.multipart = { 4 | fileExtensions: ['.foo', '.BAR', 'abc', '' ], 5 | }; 6 | 7 | exports.keys = 'multipart'; 8 | -------------------------------------------------------------------------------- /test/fixtures/apps/dynamic-option/config/config.unittest.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | exports.logger = { 4 | consoleLevel: 'NONE', 5 | coreLogger: { 6 | // consoleLevel: 'DEBUG', 7 | }, 8 | }; 9 | -------------------------------------------------------------------------------- /test/fixtures/apps/fileModeMatch/config/config.unittest.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | exports.logger = { 4 | consoleLevel: 'NONE', 5 | coreLogger: { 6 | // consoleLevel: 'DEBUG', 7 | }, 8 | }; 9 | -------------------------------------------------------------------------------- /test/fixtures/apps/ts/app/router.ts: -------------------------------------------------------------------------------- 1 | import { Application } from 'egg'; 2 | 3 | export default (app: Application) => { 4 | const { controller } = app; 5 | app.post('/', controller.home.index); 6 | } -------------------------------------------------------------------------------- /test/fixtures/apps/upload-limit/config/config.default.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | exports.keys = 'multipart'; 4 | 5 | exports.multipart = { 6 | fileSize: '1mb', 7 | fileExtensions: null, 8 | }; 9 | -------------------------------------------------------------------------------- /test/fixtures/apps/fileModeMatch-glob/config/config.unittest.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | exports.logger = { 4 | consoleLevel: 'NONE', 5 | coreLogger: { 6 | // consoleLevel: 'DEBUG', 7 | }, 8 | }; 9 | -------------------------------------------------------------------------------- /test/fixtures/apps/multipart/app/router.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | module.exports = app => { 4 | app.post('/upload', app.controller.upload); 5 | app.post('/upload.json', app.controller.upload); 6 | }; 7 | -------------------------------------------------------------------------------- /test/fixtures/apps/fileModeMatch-glob/config/config.default.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | exports.multipart = { 4 | mode: 'stream', 5 | fileModeMatch: '/upload_file' 6 | }; 7 | 8 | exports.keys = 'multipart'; 9 | -------------------------------------------------------------------------------- /test/fixtures/apps/fileModeMatch/config/config.default.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | exports.multipart = { 4 | mode: 'stream', 5 | fileModeMatch: /^\/upload_file$/ 6 | }; 7 | 8 | exports.keys = 'multipart'; 9 | -------------------------------------------------------------------------------- /test/fixtures/apps/wrong-fileModeMatch/config/config.default.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | exports.multipart = { 4 | mode: 'file', 5 | fileModeMatch: /^\/upload$/, 6 | }; 7 | 8 | exports.keys = 'multipart'; 9 | -------------------------------------------------------------------------------- /test/fixtures/apps/fileModeMatch/app/controller/upload.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | module.exports = async ctx => { 4 | ctx.body = { 5 | body: ctx.request.body, 6 | files: ctx.request.files, 7 | }; 8 | }; 9 | -------------------------------------------------------------------------------- /test/fixtures/apps/ts/typings/index.d.ts: -------------------------------------------------------------------------------- 1 | import 'egg'; 2 | import HomeController from '../app/controller/home'; 3 | 4 | declare module 'egg' { 5 | interface IController { 6 | home: HomeController; 7 | } 8 | } -------------------------------------------------------------------------------- /test/fixtures/apps/wrong-fileModeMatch-value/config/config.default.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | exports.multipart = { 4 | mode: 'stream', 5 | fileModeMatch: 'foobar', 6 | }; 7 | 8 | exports.keys = 'multipart'; 9 | -------------------------------------------------------------------------------- /test/fixtures/apps/fileModeMatch-glob-with-pathToRegexpModule/config/config.default.js: -------------------------------------------------------------------------------- 1 | exports.multipart = { 2 | mode: 'stream', 3 | fileModeMatch: '/upload_file{/:paths}' 4 | }; 5 | 6 | exports.keys = 'multipart'; 7 | -------------------------------------------------------------------------------- /test/fixtures/apps/fileModeMatch-glob/app/controller/upload.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | module.exports = async ctx => { 4 | ctx.body = { 5 | body: ctx.request.body, 6 | files: ctx.request.files, 7 | }; 8 | }; 9 | -------------------------------------------------------------------------------- /test/fixtures/apps/ts/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@eggjs/tsconfig", 3 | "compilerOptions": { 4 | "baseUrl": "./", 5 | "paths": { 6 | "egg-multipart": ["../../../../"] 7 | } 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /test/fixtures/apps/fileModeMatch-glob-with-pathToRegexpModule/config/config.unittest.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | exports.logger = { 4 | consoleLevel: 'NONE', 5 | coreLogger: { 6 | // consoleLevel: 'DEBUG', 7 | }, 8 | }; 9 | -------------------------------------------------------------------------------- /test/fixtures/apps/multipart-with-whitelist/config/config.default.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | exports.multipart = { 4 | fileExtensions: [ '.foo' ], 5 | whitelist: [ '.whitelist' ], 6 | }; 7 | 8 | exports.keys = 'multipart'; 9 | -------------------------------------------------------------------------------- /test/fixtures/apps/limit-filesize-per-request/config/config.default.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | exports.keys = 'multipart'; 4 | 5 | exports.multipart = { 6 | fileSize: '1kb', 7 | fileModeMatch: /^\/non-exists-routers-/i, 8 | }; 9 | -------------------------------------------------------------------------------- /src/typings/index.d.ts: -------------------------------------------------------------------------------- 1 | // make sure to import egg typings and let typescript know about it 2 | // @see https://github.com/whxaxes/blog/issues/11 3 | // and https://www.typescriptlang.org/docs/handbook/declaration-merging.html 4 | import 'egg'; 5 | -------------------------------------------------------------------------------- /test/fixtures/apps/fileModeMatch-glob-with-pathToRegexpModule/app/controller/upload.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | module.exports = async ctx => { 4 | ctx.body = { 5 | body: ctx.request.body, 6 | files: ctx.request.files, 7 | }; 8 | }; 9 | -------------------------------------------------------------------------------- /test/fixtures/apps/multipart-for-await/config/config.default.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | exports.multipart = { 4 | fieldSize: 10, 5 | fieldNameSize: 10, 6 | fileSize: 1024 * 1024 * 2, 7 | }; 8 | 9 | exports.keys = 'multipart'; 10 | -------------------------------------------------------------------------------- /test/fixtures/apps/fileModeMatch/app/router.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | module.exports = app => { 4 | app.post('/upload', app.controller.upload); 5 | app.post('/upload_file', app.controller.upload); 6 | app.post('/save', app.controller.save); 7 | }; 8 | -------------------------------------------------------------------------------- /test/fixtures/apps/fileModeMatch/app/controller/save.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | module.exports = async ctx => { 4 | await ctx.saveRequestFiles(); 5 | ctx.body = { 6 | body: ctx.request.body, 7 | files: ctx.request.files, 8 | }; 9 | }; 10 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@eggjs/tsconfig", 3 | "compilerOptions": { 4 | "strict": true, 5 | "noImplicitAny": true, 6 | "target": "ES2022", 7 | "module": "NodeNext", 8 | "moduleResolution": "NodeNext" 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /test/fixtures/apps/fileModeMatch-glob/app/controller/save.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | module.exports = async ctx => { 4 | await ctx.saveRequestFiles(); 5 | ctx.body = { 6 | body: ctx.request.body, 7 | files: ctx.request.files, 8 | }; 9 | }; 10 | -------------------------------------------------------------------------------- /test/fixtures/apps/multipart/app/views/home.html: -------------------------------------------------------------------------------- 1 |
2 | title: 3 | file: 4 | 5 |
6 | -------------------------------------------------------------------------------- /test/fixtures/apps/fileModeMatch-glob-with-pathToRegexpModule/app/controller/save.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | module.exports = async ctx => { 4 | await ctx.saveRequestFiles(); 5 | ctx.body = { 6 | body: ctx.request.body, 7 | files: ctx.request.files, 8 | }; 9 | }; 10 | -------------------------------------------------------------------------------- /test/fixtures/apps/multipart-for-await/app/views/home.html: -------------------------------------------------------------------------------- 1 |
2 | title: 3 | file: 4 | 5 |
6 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | logs/ 2 | npm-debug.log 3 | node_modules/ 4 | coverage/ 5 | test/fixtures/**/run 6 | .idea/ 7 | .nyc_output/ 8 | .DS_Store 9 | *-lock.json 10 | *-lock.yaml 11 | test/fixtures/apps/ts/tsconfig.tsbuildinfo 12 | test/fixtures/apps/ts/**/*.d.ts 13 | .tshy* 14 | .eslintcache 15 | dist 16 | -------------------------------------------------------------------------------- /test/fixtures/apps/fileModeMatch-glob/app/router.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | module.exports = app => { 4 | app.post('/upload', app.controller.upload); 5 | app.post('/upload_file', app.controller.upload); 6 | app.post('/upload_file/foo', app.controller.upload); 7 | app.post('/save', app.controller.save); 8 | }; 9 | -------------------------------------------------------------------------------- /test/fixtures/apps/fileModeMatch-glob-with-pathToRegexpModule/app/router.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | module.exports = app => { 4 | app.post('/upload', app.controller.upload); 5 | app.post('/upload_file', app.controller.upload); 6 | app.post('/upload_file/foo', app.controller.upload); 7 | app.post('/save', app.controller.save); 8 | }; 9 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | push: 5 | branches: [ master ] 6 | 7 | jobs: 8 | release: 9 | name: Node.js 10 | uses: eggjs/github-actions/.github/workflows/node-release.yml@master 11 | secrets: 12 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 13 | GIT_TOKEN: ${{ secrets.GIT_TOKEN }} 14 | -------------------------------------------------------------------------------- /test/fixtures/apps/ts/app/controller/home.ts: -------------------------------------------------------------------------------- 1 | import { Controller } from 'egg'; 2 | 3 | class HomeController extends Controller { 4 | async index() { 5 | const { ctx } = this; 6 | ctx.body = { 7 | body: ctx.request.body, 8 | files: ctx.request.files, 9 | }; 10 | } 11 | } 12 | 13 | export default HomeController; -------------------------------------------------------------------------------- /src/lib/LimitError.ts: -------------------------------------------------------------------------------- 1 | export class LimitError extends Error { 2 | code: string; 3 | status: number; 4 | 5 | constructor(code: string, message: string) { 6 | super(message); 7 | this.code = code; 8 | this.status = 413; 9 | this.name = this.constructor.name; 10 | Error.captureStackTrace(this, this.constructor); 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /test/fixtures/apps/whitelist-function/config/config.default.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | exports.multipart = { 4 | fileExtensions: [ '.foo' ], 5 | whitelist(filename) { 6 | if (filename === 'bar') return true; 7 | if (filename === 'error') throw new Error('mock checkExt error'); 8 | return false; 9 | } 10 | }; 11 | 12 | exports.keys = 'multipart'; 13 | -------------------------------------------------------------------------------- /test/fixtures/apps/file-mode/app/views/home.html: -------------------------------------------------------------------------------- 1 |
2 | title: 3 | file1: 4 | file2: 5 | file3: 6 | other: 7 | 8 |
9 | -------------------------------------------------------------------------------- /test/fixtures/apps/dynamic-option/app/views/home.html: -------------------------------------------------------------------------------- 1 |
2 | title: 3 | file1: 4 | file2: 5 | file3: 6 | other: 7 | 8 |
9 | -------------------------------------------------------------------------------- /test/fixtures/apps/fileModeMatch/app/views/home.html: -------------------------------------------------------------------------------- 1 |
2 | title: 3 | file1: 4 | file2: 5 | file3: 6 | other: 7 | 8 |
9 | -------------------------------------------------------------------------------- /test/fixtures/apps/fileModeMatch-glob/app/views/home.html: -------------------------------------------------------------------------------- 1 |
2 | title: 3 | file1: 4 | file2: 5 | file3: 6 | other: 7 | 8 |
9 | -------------------------------------------------------------------------------- /test/fixtures/apps/fileModeMatch-glob-with-pathToRegexpModule/app/views/home.html: -------------------------------------------------------------------------------- 1 |
2 | title: 3 | file1: 4 | file2: 5 | file3: 6 | other: 7 | 8 |
9 | -------------------------------------------------------------------------------- /.github/workflows/nodejs.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: [ master ] 6 | pull_request: 7 | branches: [ master ] 8 | 9 | jobs: 10 | Job: 11 | name: Node.js 12 | uses: node-modules/github-actions/.github/workflows/node-test.yml@master 13 | with: 14 | os: 'ubuntu-latest, macos-latest, windows-latest' 15 | version: '18, 20, 22' 16 | secrets: 17 | CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} 18 | -------------------------------------------------------------------------------- /test/fixtures/apps/dynamic-option/app/controller/upload.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | module.exports = (app) => { 4 | return class extends app.Controller { 5 | async index(ctx) { 6 | try { 7 | const file = await ctx.saveRequestFiles({ 8 | limits: { 9 | fileSize: 10000 10 | } 11 | }); 12 | ctx.body = file; 13 | } finally { 14 | await ctx.cleanupRequestFiles(); 15 | } 16 | }; 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /test/fixtures/apps/file-mode/app/controller/upload.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | module.exports = async ctx => { 4 | ctx.body = { 5 | body: ctx.request.body, 6 | files: ctx.request.files, 7 | }; 8 | 9 | if (ctx.query.cleanup === 'true') { 10 | await ctx.cleanupRequestFiles(); 11 | } 12 | if (ctx.query.async_cleanup === 'true') { 13 | ctx.cleanupRequestFiles(); 14 | } 15 | 16 | if (ctx.query.call_multipart_twice) { 17 | ctx.multipart(); 18 | } 19 | }; 20 | -------------------------------------------------------------------------------- /src/lib/MultipartFileTooLargeError.ts: -------------------------------------------------------------------------------- 1 | export class MultipartFileTooLargeError extends Error { 2 | status: number; 3 | fields: Record; 4 | filename: string; 5 | 6 | constructor(message: string, fields: Record, filename: string) { 7 | super(message); 8 | this.name = this.constructor.name; 9 | this.status = 413; 10 | this.fields = fields; 11 | this.filename = filename; 12 | Error.captureStackTrace(this, this.constructor); 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /test/fixtures/apps/limit-filesize-per-request/app/router.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | module.exports = app => { 4 | app.post('/upload-limit-1mb', async ctx => { 5 | await ctx.saveRequestFiles({ limits: { fileSize: '1mb' } }); 6 | ctx.body = { 7 | body: ctx.request.body, 8 | files: ctx.request.files, 9 | }; 10 | }); 11 | 12 | app.post('/upload-limit-2mb', async ctx => { 13 | await ctx.saveRequestFiles({ limits: { fileSize: '2mb' } }); 14 | ctx.body = { 15 | body: ctx.request.body, 16 | files: ctx.request.files, 17 | }; 18 | }); 19 | }; 20 | -------------------------------------------------------------------------------- /test/fixtures/apps/multipart-with-whitelist/app/controller/upload.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const path = require('path'); 4 | const fs = require('fs'); 5 | 6 | module.exports = async ctx => { 7 | const parts = ctx.multipart(); 8 | let part; 9 | while ((part = await parts()) != null) { 10 | if (Array.isArray(part)) { 11 | continue; 12 | } else { 13 | break; 14 | } 15 | } 16 | 17 | const ws = fs.createWriteStream(path.join(ctx.app.config.logger.dir, 'multipart-test-file')); 18 | part.pipe(ws); 19 | ctx.body = { 20 | filename: part.filename, 21 | }; 22 | }; 23 | -------------------------------------------------------------------------------- /test/fixtures/apps/whitelist-function/app/controller/upload.js: -------------------------------------------------------------------------------- 1 | const path = require('node:path'); 2 | const fs = require('node:fs'); 3 | 4 | // keep one generator function test case 5 | module.exports = async function() { 6 | const parts = this.multipart(); 7 | let part; 8 | while ((part = await parts()) != null) { 9 | if (Array.isArray(part)) { 10 | continue; 11 | } else { 12 | break; 13 | } 14 | } 15 | 16 | const ws = fs.createWriteStream(path.join(this.app.config.logger.dir, 'multipart-test-file')); 17 | part.pipe(ws); 18 | this.body = { 19 | filename: part.filename, 20 | }; 21 | }; 22 | -------------------------------------------------------------------------------- /src/app/middleware/multipart.ts: -------------------------------------------------------------------------------- 1 | import { pathMatching } from 'egg-path-matching'; 2 | import type { Context, Next, EggCore } from '@eggjs/core'; 3 | import type { MultipartConfig } from '../../config/config.default.js'; 4 | 5 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 6 | export default (options: MultipartConfig, _app: EggCore) => { 7 | // normalize options 8 | const matchFn = options.fileModeMatch && pathMatching({ 9 | match: options.fileModeMatch, 10 | // pathToRegexpModule: app.options.pathToRegexpModule, 11 | }); 12 | 13 | return async function multipart(ctx: Context, next: Next) { 14 | if (!ctx.is('multipart')) return next(); 15 | if (matchFn && !matchFn(ctx)) return next(); 16 | 17 | await ctx.saveRequestFiles(); 18 | return next(); 19 | }; 20 | }; 21 | -------------------------------------------------------------------------------- /test/fixtures/apps/ts/config/config.default.ts: -------------------------------------------------------------------------------- 1 | import { EggAppInfo, EggAppConfig } from 'egg'; 2 | 3 | /** 4 | * Powerful Partial, Support adding ? modifier to a mapped property in deep level 5 | * @example 6 | * import { PowerPartial, EggAppConfig } from 'egg'; 7 | * 8 | * // { view: { defaultEngines: string } } => { view?: { defaultEngines?: string } } 9 | * type EggConfig = PowerPartial 10 | */ 11 | export type PowerPartial = { 12 | [U in keyof T]?: T[U] extends object 13 | ? PowerPartial 14 | : T[U]; 15 | }; 16 | 17 | export default (appInfo: EggAppInfo) => { 18 | const config = {} as PowerPartial; 19 | 20 | config.keys = 'multipart-ts-test'; 21 | 22 | config.appInfo = appInfo; 23 | 24 | config.multipart = { 25 | mode: 'file', 26 | }; 27 | 28 | return config; 29 | } 30 | -------------------------------------------------------------------------------- /src/app.ts: -------------------------------------------------------------------------------- 1 | import type { EggCore, ILifecycleBoot } from '@eggjs/core'; 2 | import { normalizeOptions } from './lib/utils.js'; 3 | 4 | export default class AppBootHook implements ILifecycleBoot { 5 | constructor(private app: EggCore) {} 6 | 7 | configWillLoad() { 8 | this.app.config.multipart = normalizeOptions(this.app.config.multipart); 9 | const options = this.app.config.multipart; 10 | 11 | this.app.coreLogger.info('[@eggjs/multipart] %s mode enable', options.mode); 12 | if (options.mode === 'file' || options.fileModeMatch) { 13 | this.app.coreLogger.info('[@eggjs/multipart] will save temporary files to %j, cleanup job cron: %j', 14 | options.tmpdir, options.cleanSchedule.cron); 15 | // enable multipart middleware 16 | this.app.config.coreMiddleware.push('multipart'); 17 | } 18 | } 19 | } 20 | 21 | -------------------------------------------------------------------------------- /test/wrong-mode.test.ts: -------------------------------------------------------------------------------- 1 | import { strict as assert } from 'node:assert'; 2 | import { mm, MockApplication } from '@eggjs/mock'; 3 | 4 | describe('test/wrong-mode.test.ts', () => { 5 | let app: MockApplication; 6 | afterEach(async () => { 7 | await app.close(); 8 | }); 9 | 10 | it('should start fail when mode=foo', async () => { 11 | app = mm.app({ 12 | baseDir: 'apps/wrong-mode', 13 | }); 14 | await assert.rejects(async () => { 15 | await app.ready(); 16 | }, /Expect mode to be 'stream' or 'file', but got 'foo'/); 17 | }); 18 | 19 | it('should start fail when using options.fileModeMatch on file mode', async () => { 20 | app = mm.app({ 21 | baseDir: 'apps/wrong-fileModeMatch', 22 | }); 23 | await assert.rejects(async () => { 24 | await app.ready(); 25 | }, /`fileModeMatch` options only work on stream mode, please remove it/); 26 | }); 27 | }); 28 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017-present Alibaba Group Holding Limited and other contributors. 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /test/dynamic-option.test.ts: -------------------------------------------------------------------------------- 1 | import { strict as assert } from 'node:assert'; 2 | import fs from 'node:fs/promises'; 3 | import formstream from 'formstream'; 4 | import urllib from 'urllib'; 5 | import { mm, MockApplication } from '@eggjs/mock'; 6 | 7 | describe('test/dynamic-option.test.ts', () => { 8 | let app: MockApplication; 9 | let server: any; 10 | let host: string; 11 | before(() => { 12 | app = mm.app({ 13 | baseDir: 'apps/dynamic-option', 14 | }); 15 | return app.ready(); 16 | }); 17 | before(() => { 18 | server = app.listen(); 19 | host = 'http://127.0.0.1:' + server.address().port; 20 | }); 21 | after(() => { 22 | return fs.rm(app.config.multipart.tmpdir, { force: true, recursive: true }); 23 | }); 24 | after(() => app.close()); 25 | after(() => server.close()); 26 | beforeEach(() => app.mockCsrf()); 27 | afterEach(() => mm.restore()); 28 | 29 | it('should work with saveRequestFiles options', async () => { 30 | const form = formstream(); 31 | form.buffer('file', Buffer.alloc(1 * 1024 * 1024), '1mb.js', 'application/octet-stream'); 32 | 33 | const headers = form.headers(); 34 | const res = await urllib.request(host + '/upload', { 35 | method: 'POST', 36 | headers, 37 | stream: form as any, 38 | // dataType: 'json', 39 | }); 40 | 41 | assert.equal(res.status, 413); 42 | assert.match(res.data.toString(), /Error: Reach fileSize limit/); 43 | }); 44 | }); 45 | -------------------------------------------------------------------------------- /test/fixtures/apps/multipart/app/controller/upload.js: -------------------------------------------------------------------------------- 1 | const path = require('node:path'); 2 | const fs = require('node:fs'); 3 | const { sendToWormhole } = require('stream-wormhole'); 4 | 5 | module.exports = async ctx => { 6 | const parts = ctx.multipart(); 7 | let part; 8 | while ((part = await parts()) != null) { 9 | if (Array.isArray(part)) { 10 | continue; 11 | } else { 12 | break; 13 | } 14 | } 15 | 16 | if (!part || !part.filename) { 17 | ctx.body = { 18 | message: 'no file', 19 | }; 20 | return; 21 | } 22 | 23 | if (ctx.query.mock_stream_error) { 24 | // mock save stream error 25 | const filepath = path.join(ctx.app.config.logger.dir, 'not-exists-dir/dir2/testfile'); 26 | try { 27 | await saveStream(part, filepath); 28 | } catch (err) { 29 | await sendToWormhole(part); 30 | throw err; 31 | } 32 | 33 | ctx.body = { 34 | filename: part.filename, 35 | }; 36 | } 37 | 38 | if (ctx.query.mock_undefined_error) { 39 | part.foo(); 40 | } 41 | 42 | const filepath = path.join(ctx.app.config.logger.dir, 'multipart-test-file'); 43 | await saveStream(part, filepath); 44 | ctx.body = { 45 | filename: part.filename, 46 | }; 47 | }; 48 | 49 | function saveStream(stream, filepath) { 50 | return new Promise((resolve, reject) => { 51 | const ws = fs.createWriteStream(filepath); 52 | stream.pipe(ws); 53 | ws.on('error', reject); 54 | ws.on('finish', resolve); 55 | }); 56 | } 57 | -------------------------------------------------------------------------------- /test/fixtures/apps/multipart-for-await/app/controller/upload.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const path = require('path'); 4 | const { Controller } = require('egg'); 5 | const stream = require('stream'); 6 | const util = require('util'); 7 | const pipeline = util.promisify(stream.pipeline); 8 | 9 | module.exports = class UploadController extends Controller { 10 | async index() { 11 | const shouldMockError = !!this.ctx.query.mock_error; 12 | const fileSize = parseInt(this.ctx.query.fileSize) || this.app.config.multipart.fileSize; 13 | 14 | const parts = this.ctx.multipart({ limits: { fileSize } }); 15 | const fields = {}; 16 | const files = {}; 17 | 18 | for await (const part of parts) { 19 | if (Array.isArray(part)) { 20 | const [ name, value ] = part; 21 | fields[name] = value; 22 | } else { 23 | const { filename, fieldname } = part; 24 | 25 | let content = ''; 26 | await pipeline(part, 27 | new stream.Writable({ 28 | write(chunk, encoding, callback) { 29 | content += chunk.toString(); 30 | // console.log('@@', part.filename, part.truncated); 31 | if (shouldMockError) { 32 | return callback(new Error('mock error')); 33 | } 34 | setImmediate(callback); 35 | }, 36 | }), 37 | ); 38 | files[fieldname] = { 39 | fileName: filename, 40 | content, 41 | } 42 | } 43 | } 44 | 45 | this.ctx.body = { fields, files }; 46 | } 47 | }; 48 | -------------------------------------------------------------------------------- /test/fixtures/apps/upload-one-file/app/controller/async.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const path = require('path'); 4 | const { Controller } = require('egg'); 5 | 6 | module.exports = class UploadController extends Controller { 7 | async async() { 8 | const ctx = this.ctx; 9 | const options = {}; 10 | if (ctx.query.fileSize) { 11 | options.limits = { fileSize: parseInt(ctx.query.fileSize) }; 12 | } 13 | const stream = await ctx.getFileStream(options); 14 | if (ctx.query.foo === 'error') { 15 | // mock undefined error 16 | stream.foo(); 17 | } 18 | const name = 'egg-multipart-test/' + process.version + '-' + Date.now() + '-' + path.basename(stream.filename); 19 | const result = await ctx.oss.put(name, stream); 20 | ctx.body = { 21 | name: result.name, 22 | url: result.url, 23 | status: result.res.status, 24 | fields: stream.fields, 25 | }; 26 | } 27 | 28 | async allowEmpty() { 29 | const ctx = this.ctx; 30 | const stream = await ctx.getFileStream({ requireFile: false }); 31 | if (stream.filename) { 32 | const name = 'egg-multipart-test/' + process.version + '-' + Date.now() + '-' + path.basename(stream.filename); 33 | const result = await ctx.oss.put(name, stream); 34 | ctx.body = { 35 | name: result.name, 36 | url: result.url, 37 | status: result.res.status, 38 | fields: stream.fields, 39 | }; 40 | return; 41 | } 42 | 43 | stream.resume(); 44 | ctx.body = { 45 | fields: stream.fields, 46 | }; 47 | } 48 | }; 49 | -------------------------------------------------------------------------------- /test/fixtures/apps/upload-limit/app/router.js: -------------------------------------------------------------------------------- 1 | const path = require('node:path'); 2 | const fs = require('node:fs/promises'); 3 | const { createWriteStream } = require('node:fs'); 4 | const os = require('node:os'); 5 | 6 | module.exports = app => { 7 | // mock oss 8 | app.context.oss = { 9 | async put(name, stream) { 10 | const storefile = path.join(os.tmpdir(), name); 11 | await fs.mkdir(path.dirname(storefile), { recursive: true }); 12 | 13 | return new Promise((resolve, reject) => { 14 | const writeStream = createWriteStream(storefile); 15 | stream.pipe(writeStream); 16 | 17 | if (!name.includes('not-handle-error-event')) { 18 | stream.on('error', err => { 19 | console.log('read stream error: %s', err); 20 | reject(err); 21 | }); 22 | } 23 | 24 | writeStream.on('error', err => { 25 | console.log('write stream error: %s', err); 26 | reject(err); 27 | }); 28 | writeStream.on('close', () => { 29 | resolve({ 30 | name, 31 | url: 'http://mockoss.com/' + name, 32 | res: { 33 | status: 200, 34 | }, 35 | }); 36 | }); 37 | }); 38 | }, 39 | }; 40 | 41 | app.get('/upload', async ctx => { 42 | ctx.set('x-csrf', ctx.csrf); 43 | ctx.body = 'hi'; 44 | }); 45 | 46 | app.post('/upload', async ctx => { 47 | const stream = await ctx.getFileStream(); 48 | const name = 'egg-multipart-test/' + process.version + '-' + Date.now() + '-' + path.basename(stream.filename); 49 | const result = await ctx.oss.put(name, stream); 50 | if (name.includes('not-handle-error-event-and-mock-stream-error')) { 51 | // process.nextTick(() => stream.emit('error', new Error('mock stream unhandle error'))); 52 | } 53 | ctx.body = { 54 | name: result.name, 55 | url: result.url, 56 | status: result.res.status, 57 | fields: stream.fields, 58 | }; 59 | }); 60 | }; 61 | -------------------------------------------------------------------------------- /test/fixtures/apps/upload-one-file/app/router.js: -------------------------------------------------------------------------------- 1 | const path = require('node:path'); 2 | const fs = require('node:fs/promises'); 3 | const is = require('is-type-of'); 4 | 5 | async function readableToBytes(stream) { 6 | const chunks = []; 7 | let chunk; 8 | let totalLength = 0; 9 | for await (chunk of stream) { 10 | chunks.push(chunk); 11 | totalLength += chunk.length; 12 | } 13 | return Buffer.concat(chunks, totalLength); 14 | } 15 | 16 | module.exports = app => { 17 | // mock oss 18 | app.context.oss = { 19 | async put(name, stream) { 20 | const bytes = await readableToBytes(stream); 21 | return { 22 | name, 23 | url: 'http://mockoss.com/' + name, 24 | size: bytes.length, 25 | res: { 26 | status: 200, 27 | }, 28 | }; 29 | }, 30 | }; 31 | 32 | app.get('/', async ctx => { 33 | ctx.body = { 34 | app: is.object(ctx.app.oss), 35 | ctx: is.object(ctx.oss), 36 | putBucket: is.generatorFunction(ctx.oss.putBucket), 37 | }; 38 | }); 39 | 40 | app.get('/uploadtest', async ctx => { 41 | const name = 'egg-oss-test-upload-' + process.version + '-' + Date.now(); 42 | ctx.body = await ctx.oss.put(name, fs.createReadStream(__filename)); 43 | }); 44 | 45 | app.get('/upload', async ctx => { 46 | ctx.set('x-csrf', ctx.csrf); 47 | ctx.body = 'hi'; 48 | // await ctx.render('upload.html'); 49 | }); 50 | 51 | app.post('/upload', async ctx => { 52 | const stream = await ctx.getFileStream(); 53 | const name = 'egg-multipart-test/' + process.version + '-' + Date.now() + '-' + path.basename(stream.filename); 54 | // 文件处理,上传到云存储等等 55 | const result = await ctx.oss.put(name, stream); 56 | ctx.body = { 57 | name: result.name, 58 | url: result.url, 59 | status: result.res.status, 60 | fields: stream.fields, 61 | }; 62 | }); 63 | 64 | app.post('/upload2', async ctx => { 65 | await ctx.getFileStream({ limits: { fileSize: '1kb' } }); 66 | ctx.body = ctx.request.body; 67 | }) 68 | 69 | app.post('/upload/async', 'async.async'); 70 | 71 | app.post('/upload/allowEmpty', 'async.allowEmpty'); 72 | }; 73 | -------------------------------------------------------------------------------- /src/app/schedule/clean_tmpdir.ts: -------------------------------------------------------------------------------- 1 | import path from 'node:path'; 2 | import fs from 'node:fs/promises'; 3 | import dayjs from 'dayjs'; 4 | import { EggCore } from '@eggjs/core'; 5 | 6 | export default (app: EggCore): any => { 7 | return class CleanTmpdir extends app.Subscription { 8 | static get schedule() { 9 | return { 10 | type: 'worker', 11 | cron: app.config.multipart.cleanSchedule.cron, 12 | disable: app.config.multipart.cleanSchedule.disable, 13 | immediate: false, 14 | }; 15 | } 16 | 17 | async _remove(dir: string) { 18 | const { ctx } = this; 19 | if (await fs.access(dir).then(() => true, () => false)) { 20 | ctx.coreLogger.info('[@eggjs/multipart:CleanTmpdir] removing tmpdir: %j', dir); 21 | try { 22 | await fs.rm(dir, { force: true, recursive: true }); 23 | ctx.coreLogger.info('[@eggjs/multipart:CleanTmpdir:success] tmpdir: %j has been removed', dir); 24 | } catch (err) { 25 | /* c8 ignore next 3 */ 26 | ctx.coreLogger.error('[@eggjs/multipart:CleanTmpdir:error] remove tmpdir: %j error: %s', dir, err); 27 | ctx.coreLogger.error(err); 28 | } 29 | } 30 | } 31 | 32 | async subscribe() { 33 | const { ctx } = this; 34 | const config = ctx.app.config; 35 | ctx.coreLogger.info('[@eggjs/multipart:CleanTmpdir] start clean tmpdir: %j', config.multipart.tmpdir); 36 | // last year 37 | const lastYear = dayjs().subtract(1, 'years'); 38 | const lastYearDir = path.join(config.multipart.tmpdir, lastYear.format('YYYY')); 39 | await this._remove(lastYearDir); 40 | // 3 months 41 | for (let i = 1; i <= 3; i++) { 42 | const date = dayjs().subtract(i, 'months'); 43 | const dir = path.join(config.multipart.tmpdir, date.format('YYYY/MM')); 44 | await this._remove(dir); 45 | } 46 | // 7 days 47 | for (let i = 1; i <= 7; i++) { 48 | const date = dayjs().subtract(i, 'days'); 49 | const dir = path.join(config.multipart.tmpdir, date.format('YYYY/MM/DD')); 50 | await this._remove(dir); 51 | } 52 | ctx.coreLogger.info('[@eggjs/multipart:CleanTmpdir] end'); 53 | } 54 | }; 55 | }; 56 | -------------------------------------------------------------------------------- /test/ts.test.ts: -------------------------------------------------------------------------------- 1 | import assert from 'node:assert'; 2 | import path from 'node:path'; 3 | import { fileURLToPath } from 'node:url'; 4 | import fs from 'node:fs/promises'; 5 | import { mm, MockApplication } from '@eggjs/mock'; 6 | import formstream from 'formstream'; 7 | import urllib from 'urllib'; 8 | 9 | const __filename = fileURLToPath(import.meta.url); 10 | const __dirname = path.dirname(__filename); 11 | 12 | describe('test/ts.test.ts', () => { 13 | let app: MockApplication; 14 | let server: any; 15 | let host: string; 16 | before(() => { 17 | app = mm.app({ 18 | baseDir: 'apps/ts', 19 | }); 20 | return app.ready(); 21 | }); 22 | before(() => { 23 | server = app.listen(); 24 | host = 'http://127.0.0.1:' + server.address().port; 25 | }); 26 | after(() => { 27 | return fs.rm(app.config.multipart.tmpdir, { force: true, recursive: true }); 28 | }); 29 | after(() => app.close()); 30 | after(() => server.close()); 31 | beforeEach(() => app.mockCsrf()); 32 | afterEach(mm.restore); 33 | 34 | it('ts should run without err', async () => { 35 | const form = formstream(); 36 | form.field('foo', 'bar').field('luckyscript', 'egg'); 37 | form.file('file1', __filename, 'foooooooo.js'); 38 | form.file('file2', __filename); 39 | // will ignore empty file 40 | form.buffer('file3', Buffer.from(''), '', 'application/octet-stream'); 41 | form.file('bigfile', path.join(__dirname, 'fixtures', 'bigfile.js')); 42 | // other form fields 43 | form.field('work', 'with Node.js'); 44 | 45 | const headers = form.headers(); 46 | const res = await urllib.request(host + '/', { 47 | method: 'POST', 48 | headers, 49 | stream: form as any, 50 | }); 51 | 52 | assert(res.status === 200); 53 | const data = JSON.parse(res.data); 54 | assert.deepStrictEqual(data.body, { foo: 'bar', luckyscript: 'egg', work: 'with Node.js' }); 55 | assert.equal(data.files.length, 3); 56 | assert.equal(data.files[0].field, 'file1'); 57 | assert.equal(data.files[0].filename, 'foooooooo.js'); 58 | assert.equal(data.files[0].encoding, '7bit'); 59 | assert.equal(data.files[0].mime, 'application/javascript'); 60 | assert.ok(data.files[0].filepath.startsWith(app.config.multipart.tmpdir)); 61 | 62 | assert.equal(data.files[1].field, 'file2'); 63 | assert.equal(data.files[1].filename, 'ts.test.ts'); 64 | assert.equal(data.files[1].encoding, '7bit'); 65 | assert.equal(data.files[1].mime, 'video/mp2t'); 66 | assert.ok(data.files[1].filepath.startsWith(app.config.multipart.tmpdir)); 67 | 68 | assert.equal(data.files[2].field, 'bigfile'); 69 | assert.equal(data.files[2].filename, 'bigfile.js'); 70 | assert.equal(data.files[2].encoding, '7bit'); 71 | assert.equal(data.files[2].mime, 'application/javascript'); 72 | assert.ok(data.files[2].filepath.startsWith(app.config.multipart.tmpdir)); 73 | }); 74 | }); 75 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@eggjs/multipart", 3 | "version": "4.0.0", 4 | "publishConfig": { 5 | "access": "public" 6 | }, 7 | "eggPlugin": { 8 | "name": "multipart", 9 | "optionalDependencies": [ 10 | "schedule" 11 | ], 12 | "exports": { 13 | "import": "./dist/esm", 14 | "require": "./dist/commonjs", 15 | "typescript": "./src" 16 | } 17 | }, 18 | "description": "multipart plugin for egg", 19 | "repository": { 20 | "type": "git", 21 | "url": "git+https://github.com/eggjs/multipart.git" 22 | }, 23 | "keywords": [ 24 | "egg", 25 | "egg-plugin", 26 | "eggPlugin", 27 | "multipart" 28 | ], 29 | "author": "gxcsoccer ", 30 | "license": "MIT", 31 | "bugs": { 32 | "url": "https://github.com/eggjs/egg/issues" 33 | }, 34 | "homepage": "https://github.com/eggjs/multipart#readme", 35 | "engines": { 36 | "node": ">= 18.19.0" 37 | }, 38 | "dependencies": { 39 | "@eggjs/core": "^6.3.1", 40 | "bytes": "^3.1.2", 41 | "co-busboy": "^2.0.1", 42 | "dayjs": "^1.11.5", 43 | "egg-path-matching": "^2.1.0" 44 | }, 45 | "devDependencies": { 46 | "@arethetypeswrong/cli": "^0.17.1", 47 | "@eggjs/bin": "7", 48 | "@eggjs/mock": "^6.0.5", 49 | "@eggjs/tsconfig": "1", 50 | "@types/bytes": "^3.1.5", 51 | "@types/mocha": "10", 52 | "@types/node": "22", 53 | "egg": "^4.0.6", 54 | "eslint": "8", 55 | "eslint-config-egg": "14", 56 | "formstream": "^1.5.1", 57 | "is-type-of": "2", 58 | "path-to-regexp-v8": "npm:path-to-regexp@8", 59 | "rimraf": "6", 60 | "stream-wormhole": "^2.0.1", 61 | "tshy": "3", 62 | "tshy-after": "1", 63 | "typescript": "5", 64 | "urllib": "4" 65 | }, 66 | "scripts": { 67 | "lint": "eslint --cache src test --ext .ts", 68 | "pretest": "npm run clean && npm run lint -- --fix", 69 | "test": "egg-bin test", 70 | "preci": "npm run clean && npm run lint", 71 | "ci": "egg-bin cov", 72 | "postci": "npm run prepublishOnly && npm run clean", 73 | "clean": "rimraf dist", 74 | "prepublishOnly": "tshy && tshy-after && attw --pack" 75 | }, 76 | "type": "module", 77 | "tshy": { 78 | "exports": { 79 | ".": "./src/index.ts", 80 | "./package.json": "./package.json" 81 | } 82 | }, 83 | "exports": { 84 | ".": { 85 | "import": { 86 | "types": "./dist/esm/index.d.ts", 87 | "default": "./dist/esm/index.js" 88 | }, 89 | "require": { 90 | "types": "./dist/commonjs/index.d.ts", 91 | "default": "./dist/commonjs/index.js" 92 | } 93 | }, 94 | "./package.json": "./package.json" 95 | }, 96 | "files": [ 97 | "dist", 98 | "src" 99 | ], 100 | "types": "./dist/commonjs/index.d.ts", 101 | "main": "./dist/commonjs/index.js", 102 | "module": "./dist/esm/index.js" 103 | } 104 | -------------------------------------------------------------------------------- /src/lib/utils.ts: -------------------------------------------------------------------------------- 1 | import path from 'node:path'; 2 | import assert from 'node:assert'; 3 | import bytes from 'bytes'; 4 | import { MultipartConfig } from '../config/config.default.js'; 5 | 6 | export const whitelist = [ 7 | // images 8 | '.jpg', '.jpeg', // image/jpeg 9 | '.png', // image/png, image/x-png 10 | '.gif', // image/gif 11 | '.bmp', // image/bmp 12 | '.wbmp', // image/vnd.wap.wbmp 13 | '.webp', 14 | '.tif', 15 | '.psd', 16 | // text 17 | '.svg', 18 | '.js', '.jsx', 19 | '.json', 20 | '.css', '.less', 21 | '.html', '.htm', 22 | '.xml', 23 | // tar 24 | '.zip', 25 | '.gz', '.tgz', '.gzip', 26 | // video 27 | '.mp3', 28 | '.mp4', 29 | '.avi', 30 | ]; 31 | 32 | export function humanizeBytes(size: number | string) { 33 | if (typeof size === 'number') { 34 | return size; 35 | } 36 | return bytes(size) as number; 37 | } 38 | 39 | export function normalizeOptions(options: MultipartConfig) { 40 | // make sure to cast the value of config **Size to number 41 | options.fileSize = humanizeBytes(options.fileSize); 42 | options.fieldSize = humanizeBytes(options.fieldSize); 43 | options.fieldNameSize = humanizeBytes(options.fieldNameSize); 44 | 45 | // validate mode 46 | options.mode = options.mode || 'stream'; 47 | assert([ 'stream', 'file' ].includes(options.mode), `Expect mode to be 'stream' or 'file', but got '${options.mode}'`); 48 | if (options.mode === 'file') { 49 | assert(!options.fileModeMatch, '`fileModeMatch` options only work on stream mode, please remove it'); 50 | } 51 | 52 | // normalize whitelist 53 | if (Array.isArray(options.whitelist)) { 54 | options.whitelist = options.whitelist.map(extname => extname.toLowerCase()); 55 | } 56 | 57 | // normalize fileExtensions 58 | if (Array.isArray(options.fileExtensions)) { 59 | options.fileExtensions = options.fileExtensions.map(extname => { 60 | return (extname.startsWith('.') || extname === '') ? extname.toLowerCase() : `.${extname.toLowerCase()}`; 61 | }); 62 | } 63 | 64 | function checkExt(fileName: string) { 65 | if (typeof options.whitelist === 'function') { 66 | return options.whitelist(fileName); 67 | } 68 | const extname = path.extname(fileName).toLowerCase(); 69 | if (Array.isArray(options.whitelist)) { 70 | return options.whitelist.includes(extname); 71 | } 72 | // only if user don't provide whitelist, we will use default whitelist + fileExtensions 73 | return whitelist.includes(extname) || options.fileExtensions.includes(extname); 74 | } 75 | 76 | options.checkFile = (_fieldName: string, fileStream: any, fileName: string): void | Error => { 77 | // just ignore, if no file 78 | if (!fileStream || !fileName) return; 79 | try { 80 | if (!checkExt(fileName)) { 81 | const err = new Error('Invalid filename: ' + fileName); 82 | Reflect.set(err, 'status', 400); 83 | return err; 84 | } 85 | } catch (err: any) { 86 | err.status = 400; 87 | return err; 88 | } 89 | }; 90 | 91 | return options; 92 | } 93 | -------------------------------------------------------------------------------- /src/config/config.default.ts: -------------------------------------------------------------------------------- 1 | import os from 'node:os'; 2 | import path from 'node:path'; 3 | import type { Context, EggAppInfo } from '@eggjs/core'; 4 | import type { PathMatchingPattern } from 'egg-path-matching'; 5 | 6 | export type MatchItem = string | RegExp | ((ctx: Context) => boolean); 7 | 8 | /** 9 | * multipart parser options 10 | * @member Config#multipart 11 | */ 12 | export interface MultipartConfig { 13 | /** 14 | * which mode to handle multipart request, default is `stream`, the hard way. 15 | * If set mode to `file`, it's the easy way to handle multipart request and save it to local files. 16 | * If you don't know the Node.js Stream work, maybe you should use the `file` mode to get started. 17 | */ 18 | mode: 'stream' | 'file'; 19 | /** 20 | * special url to use file mode when global `mode` is `stream`. 21 | */ 22 | fileModeMatch?: PathMatchingPattern; 23 | /** 24 | * Auto set fields to parts, default is `false`. 25 | * Only work on `stream` mode. 26 | * If set true,all fields will be auto handle and can access by `parts.fields` 27 | */ 28 | autoFields: boolean; 29 | /** 30 | * default charset encoding, don't change it before you real know about it 31 | * Default is `utf8` 32 | */ 33 | defaultCharset: string; 34 | /** 35 | * For multipart forms, the default character set to use for values of part header parameters (e.g. filename) 36 | * that are not extended parameters (that contain an explicit charset), don't change it before you real know about it 37 | * Default is `utf8` 38 | */ 39 | defaultParamCharset: string; 40 | /** 41 | * Max field name size (in bytes), default is `100` 42 | */ 43 | fieldNameSize: number; 44 | /** 45 | * Max field value size (in bytes), default is `100kb` 46 | */ 47 | fieldSize: string | number; 48 | /** 49 | * Max number of non-file fields, default is `10` 50 | */ 51 | fields: number; 52 | /** 53 | * Max file size (in bytes), default is `10mb` 54 | */ 55 | fileSize: string | number; 56 | /** 57 | * Max number of file fields, default is `10` 58 | */ 59 | files: number; 60 | /** 61 | * Add more ext file names to the `whitelist`, default is `[]`, only valid when `whitelist` is `null` 62 | */ 63 | fileExtensions: string[]; 64 | /** 65 | * The white ext file names, default is `null` 66 | */ 67 | whitelist: string[] | ((filename: string) => boolean) | null; 68 | /** 69 | * Allow array field, default is `false` 70 | */ 71 | allowArrayField: boolean; 72 | /** 73 | * The directory for temporary files. Only work on `file` mode. 74 | * Default is `os.tmpdir()/egg-multipart-tmp/${appInfo.name}` 75 | */ 76 | tmpdir: string; 77 | /** 78 | * The schedule for cleaning temporary files. Only work on `file` mode. 79 | */ 80 | cleanSchedule: { 81 | /** 82 | * The cron expression for the schedule. 83 | * Default is `0 30 4 * * *` 84 | * @see https://github.com/eggjs/egg-schedule#cron-style-scheduling 85 | */ 86 | cron: string; 87 | /** 88 | * Default is `false` 89 | */ 90 | disable: boolean; 91 | }; 92 | checkFile?( 93 | fieldname: string, 94 | file: any, 95 | filename: string, 96 | encoding: string, 97 | mimetype: string 98 | ): void | Error; 99 | } 100 | 101 | export default (appInfo: EggAppInfo) => { 102 | return { 103 | multipart: { 104 | mode: 'stream', 105 | autoFields: false, 106 | defaultCharset: 'utf8', 107 | defaultParamCharset: 'utf8', 108 | fieldNameSize: 100, 109 | fieldSize: '100kb', 110 | fields: 10, 111 | fileSize: '10mb', 112 | files: 10, 113 | fileExtensions: [], 114 | whitelist: null, 115 | allowArrayField: false, 116 | tmpdir: path.join(os.tmpdir(), 'egg-multipart-tmp', appInfo.name), 117 | cleanSchedule: { 118 | cron: '0 30 4 * * *', 119 | disable: false, 120 | }, 121 | } as MultipartConfig, 122 | }; 123 | }; 124 | 125 | declare module '@eggjs/core' { 126 | // add EggAppConfig overrides types 127 | interface EggAppConfig { 128 | multipart: MultipartConfig; 129 | } 130 | } 131 | -------------------------------------------------------------------------------- /test/file-mode-limit-filesize-per-request.test.ts: -------------------------------------------------------------------------------- 1 | import assert from 'node:assert'; 2 | import fs from 'node:fs/promises'; 3 | import formstream from 'formstream'; 4 | import urllib from 'urllib'; 5 | import { mm, MockApplication } from '@eggjs/mock'; 6 | 7 | describe('test/file-mode-limit-filesize-per-request.test.ts', () => { 8 | let app: MockApplication; 9 | let server: any; 10 | let host: string; 11 | before(() => { 12 | app = mm.app({ 13 | baseDir: 'apps/limit-filesize-per-request', 14 | }); 15 | return app.ready(); 16 | }); 17 | before(() => { 18 | server = app.listen(); 19 | host = 'http://127.0.0.1:' + server.address().port; 20 | }); 21 | after(() => { 22 | return fs.rm(app.config.multipart.tmpdir, { force: true, recursive: true }); 23 | }); 24 | after(() => app.close()); 25 | after(() => server.close()); 26 | beforeEach(() => app.mockCsrf()); 27 | afterEach(mm.restore); 28 | 29 | it('should 200 when file size just 1mb on /upload-limit-1mb', async () => { 30 | const form = formstream(); 31 | form.buffer('file', Buffer.alloc(1 * 1024 * 1024 - 1), '1mb.js', 'application/octet-stream'); 32 | 33 | const headers = form.headers(); 34 | const res = await urllib.request(host + '/upload-limit-1mb', { 35 | method: 'POST', 36 | headers, 37 | stream: form as any, 38 | }); 39 | 40 | assert(res.status === 200); 41 | const data = JSON.parse(res.data); 42 | // console.log(data); 43 | assert(data.files.length === 1); 44 | assert(data.files[0].field === 'file'); 45 | assert(data.files[0].filename === '1mb.js'); 46 | assert(data.files[0].encoding === '7bit'); 47 | assert(data.files[0].mime === 'application/octet-stream'); 48 | assert(data.files[0].filepath.startsWith(app.config.multipart.tmpdir)); 49 | const stat = await fs.stat(data.files[0].filepath); 50 | assert(stat.size === 1 * 1024 * 1024 - 1); 51 | }); 52 | 53 | it('should 413 when file size > 1mb on /upload-limit-1mb', async () => { 54 | const form = formstream(); 55 | form.buffer('file', Buffer.alloc(1 * 1024 * 1024 + 10), '1mb.js', 'application/octet-stream'); 56 | 57 | const headers = form.headers(); 58 | const res = await urllib.request(host + '/upload-limit-1mb', { 59 | method: 'POST', 60 | headers, 61 | stream: form as any, 62 | dataType: 'json', 63 | }); 64 | assert(res.status === 413); 65 | assert(res.data.code === 'Request_fileSize_limit'); 66 | assert(res.data.message === 'Reach fileSize limit'); 67 | }); 68 | 69 | it('should 200 when file size > 1mb /upload-limit-2mb', async () => { 70 | const form = formstream(); 71 | form.buffer('file', Buffer.alloc(1 * 1024 * 1024 + 10), '2mb.js', 'application/octet-stream'); 72 | 73 | const headers = form.headers(); 74 | const res = await urllib.request(host + '/upload-limit-2mb', { 75 | method: 'POST', 76 | headers, 77 | stream: form as any, 78 | dataType: 'json', 79 | }); 80 | 81 | assert(res.status === 200); 82 | // console.log(res.data); 83 | const data = res.data; 84 | assert(data.files.length === 1); 85 | assert(data.files[0].field === 'file'); 86 | assert(data.files[0].filename === '2mb.js'); 87 | assert(data.files[0].encoding === '7bit'); 88 | assert(data.files[0].mime === 'application/octet-stream'); 89 | assert(data.files[0].filepath.startsWith(app.config.multipart.tmpdir)); 90 | const stat = await fs.stat(data.files[0].filepath); 91 | assert(stat.size === 1 * 1024 * 1024 + 10); 92 | }); 93 | 94 | it('should 413 when file size > 2mb on /upload-limit-2mb', async () => { 95 | const form = formstream(); 96 | form.buffer('file', Buffer.alloc(2 * 1024 * 1024 + 10), '2mb.js', 'application/octet-stream'); 97 | 98 | const headers = form.headers(); 99 | const res = await urllib.request(host + '/upload-limit-2mb', { 100 | method: 'POST', 101 | headers, 102 | stream: form as any, 103 | dataType: 'json', 104 | }); 105 | 106 | assert(res.status === 413); 107 | // console.log(res.data); 108 | assert(res.data.code === 'Request_fileSize_limit'); 109 | assert(res.data.message === 'Reach fileSize limit'); 110 | }); 111 | 112 | it('should 400 when request is not multipart content type /upload-limit-2mb', async () => { 113 | const res = await urllib.request(host + '/upload-limit-2mb', { 114 | method: 'POST', 115 | data: {}, 116 | dataType: 'json', 117 | }); 118 | 119 | assert(res.status === 400); 120 | assert(res.data.message === 'Content-Type must be multipart/*'); 121 | }); 122 | }); 123 | -------------------------------------------------------------------------------- /test/multipart-for-await.test.ts: -------------------------------------------------------------------------------- 1 | import assert from 'node:assert'; 2 | import path from 'node:path'; 3 | import { fileURLToPath } from 'node:url'; 4 | import formstream from 'formstream'; 5 | import urllib from 'urllib'; 6 | import { mm, MockApplication } from '@eggjs/mock'; 7 | 8 | const __filename = fileURLToPath(import.meta.url); 9 | const __dirname = path.dirname(__filename); 10 | 11 | describe('test/multipart-for-await.test.ts', () => { 12 | let app: MockApplication; 13 | let server: any; 14 | let host: string; 15 | before(async () => { 16 | app = mm.app({ 17 | baseDir: 'apps/multipart-for-await', 18 | }); 19 | await app.ready(); 20 | server = app.listen(); 21 | host = 'http://127.0.0.1:' + server.address().port; 22 | }); 23 | after(() => app.close()); 24 | after(() => server.close()); 25 | beforeEach(() => app.mockCsrf()); 26 | afterEach(mm.restore); 27 | 28 | it('should support for-await-of', async () => { 29 | const form = formstream(); 30 | form.field('foo', 'bar'); 31 | form.field('love', 'egg'); 32 | form.file('file1', path.join(__dirname, 'fixtures/中文名.js')); 33 | form.file('file2', path.join(__dirname, 'fixtures/testfile.js')); 34 | // will ignore empty file 35 | form.buffer('file3', Buffer.from(''), '', 'application/octet-stream'); 36 | 37 | const res = await urllib.request(host + '/upload', { 38 | method: 'POST', 39 | headers: form.headers(), 40 | stream: form as any, 41 | dataType: 'json', 42 | }); 43 | 44 | const data = res.data; 45 | // console.log(data); 46 | assert.equal(data.fields.foo, 'bar'); 47 | assert.equal(data.fields.love, 'egg'); 48 | assert.equal(data.files.file1.fileName, '中文名.js'); 49 | assert(data.files.file1.content.includes('hello')); 50 | assert.equal(data.files.file2.fileName, 'testfile.js'); 51 | assert(data.files.file2.content.includes('this is a test file')); 52 | assert(!data.files.file3); 53 | }); 54 | 55 | it('should auto consumed file stream on error throw', async () => { 56 | const form = formstream(); 57 | form.field('foo', 'bar'); 58 | form.field('love', 'egg'); 59 | form.file('file2', path.join(__dirname, 'fixtures/testfile.js')); 60 | 61 | const res = await urllib.request(host + '/upload?mock_error=true', { 62 | method: 'POST', 63 | headers: form.headers(), 64 | stream: form as any, 65 | dataType: 'json', 66 | }); 67 | 68 | assert.equal(res.data.message, 'mock error'); 69 | }); 70 | 71 | describe('should throw when limit', () => { 72 | it('limit fileSize', async () => { 73 | const form = formstream(); 74 | form.field('foo', 'bar'); 75 | form.field('love', 'egg'); 76 | form.file('file1', path.join(__dirname, 'fixtures/中文名.js')); 77 | form.file('file2', path.join(__dirname, 'fixtures/bigfile.js')); 78 | 79 | const res = await urllib.request(host + '/upload', { 80 | method: 'POST', 81 | headers: form.headers(), 82 | stream: form as any, 83 | dataType: 'json', 84 | }); 85 | 86 | const { data, status } = res; 87 | assert.equal(status, 413); 88 | assert.equal(data.message, 'Reach fileSize limit'); 89 | }); 90 | 91 | it('limit fileSize very small so limit event is miss', async () => { 92 | const form = formstream(); 93 | form.field('foo', 'bar'); 94 | form.field('love', 'egg'); 95 | form.file('file2', path.join(__dirname, 'fixtures/bigfile.js')); 96 | 97 | const res = await urllib.request(host + '/upload?fileSize=10', { 98 | method: 'POST', 99 | headers: form.headers(), 100 | stream: form as any, 101 | dataType: 'json', 102 | }); 103 | 104 | const { data, status } = res; 105 | assert.equal(status, 413); 106 | assert.equal(data.message, 'Reach fileSize limit'); 107 | }); 108 | 109 | it('limit fieldSize', async () => { 110 | const form = formstream(); 111 | form.field('foo', 'bar'); 112 | form.field('love', 'eggaaaaaaaaaaaaa'); 113 | form.file('file1', path.join(__dirname, 'fixtures/中文名.js')); 114 | form.file('file2', path.join(__dirname, 'fixtures/testfile.js')); 115 | 116 | const res = await urllib.request(host + '/upload', { 117 | method: 'POST', 118 | headers: form.headers(), 119 | stream: form as any, 120 | dataType: 'json', 121 | }); 122 | 123 | const { data, status } = res; 124 | assert.equal(status, 413); 125 | assert.equal(data.message, 'Reach fieldSize limit'); 126 | }); 127 | 128 | // TODO: still not support at busboy 1.x (only support at urlencoded) 129 | // https://github.com/mscdex/busboy/blob/v0.3.1/lib/types/multipart.js#L5 130 | // https://github.com/mscdex/busboy/blob/master/lib/types/multipart.js#L251 131 | it.skip('limit fieldNameSize', async () => { 132 | const form = formstream(); 133 | form.field('fooaaaaaaaaaaaaaaa', 'bar'); 134 | form.field('love', 'egg'); 135 | form.file('file1', path.join(__dirname, 'fixtures/中文名.js')); 136 | form.file('file2', path.join(__dirname, 'fixtures/testfile.js')); 137 | 138 | const res = await urllib.request(host + '/upload', { 139 | method: 'POST', 140 | headers: form.headers(), 141 | stream: form as any, 142 | dataType: 'json', 143 | }); 144 | 145 | const { data, status } = res; 146 | assert.equal(status, 413); 147 | assert.equal(data.message, 'Reach fieldNameSize limit'); 148 | }); 149 | }); 150 | }); 151 | -------------------------------------------------------------------------------- /test/enable-pathToRegexpModule.test.ts: -------------------------------------------------------------------------------- 1 | import assert from 'node:assert'; 2 | import formstream from 'formstream'; 3 | import urllib from 'urllib'; 4 | import path from 'node:path'; 5 | import { mm, MockApplication } from '@eggjs/mock'; 6 | import fs from 'node:fs/promises'; 7 | 8 | describe.skip('test/enable-pathToRegexpModule.test.ts', () => { 9 | let app: MockApplication; 10 | let server: any; 11 | let host: string; 12 | before(() => { 13 | app = mm.app({ 14 | baseDir: 'apps/fileModeMatch-glob-with-pathToRegexpModule', 15 | // pathToRegexpModule: require.resolve('path-to-regexp-v8'), 16 | }); 17 | return app.ready(); 18 | }); 19 | before(() => { 20 | server = app.listen(); 21 | host = 'http://127.0.0.1:' + server.address().port; 22 | }); 23 | after(() => { 24 | return fs.rm(app.config.multipart.tmpdir, { force: true, recursive: true }); 25 | }); 26 | after(() => app.close()); 27 | after(() => server.close()); 28 | beforeEach(() => app.mockCsrf()); 29 | afterEach(mm.restore); 30 | 31 | it('should upload match file mode work on /upload_file', async () => { 32 | const form = formstream(); 33 | form.field('foo', 'fengmk2').field('love', 'egg'); 34 | form.file('file1', __filename, 'foooooooo.js'); 35 | form.file('file2', __filename); 36 | // will ignore empty file 37 | form.buffer('file3', Buffer.from(''), '', 'application/octet-stream'); 38 | form.file('bigfile', path.join(__dirname, 'fixtures', 'bigfile.js')); 39 | // other form fields 40 | form.field('work', 'with Node.js'); 41 | 42 | const headers = form.headers(); 43 | const res = await urllib.request(host + '/upload_file', { 44 | method: 'POST', 45 | headers, 46 | stream: form as any, 47 | }); 48 | 49 | assert(res.status === 200); 50 | const data = JSON.parse(res.data); 51 | assert.deepStrictEqual(data.body, { foo: 'fengmk2', love: 'egg', work: 'with Node.js' }); 52 | assert(data.files.length === 3); 53 | assert(data.files[0].field === 'file1'); 54 | assert(data.files[0].filename === 'foooooooo.js'); 55 | assert(data.files[0].encoding === '7bit'); 56 | assert(data.files[0].mime === 'application/javascript'); 57 | assert(data.files[0].filepath.startsWith(app.config.multipart.tmpdir)); 58 | 59 | assert(data.files[1].field === 'file2'); 60 | assert(data.files[1].filename === 'enable-pathToRegexpModule.test.js'); 61 | assert(data.files[1].encoding === '7bit'); 62 | assert(data.files[1].mime === 'application/javascript'); 63 | assert(data.files[1].filepath.startsWith(app.config.multipart.tmpdir)); 64 | 65 | assert(data.files[2].field === 'bigfile'); 66 | assert(data.files[2].filename === 'bigfile.js'); 67 | assert(data.files[2].encoding === '7bit'); 68 | assert(data.files[2].mime === 'application/javascript'); 69 | assert(data.files[2].filepath.startsWith(app.config.multipart.tmpdir)); 70 | }); 71 | 72 | it('should upload match file mode work on /upload_file/*', async () => { 73 | const form = formstream(); 74 | form.field('foo', 'fengmk2').field('love', 'egg'); 75 | form.file('file1', __filename, 'foooooooo.js'); 76 | form.file('file2', __filename); 77 | // will ignore empty file 78 | form.buffer('file3', Buffer.from(''), '', 'application/octet-stream'); 79 | form.file('bigfile', path.join(__dirname, 'fixtures', 'bigfile.js')); 80 | // other form fields 81 | form.field('work', 'with Node.js'); 82 | 83 | const headers = form.headers(); 84 | const res = await urllib.request(host + '/upload_file/foo', { 85 | method: 'POST', 86 | headers, 87 | stream: form as any, 88 | }); 89 | 90 | assert.equal(res.status, 200); 91 | const data = JSON.parse(res.data); 92 | assert.deepStrictEqual(data.body, { foo: 'fengmk2', love: 'egg', work: 'with Node.js' }); 93 | assert(data.files.length === 3); 94 | assert(data.files[0].field === 'file1'); 95 | assert(data.files[0].filename === 'foooooooo.js'); 96 | assert(data.files[0].encoding === '7bit'); 97 | assert(data.files[0].mime === 'application/javascript'); 98 | assert(data.files[0].filepath.startsWith(app.config.multipart.tmpdir)); 99 | 100 | assert(data.files[1].field === 'file2'); 101 | assert(data.files[1].filename === 'enable-pathToRegexpModule.test.js'); 102 | assert(data.files[1].encoding === '7bit'); 103 | assert(data.files[1].mime === 'application/javascript'); 104 | assert(data.files[1].filepath.startsWith(app.config.multipart.tmpdir)); 105 | 106 | assert(data.files[2].field === 'bigfile'); 107 | assert(data.files[2].filename === 'bigfile.js'); 108 | assert(data.files[2].encoding === '7bit'); 109 | assert(data.files[2].mime === 'application/javascript'); 110 | assert(data.files[2].filepath.startsWith(app.config.multipart.tmpdir)); 111 | }); 112 | 113 | it('should upload not match file mode', async () => { 114 | const form = formstream(); 115 | form.field('foo', 'fengmk2').field('love', 'egg'); 116 | form.file('file1', __filename, 'foooooooo.js'); 117 | form.file('file2', __filename); 118 | // will ignore empty file 119 | form.buffer('file3', Buffer.from(''), '', 'application/octet-stream'); 120 | form.file('bigfile', path.join(__dirname, 'fixtures', 'bigfile.js')); 121 | // other form fields 122 | form.field('work', 'with Node.js'); 123 | 124 | const headers = form.headers(); 125 | const res = await urllib.request(host + '/upload', { 126 | method: 'POST', 127 | headers, 128 | stream: form as any, 129 | }); 130 | 131 | assert(res.status === 200); 132 | const data = JSON.parse(res.data); 133 | assert.deepStrictEqual(data, { body: {} }); 134 | }); 135 | }); 136 | -------------------------------------------------------------------------------- /test/stream-mode-with-filematch-glob.test.ts: -------------------------------------------------------------------------------- 1 | import assert from 'node:assert'; 2 | import path from 'node:path'; 3 | import fs from 'node:fs/promises'; 4 | import { fileURLToPath } from 'node:url'; 5 | import { mm, MockApplication } from '@eggjs/mock'; 6 | import formstream from 'formstream'; 7 | import urllib from 'urllib'; 8 | 9 | const __filename = fileURLToPath(import.meta.url); 10 | const __dirname = path.dirname(__filename); 11 | 12 | describe('test/stream-mode-with-filematch-glob.test.ts', () => { 13 | let app: MockApplication; 14 | let server: any; 15 | let host: string; 16 | before(() => { 17 | app = mm.app({ 18 | baseDir: 'apps/fileModeMatch-glob', 19 | }); 20 | return app.ready(); 21 | }); 22 | before(() => { 23 | server = app.listen(); 24 | host = 'http://127.0.0.1:' + server.address().port; 25 | }); 26 | after(() => { 27 | return fs.rm(app.config.multipart.tmpdir, { force: true, recursive: true }); 28 | }); 29 | after(() => app.close()); 30 | after(() => server.close()); 31 | beforeEach(() => app.mockCsrf()); 32 | afterEach(mm.restore); 33 | 34 | it('should upload match file mode work on /upload_file', async () => { 35 | const form = formstream(); 36 | form.field('foo', 'fengmk2').field('love', 'egg'); 37 | form.file('file1', __filename, 'foooooooo.js'); 38 | form.file('file2', __filename); 39 | // will ignore empty file 40 | form.buffer('file3', Buffer.from(''), '', 'application/octet-stream'); 41 | form.file('bigfile', path.join(__dirname, 'fixtures', 'bigfile.js')); 42 | // other form fields 43 | form.field('work', 'with Node.js'); 44 | 45 | const headers = form.headers(); 46 | const res = await urllib.request(host + '/upload_file', { 47 | method: 'POST', 48 | headers, 49 | stream: form as any, 50 | }); 51 | 52 | assert.equal(res.status, 200); 53 | const data = JSON.parse(res.data); 54 | assert.deepStrictEqual(data.body, { foo: 'fengmk2', love: 'egg', work: 'with Node.js' }); 55 | assert.equal(data.files.length, 3); 56 | assert.equal(data.files[0].field, 'file1'); 57 | assert.equal(data.files[0].filename, 'foooooooo.js'); 58 | assert.equal(data.files[0].encoding, '7bit'); 59 | assert.equal(data.files[0].mime, 'application/javascript'); 60 | assert.ok(data.files[0].filepath.startsWith(app.config.multipart.tmpdir)); 61 | 62 | assert.equal(data.files[1].field, 'file2'); 63 | assert.equal(data.files[1].filename, 'stream-mode-with-filematch-glob.test.ts'); 64 | assert.equal(data.files[1].encoding, '7bit'); 65 | assert.equal(data.files[1].mime, 'video/mp2t'); 66 | assert.ok(data.files[1].filepath.startsWith(app.config.multipart.tmpdir)); 67 | 68 | assert.equal(data.files[2].field, 'bigfile'); 69 | assert.equal(data.files[2].filename, 'bigfile.js'); 70 | assert.equal(data.files[2].encoding, '7bit'); 71 | assert.equal(data.files[2].mime, 'application/javascript'); 72 | assert.ok(data.files[2].filepath.startsWith(app.config.multipart.tmpdir)); 73 | }); 74 | 75 | it('should upload match file mode work on /upload_file/*', async () => { 76 | const form = formstream(); 77 | form.field('foo', 'fengmk2').field('love', 'egg'); 78 | form.file('file1', __filename, 'foooooooo.js'); 79 | form.file('file2', __filename); 80 | // will ignore empty file 81 | form.buffer('file3', Buffer.from(''), '', 'application/octet-stream'); 82 | form.file('bigfile', path.join(__dirname, 'fixtures', 'bigfile.js')); 83 | // other form fields 84 | form.field('work', 'with Node.js'); 85 | 86 | const headers = form.headers(); 87 | const res = await urllib.request(host + '/upload_file/foo', { 88 | method: 'POST', 89 | headers, 90 | stream: form as any, 91 | }); 92 | 93 | assert.equal(res.status, 200); 94 | const data = JSON.parse(res.data); 95 | assert.deepStrictEqual(data.body, { foo: 'fengmk2', love: 'egg', work: 'with Node.js' }); 96 | assert.equal(data.files.length, 3); 97 | assert.equal(data.files[0].field, 'file1'); 98 | assert.equal(data.files[0].filename, 'foooooooo.js'); 99 | assert.equal(data.files[0].encoding, '7bit'); 100 | assert.equal(data.files[0].mime, 'application/javascript'); 101 | assert.ok(data.files[0].filepath.startsWith(app.config.multipart.tmpdir)); 102 | 103 | assert.equal(data.files[1].field, 'file2'); 104 | assert.equal(data.files[1].filename, 'stream-mode-with-filematch-glob.test.ts'); 105 | assert.equal(data.files[1].encoding, '7bit'); 106 | assert.equal(data.files[1].mime, 'video/mp2t'); 107 | assert.ok(data.files[1].filepath.startsWith(app.config.multipart.tmpdir)); 108 | 109 | assert.equal(data.files[2].field, 'bigfile'); 110 | assert.equal(data.files[2].filename, 'bigfile.js'); 111 | assert.equal(data.files[2].encoding, '7bit'); 112 | assert.equal(data.files[2].mime, 'application/javascript'); 113 | assert.ok(data.files[2].filepath.startsWith(app.config.multipart.tmpdir)); 114 | }); 115 | 116 | it('should upload not match file mode', async () => { 117 | const form = formstream(); 118 | form.field('foo', 'fengmk2').field('love', 'egg'); 119 | form.file('file1', __filename, 'foooooooo.js'); 120 | form.file('file2', __filename); 121 | // will ignore empty file 122 | form.buffer('file3', Buffer.from(''), '', 'application/octet-stream'); 123 | form.file('bigfile', path.join(__dirname, 'fixtures', 'bigfile.js')); 124 | // other form fields 125 | form.field('work', 'with Node.js'); 126 | 127 | const headers = form.headers(); 128 | const res = await urllib.request(host + '/upload', { 129 | method: 'POST', 130 | headers, 131 | stream: form as any, 132 | }); 133 | 134 | assert(res.status === 200); 135 | const data = JSON.parse(res.data); 136 | assert.deepStrictEqual(data, { body: {} }); 137 | }); 138 | }); 139 | -------------------------------------------------------------------------------- /test/stream-mode-with-filematch.test.ts: -------------------------------------------------------------------------------- 1 | import assert from 'node:assert'; 2 | import path from 'node:path'; 3 | import fs from 'node:fs/promises'; 4 | import { fileURLToPath } from 'node:url'; 5 | import formstream from 'formstream'; 6 | import urllib from 'urllib'; 7 | import { mm, MockApplication } from '@eggjs/mock'; 8 | 9 | const __filename = fileURLToPath(import.meta.url); 10 | const __dirname = path.dirname(__filename); 11 | 12 | describe('test/stream-mode-with-filematch.test.ts', () => { 13 | let app: MockApplication; 14 | let server: any; 15 | let host: string; 16 | before(() => { 17 | app = mm.app({ 18 | baseDir: 'apps/fileModeMatch', 19 | }); 20 | return app.ready(); 21 | }); 22 | before(() => { 23 | server = app.listen(); 24 | host = 'http://127.0.0.1:' + server.address().port; 25 | }); 26 | after(() => { 27 | return fs.rm(app.config.multipart.tmpdir, { force: true, recursive: true }); 28 | }); 29 | after(() => app.close()); 30 | after(() => server.close()); 31 | beforeEach(() => app.mockCsrf()); 32 | afterEach(mm.restore); 33 | 34 | it('should upload match file mode', async () => { 35 | const form = formstream(); 36 | form.field('foo', 'fengmk2').field('love', 'egg'); 37 | form.file('file1', __filename, 'foooooooo.js'); 38 | form.file('file2', __filename); 39 | // will ignore empty file 40 | form.buffer('file3', Buffer.from(''), '', 'application/octet-stream'); 41 | form.file('bigfile', path.join(__dirname, 'fixtures', 'bigfile.js')); 42 | // other form fields 43 | form.field('work', 'with Node.js'); 44 | 45 | const headers = form.headers(); 46 | const res = await urllib.request(host + '/upload_file', { 47 | method: 'POST', 48 | headers, 49 | stream: form as any, 50 | }); 51 | 52 | assert(res.status === 200); 53 | const data = JSON.parse(res.data); 54 | assert.deepStrictEqual(data.body, { foo: 'fengmk2', love: 'egg', work: 'with Node.js' }); 55 | assert(data.files.length === 3); 56 | assert(data.files[0].field === 'file1'); 57 | assert(data.files[0].filename === 'foooooooo.js'); 58 | assert(data.files[0].encoding === '7bit'); 59 | assert(data.files[0].mime === 'application/javascript'); 60 | assert(data.files[0].filepath.startsWith(app.config.multipart.tmpdir)); 61 | 62 | assert(data.files[1].field === 'file2'); 63 | assert(data.files[1].filename === 'stream-mode-with-filematch.test.ts'); 64 | assert(data.files[1].encoding === '7bit'); 65 | assert(data.files[1].mime === 'video/mp2t'); 66 | assert(data.files[1].filepath.startsWith(app.config.multipart.tmpdir)); 67 | 68 | assert(data.files[2].field === 'bigfile'); 69 | assert(data.files[2].filename === 'bigfile.js'); 70 | assert(data.files[2].encoding === '7bit'); 71 | assert(data.files[2].mime === 'application/javascript'); 72 | assert(data.files[2].filepath.startsWith(app.config.multipart.tmpdir)); 73 | }); 74 | 75 | it('should upload not match file mode', async () => { 76 | const form = formstream(); 77 | form.field('foo', 'fengmk2').field('love', 'egg'); 78 | form.file('file1', __filename, 'foooooooo.js'); 79 | form.file('file2', __filename); 80 | // will ignore empty file 81 | form.buffer('file3', Buffer.from(''), '', 'application/octet-stream'); 82 | form.file('bigfile', path.join(__dirname, 'fixtures', 'bigfile.js')); 83 | // other form fields 84 | form.field('work', 'with Node.js'); 85 | 86 | const headers = form.headers(); 87 | const res = await urllib.request(host + '/upload', { 88 | method: 'POST', 89 | headers, 90 | stream: form as any, 91 | }); 92 | 93 | assert(res.status === 200); 94 | const data = JSON.parse(res.data); 95 | assert.deepStrictEqual(data, { body: {} }); 96 | }); 97 | 98 | it('should allow to call saveRequestFiles on controller', async () => { 99 | const form = formstream(); 100 | form.field('foo', 'fengmk2').field('love', 'egg'); 101 | form.file('file1', __filename, 'foooooooo.js'); 102 | form.file('file2', __filename); 103 | // will ignore empty file 104 | form.buffer('file3', Buffer.from(''), '', 'application/octet-stream'); 105 | form.file('bigfile', path.join(__dirname, 'fixtures', 'bigfile.js')); 106 | // other form fields 107 | form.field('work', 'with Node.js'); 108 | 109 | const headers = form.headers(); 110 | const res = await urllib.request(host + '/save', { 111 | method: 'POST', 112 | headers, 113 | stream: form as any, 114 | }); 115 | 116 | assert(res.status === 200); 117 | const data = JSON.parse(res.data); 118 | assert.deepStrictEqual(data.body, { foo: 'fengmk2', love: 'egg', work: 'with Node.js' }); 119 | assert(data.files.length === 3); 120 | assert(data.files[0].field === 'file1'); 121 | assert(data.files[0].filename === 'foooooooo.js'); 122 | assert(data.files[0].encoding === '7bit'); 123 | assert(data.files[0].mime === 'application/javascript'); 124 | assert(data.files[0].filepath.startsWith(app.config.multipart.tmpdir)); 125 | 126 | assert(data.files[1].field === 'file2'); 127 | assert(data.files[1].filename === 'stream-mode-with-filematch.test.ts'); 128 | assert(data.files[1].encoding === '7bit'); 129 | assert(data.files[1].mime === 'video/mp2t'); 130 | assert(data.files[1].filepath.startsWith(app.config.multipart.tmpdir)); 131 | 132 | assert(data.files[2].field === 'bigfile'); 133 | assert(data.files[2].filename === 'bigfile.js'); 134 | assert(data.files[2].encoding === '7bit'); 135 | assert(data.files[2].mime === 'application/javascript'); 136 | assert(data.files[2].filepath.startsWith(app.config.multipart.tmpdir)); 137 | }); 138 | 139 | it('should 400 when request is not multipart', async () => { 140 | const res = await urllib.request(host + '/save', { 141 | method: 'POST', 142 | data: { foo: 'bar' }, 143 | dataType: 'json', 144 | }); 145 | assert(res.status === 400); 146 | assert.deepStrictEqual(res.data, { 147 | message: 'Content-Type must be multipart/*', 148 | }); 149 | }); 150 | 151 | it('should register clean_tmpdir schedule', async () => { 152 | // [egg-schedule]: register schedule /hello/egg-multipart/app/schedule/clean_tmpdir.js 153 | const logger = app.loggers.scheduleLogger; 154 | const content = await fs.readFile(logger.options.file, 'utf8'); 155 | assert.match(content, /\[@eggjs\/schedule\]: register schedule .+clean_tmpdir\.ts/); 156 | }); 157 | }); 158 | -------------------------------------------------------------------------------- /src/app/extend/context.ts: -------------------------------------------------------------------------------- 1 | import assert from 'node:assert'; 2 | import path from 'node:path'; 3 | import { randomUUID } from 'node:crypto'; 4 | import fs from 'node:fs/promises'; 5 | import { createWriteStream } from 'node:fs'; 6 | import { Readable, PassThrough } from 'node:stream'; 7 | import { pipeline } from 'node:stream/promises'; 8 | // @ts-expect-error no types 9 | import parse from 'co-busboy'; 10 | import dayjs from 'dayjs'; 11 | import { Context } from '@eggjs/core'; 12 | import { humanizeBytes } from '../../lib/utils.js'; 13 | import { LimitError } from '../../lib/LimitError.js'; 14 | import { MultipartFileTooLargeError } from '../../lib/MultipartFileTooLargeError.js'; 15 | 16 | const HAS_CONSUMED = Symbol('Context#multipartHasConsumed'); 17 | 18 | export interface EggFile { 19 | field: string; 20 | filename: string; 21 | encoding: string; 22 | mime: string; 23 | filepath: string; 24 | } 25 | 26 | export interface MultipartFileStream extends Readable { 27 | fields: Record; 28 | filename: string; 29 | fieldname: string; 30 | mime: string; 31 | mimeType: string; 32 | transferEncoding: string; 33 | encoding: string; 34 | truncated: boolean; 35 | } 36 | 37 | export interface MultipartOptions { 38 | autoFields?: boolean; 39 | /** 40 | * required file submit, default is true 41 | */ 42 | requireFile?: boolean; 43 | /** 44 | * default charset encoding 45 | */ 46 | defaultCharset?: string; 47 | /** 48 | * compatible with defaultCharset 49 | * @deprecated use `defaultCharset` instead 50 | */ 51 | defCharset?: string; 52 | defaultParamCharset?: string; 53 | /** 54 | * compatible with defaultParamCharset 55 | * @deprecated use `defaultParamCharset` instead 56 | */ 57 | defParamCharset?: string; 58 | limits?: { 59 | fieldNameSize?: number; 60 | fieldSize?: number; 61 | fields?: number; 62 | fileSize?: number; 63 | files?: number; 64 | parts?: number; 65 | headerPairs?: number; 66 | }; 67 | checkFile?( 68 | fieldname: string, 69 | file: any, 70 | filename: string, 71 | encoding: string, 72 | mimetype: string 73 | ): void | Error; 74 | } 75 | 76 | export default class MultipartContext extends Context { 77 | /** 78 | * create multipart.parts instance, to get separated files. 79 | * @function Context#multipart 80 | * @param {Object} [options] - override default multipart configurations 81 | * - {Boolean} options.autoFields 82 | * - {String} options.defaultCharset 83 | * - {String} options.defaultParamCharset 84 | * - {Object} options.limits 85 | * - {Function} options.checkFile 86 | * @return {Yieldable | AsyncIterable} parts 87 | */ 88 | multipart(options: MultipartOptions = {}): AsyncIterable { 89 | // eslint-disable-next-line @typescript-eslint/no-this-alias 90 | const ctx = this; 91 | // multipart/form-data 92 | if (!ctx.is('multipart')) ctx.throw(400, 'Content-Type must be multipart/*'); 93 | 94 | assert(!ctx[HAS_CONSUMED], 'the multipart request can\'t be consumed twice'); 95 | ctx[HAS_CONSUMED] = true; 96 | 97 | const { autoFields, defaultCharset, defaultParamCharset, checkFile } = ctx.app.config.multipart; 98 | const { fieldNameSize, fieldSize, fields, fileSize, files } = ctx.app.config.multipart; 99 | options = extractOptions(options); 100 | 101 | const parseOptions = Object.assign({ 102 | autoFields, 103 | defCharset: defaultCharset, 104 | defParamCharset: defaultParamCharset, 105 | checkFile, 106 | }, options); 107 | 108 | // https://github.com/mscdex/busboy#busboy-methods 109 | // merge limits 110 | parseOptions.limits = Object.assign({ 111 | fieldNameSize, 112 | fieldSize, 113 | fields, 114 | fileSize, 115 | files, 116 | }, options.limits); 117 | 118 | // mount asyncIterator, so we can use `for await` to get parts 119 | const parts = parse(this, parseOptions); 120 | parts[Symbol.asyncIterator] = async function* () { 121 | let part: MultipartFileStream | undefined; 122 | do { 123 | part = await parts(); 124 | 125 | if (!part) continue; 126 | 127 | if (Array.isArray(part)) { 128 | if (part[3]) throw new LimitError('Request_fieldSize_limit', 'Reach fieldSize limit'); 129 | // TODO: still not support at busboy 1.x (only support at urlencoded) 130 | // https://github.com/mscdex/busboy/blob/v0.3.1/lib/types/multipart.js#L5 131 | // https://github.com/mscdex/busboy/blob/master/lib/types/multipart.js#L251 132 | // if (part[2]) throw new LimitError('Request_fieldNameSize_limit', 'Reach fieldNameSize limit'); 133 | } else { 134 | // user click `upload` before choose a file, `part` will be file stream, but `part.filename` is empty must handler this, such as log error. 135 | if (!part.filename) { 136 | ctx.coreLogger.debug('[egg-multipart] file field `%s` is upload without file stream, will drop it.', part.fieldname); 137 | await pipeline(part, new PassThrough()); 138 | continue; 139 | } 140 | // TODO: check whether filename is malicious input 141 | 142 | // busboy only set truncated when consume the stream 143 | if (part.truncated) { 144 | // in case of emit 'limit' too fast 145 | throw new LimitError('Request_fileSize_limit', 'Reach fileSize limit'); 146 | } else { 147 | part.once('limit', function(this: MultipartFileStream) { 148 | this.emit('error', new LimitError('Request_fileSize_limit', 'Reach fileSize limit')); 149 | this.resume(); 150 | }); 151 | } 152 | } 153 | 154 | // dispatch part to outter logic such as for-await-of 155 | yield part; 156 | 157 | } while (part !== undefined); 158 | }; 159 | return parts; 160 | } 161 | 162 | /** 163 | * save request multipart data and files to `ctx.request` 164 | * @function Context#saveRequestFiles 165 | * @param {Object} options - { limits, checkFile, ... } 166 | */ 167 | async saveRequestFiles(options: MultipartOptions = {}) { 168 | // eslint-disable-next-line @typescript-eslint/no-this-alias 169 | const ctx = this; 170 | 171 | const allowArrayField = ctx.app.config.multipart.allowArrayField; 172 | 173 | let storeDir: string | undefined; 174 | 175 | const requestBody: Record = {}; 176 | const requestFiles: EggFile[] = []; 177 | 178 | options.autoFields = false; 179 | const parts = ctx.multipart(options); 180 | 181 | try { 182 | for await (const part of parts) { 183 | if (Array.isArray(part)) { 184 | // fields 185 | const [ fieldName, fieldValue ] = part; 186 | if (!allowArrayField) { 187 | requestBody[fieldName] = fieldValue; 188 | } else { 189 | if (!requestBody[fieldName]) { 190 | requestBody[fieldName] = fieldValue; 191 | } else if (!Array.isArray(requestBody[fieldName])) { 192 | requestBody[fieldName] = [ requestBody[fieldName], fieldValue ]; 193 | } else { 194 | requestBody[fieldName].push(fieldValue); 195 | } 196 | } 197 | } else { 198 | // stream 199 | const { filename, fieldname, encoding, mime } = part; 200 | 201 | if (!storeDir) { 202 | // ${tmpdir}/YYYY/MM/DD/HH 203 | storeDir = path.join(ctx.app.config.multipart.tmpdir, dayjs().format('YYYY/MM/DD/HH')); 204 | await fs.mkdir(storeDir, { recursive: true }); 205 | } 206 | 207 | // write to tmp file 208 | const filepath = path.join(storeDir, randomUUID() + path.extname(filename)); 209 | const target = createWriteStream(filepath); 210 | await pipeline(part, target); 211 | 212 | const meta = { 213 | filepath, 214 | field: fieldname, 215 | filename, 216 | encoding, 217 | mime, 218 | // keep same property name as file stream, https://github.com/cojs/busboy/blob/master/index.js#L114 219 | fieldname, 220 | transferEncoding: encoding, 221 | mimeType: mime, 222 | }; 223 | 224 | requestFiles.push(meta); 225 | } 226 | } 227 | } catch (err) { 228 | await ctx.cleanupRequestFiles(requestFiles); 229 | throw err; 230 | } 231 | 232 | ctx.request.body = requestBody; 233 | ctx.request.files = requestFiles; 234 | } 235 | 236 | /** 237 | * get upload file stream 238 | * @example 239 | * ```js 240 | * const stream = await ctx.getFileStream(); 241 | * // get other fields 242 | * console.log(stream.fields); 243 | * ``` 244 | * @function Context#getFileStream 245 | * @param {Object} options 246 | * - {Boolean} options.requireFile - required file submit, default is true 247 | * - {String} options.defaultCharset 248 | * - {String} options.defaultParamCharset 249 | * - {Object} options.limits 250 | * - {Function} options.checkFile 251 | * @return {ReadStream} stream 252 | * @since 1.0.0 253 | * @deprecated Not safe enough, use `ctx.multipart()` instead 254 | */ 255 | async getFileStream(options: MultipartOptions = {}): Promise { 256 | options.autoFields = true; 257 | const parts: any = this.multipart(options); 258 | let stream: MultipartFileStream = await parts(); 259 | 260 | if (options.requireFile !== false) { 261 | // stream not exists, treat as an exception 262 | if (!stream || !stream.filename) { 263 | this.throw(400, 'Can\'t found upload file'); 264 | } 265 | } 266 | 267 | if (!stream) { 268 | stream = Readable.from([]) as MultipartFileStream; 269 | } 270 | 271 | if (stream.truncated) { 272 | throw new LimitError('Request_fileSize_limit', 'Request file too large, please check multipart config'); 273 | } 274 | 275 | stream.fields = parts.field; 276 | stream.once('limit', () => { 277 | const err = new MultipartFileTooLargeError( 278 | 'Request file too large, please check multipart config', stream.fields, stream.filename); 279 | if (stream.listenerCount('error') > 0) { 280 | stream.emit('error', err); 281 | this.coreLogger.warn(err); 282 | } else { 283 | this.coreLogger.error(err); 284 | // ignore next error event 285 | stream.on('error', () => {}); 286 | } 287 | // ignore all data 288 | stream.resume(); 289 | }); 290 | return stream; 291 | } 292 | 293 | /** 294 | * clean up request tmp files helper 295 | * @function Context#cleanupRequestFiles 296 | * @param {Array} [files] - file paths need to cleanup, default is `ctx.request.files`. 297 | */ 298 | async cleanupRequestFiles(files?: EggFile[]) { 299 | if (!files || !files.length) { 300 | files = this.request.files; 301 | } 302 | if (Array.isArray(files)) { 303 | for (const file of files) { 304 | try { 305 | await fs.rm(file.filepath, { force: true, recursive: true }); 306 | } catch (err) { 307 | // warning log 308 | this.coreLogger.warn('[egg-multipart-cleanupRequestFiles-error] file: %j, error: %s', file, err); 309 | } 310 | } 311 | } 312 | } 313 | } 314 | 315 | function extractOptions(options: MultipartOptions = {}) { 316 | const opts: MultipartOptions = {}; 317 | if (typeof options.autoFields === 'boolean') { 318 | opts.autoFields = options.autoFields; 319 | } 320 | if (options.limits) { 321 | opts.limits = options.limits; 322 | } 323 | if (options.checkFile) { 324 | opts.checkFile = options.checkFile; 325 | } 326 | 327 | if (options.defCharset) { 328 | opts.defCharset = options.defCharset; 329 | } 330 | if (options.defParamCharset) { 331 | opts.defParamCharset = options.defParamCharset; 332 | } 333 | // compatible with config names 334 | if (options.defaultCharset) { 335 | opts.defCharset = options.defaultCharset; 336 | } 337 | if (options.defaultParamCharset) { 338 | opts.defParamCharset = options.defaultParamCharset; 339 | } 340 | 341 | // limits 342 | if (options.limits) { 343 | const limits: Record = opts.limits = { ...options.limits }; 344 | for (const key in limits) { 345 | if (key.endsWith('Size') && limits[key]) { 346 | limits[key] = humanizeBytes(limits[key]); 347 | } 348 | } 349 | } 350 | 351 | return opts; 352 | } 353 | 354 | declare module '@eggjs/core' { 355 | interface Request { 356 | /** 357 | * Files Object Array 358 | */ 359 | files?: EggFile[]; 360 | } 361 | 362 | interface Context { 363 | saveRequestFiles(options?: MultipartOptions): Promise; 364 | getFileStream(options?: MultipartOptions): Promise; 365 | cleanupRequestFiles(files?: EggFile[]): Promise; 366 | } 367 | } 368 | 369 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## [4.0.0](https://github.com/eggjs/multipart/compare/v3.5.0...v4.0.0) (2025-02-03) 4 | 5 | 6 | ### ⚠ BREAKING CHANGES 7 | 8 | * drop Node.js < 18.19.0 support 9 | 10 | part of https://github.com/eggjs/egg/issues/3644 11 | 12 | https://github.com/eggjs/egg/issues/5257 13 | 14 | 16 | ## Summary by CodeRabbit 17 | 18 | - **New Features** 19 | - Introduced a new middleware for handling multipart requests. 20 | - Added improved error handling for oversized file uploads. 21 | 22 | - **Refactor** 23 | - Streamlined configuration and context enhancements for better 24 | stability and TypeScript support. 25 | - Modernized the codebase by transitioning to ES modules and updating 26 | type definitions. 27 | 28 | - **Chores** 29 | - Updated package metadata, dependencies, and continuous integration 30 | settings to support newer Node.js versions. 31 | - Introduced a new TypeScript configuration for stricter type-checking. 32 | 33 | - **Tests** 34 | - Added unit tests to validate application behavior under incorrect 35 | configurations. 36 | - Established comprehensive tests for multipart form handling to ensure 37 | correct processing of file uploads. 38 | - Transitioned existing tests to TypeScript and updated assertions for 39 | consistency. 40 | 41 | 42 | ### Features 43 | 44 | * support cjs and esm both by tshy ([#67](https://github.com/eggjs/multipart/issues/67)) ([ccefb3e](https://github.com/eggjs/multipart/commit/ccefb3ebe89e5a061adb7d8235bd8670a57d7411)) 45 | 46 | ## [3.5.0](https://github.com/eggjs/egg-multipart/compare/v3.4.0...v3.5.0) (2025-01-22) 47 | 48 | 49 | ### Features 50 | 51 | * support custom pathToRegexpModule ([#66](https://github.com/eggjs/egg-multipart/issues/66)) ([d474eec](https://github.com/eggjs/egg-multipart/commit/d474eecf1f86115bfb9750f1856a4d87ec542109)) 52 | 53 | ## [3.4.0](https://github.com/eggjs/egg-multipart/compare/v3.3.0...v3.4.0) (2024-06-07) 54 | 55 | 56 | ### Features 57 | 58 | * remove unuse "stream-wormhole" deps ([#63](https://github.com/eggjs/egg-multipart/issues/63)) ([f61d60f](https://github.com/eggjs/egg-multipart/commit/f61d60fba2c9290542acabe9f7dca0f201635e61)) 59 | 60 | ## [3.3.0](https://github.com/eggjs/egg-multipart/compare/v3.2.0...v3.3.0) (2022-12-18) 61 | 62 | 63 | ### Features 64 | 65 | * drop uuid dependency ([#62](https://github.com/eggjs/egg-multipart/issues/62)) ([b8bb895](https://github.com/eggjs/egg-multipart/commit/b8bb895acec33f4b1476ad35a06718529d6ad067)) 66 | 67 | --- 68 | 69 | 70 | 3.2.0 / 2022-09-29 71 | ================== 72 | 73 | **features** 74 | * [[`b007d99`](http://github.com/eggjs/egg-multipart/commit/b007d9938939e93732cdee168987c02cf6458e86)] - feat: use PassThrough instead of Writable (#60) (TZ | 天猪 <>) 75 | * [[`ca9efc1`](http://github.com/eggjs/egg-multipart/commit/ca9efc1d3faeb332b48de8320a6b9df31936bb42)] - feat: support `for await...of` (#57) (TZ | 天猪 <>) 76 | 77 | **others** 78 | * [[`e9bba31`](http://github.com/eggjs/egg-multipart/commit/e9bba31d051cd14d0ad02c3d5297991f82d6fa1c)] - refactor: saveRequestFiles with for-await-for (#58) (TZ | 天猪 <>) 79 | 80 | 3.1.0 / 2022-09-27 81 | ================== 82 | 83 | **others** 84 | * [[`399c6cb`](http://github.com/eggjs/egg-multipart/commit/399c6cb8c685337c3a9bcab00191e8e4a018f25f)] - 🐛 feat: refactor multipart options normalize && fix defParamCharset default to utf8 (#56) (fengmk2 <>) 85 | * [[`69ba7c0`](http://github.com/eggjs/egg-multipart/commit/69ba7c0c91321866f4f3cdab06d66081cae3796a)] - Create codeql-analysis.yml (fengmk2 <>) 86 | * [[`cc93417`](http://github.com/eggjs/egg-multipart/commit/cc93417af059788ff54d6af56816991e1e0d8b6a)] - test: fix ci (#55) (TZ | 天猪 <>) 87 | 88 | 3.0.0 / 2022-09-21 89 | ================== 90 | 91 | **others** 92 | * [[`a488127`](http://github.com/eggjs/egg-multipart/commit/a488127dc399eda2542a39b268f04f78569a3be1)] - refactor: [BREAKING_CHANGE] update deps co-busbox && node>=14.x (#54) (TZ | 天猪 <>) 93 | 94 | 2.13.1 / 2021-07-21 95 | ================== 96 | 97 | **fixes** 98 | * [[`20e765e`](http://github.com/eggjs/egg-multipart/commit/20e765ee2b121bbd1b3ba8853397990a67f7b76b)] - fix: add autoFields dts (#53) (恒遥 <>) 99 | 100 | 2.13.0 / 2021-07-05 101 | ================== 102 | 103 | **others** 104 | * [[`013ba19`](http://github.com/eggjs/egg-multipart/commit/013ba1939e5eeefa605e3b444d45d2513f90a875)] - deps: upgrade globby to the latest version for install warning (sky <>) 105 | * [[`7e07b3b`](http://github.com/eggjs/egg-multipart/commit/7e07b3b7ec586b95f6ada9ac11ae5a778b2e73e3)] - chore: remove badges (fengmk2 <>) 106 | * [[`3fdc940`](http://github.com/eggjs/egg-multipart/commit/3fdc94025713f92e2be6d7e628b834d4bb8fbe5a)] - test: add limit fileSize per-request test cases (#51) (fengmk2 <>) 107 | 108 | 2.12.0 / 2021-05-22 109 | ================== 110 | 111 | **features** 112 | * [[`ebd22fb`](http://github.com/eggjs/egg-multipart/commit/ebd22fb62e16f6bc6b785d28a5e19bb3e7915170)] - feat: deleting the temporary directory is optional (#48) (cd-xulei <>) 113 | 114 | 2.11.1 / 2021-05-18 115 | ================== 116 | 117 | **fixes** 118 | * [[`73ea7f2`](http://github.com/eggjs/egg-multipart/commit/73ea7f205408b99146fa784b851001314119413b)] - fix: fix array filed value (#50) (killa <>) 119 | 120 | 2.11.0 / 2021-05-17 121 | ================== 122 | 123 | **features** 124 | * [[`a73c8e6`](http://github.com/eggjs/egg-multipart/commit/a73c8e66a03babac76e924cc23b14aa114e08ea2)] - feat: allow array fileds in file mode (#49) (killa <>) 125 | 126 | 2.10.3 / 2020-05-06 127 | ================== 128 | 129 | **fixes** 130 | * [[`718df0e`](http://github.com/eggjs/egg-multipart/commit/718df0e85455746e6126c6de8180b427e798d217)] - fix: incorrect file size limit (#44) (hyj1991 <>) 131 | 132 | 2.10.2 / 2020-03-30 133 | ================== 134 | 135 | **fixes** 136 | * [[`ce33219`](http://github.com/eggjs/egg-multipart/commit/ce33219f008e390a7321a8dfb52e887ca2d6aa71)] - fix: definition of config.whitelist (#43) (cjf <>) 137 | 138 | **others** 139 | * [[`94c1135`](http://github.com/eggjs/egg-multipart/commit/94c1135f49dfbcea3a39d59b1b5e2b0a351d217a)] - docs: fix error handler example (#42) (zeroslope <<10218146+zeroslope@users.noreply.github.com>>) 140 | 141 | 2.10.1 / 2019-12-16 142 | ================== 143 | 144 | **fixes** 145 | * [[`3451864`](http://github.com/eggjs/egg-multipart/commit/34518642562b8712040220090ee5828583a2fdcf)] - fix: support extname not speicified (#40) (刘涛 <>) 146 | 147 | 2.10.0 / 2019-12-11 148 | ================== 149 | 150 | **features** 151 | * [[`21ded55`](http://github.com/eggjs/egg-multipart/commit/21ded553420c383bf854a7e3374b0c5bb8c18581)] - feat: compatibility without dot at fileExtensions (#39) (TZ | 天猪 <>) 152 | 153 | 2.9.1 / 2019-11-07 154 | ================== 155 | 156 | **fixes** 157 | * [[`27464f3`](http://github.com/eggjs/egg-multipart/commit/27464f3b954b31005f042084a95cbfbac5dcf9a4)] - fix: add more error message (#38) (TZ | 天猪 <>) 158 | 159 | 2.9.0 / 2019-08-09 160 | ================== 161 | 162 | **features** 163 | * [[`a1fcdab`](http://github.com/eggjs/egg-multipart/commit/a1fcdab00ef1113845bbe41a4c0b40ce9356cc94)] - feat: fileModeMatch support glob with egg-path-matching (#36) (TZ | 天猪 <>) 164 | 165 | 2.8.0 / 2019-08-03 166 | ================== 167 | 168 | **features** 169 | * [[`5d3ee0f`](http://github.com/eggjs/egg-multipart/commit/5d3ee0f1b82ba705f8bac0468cb19ab6f1dce8ab)] - feat: saveRequestFiles support options (#37) (仙森 <>) 170 | 171 | 2.7.1 / 2019-05-22 172 | ================== 173 | 174 | **fixes** 175 | * [[`75b1d48`](http://github.com/eggjs/egg-multipart/commit/75b1d48079b3c6b1358bf75197af0c8164ac926a)] - fix: whitelist declaration(#35) (Stephen <>) 176 | 177 | 2.7.0 / 2019-05-20 178 | ================== 179 | 180 | **features** 181 | * [[`0d26aa0`](http://github.com/eggjs/egg-multipart/commit/0d26aa0862279eac15cf72281a90ccf77731e3d6)] - feat: export saveRequestFiles to context (#34) (fengmk2 <>) 182 | 183 | **others** 184 | * [[`c5ca3ea`](http://github.com/eggjs/egg-multipart/commit/c5ca3ea2a46708744bb884f67fafee9bc1606df1)] - chore: remove tmp files and don't block the request's response (#33) (fengmk2 <>) 185 | 186 | 2.6.2 / 2019-05-19 187 | ================== 188 | 189 | **fixes** 190 | * [[`72b03aa`](http://github.com/eggjs/egg-multipart/commit/72b03aae2ef18bef7f8d0a71c323f072c567f8d5)] - fix: keep same metas as file stream on file mode (#32) (fengmk2 <>) 191 | 192 | 2.6.1 / 2019-05-16 193 | ================== 194 | 195 | **others** 196 | * [[`c5c4308`](http://github.com/eggjs/egg-multipart/commit/c5c43080df4c203c23053398390cfee25dc60542)] - chore: add typings and jsdocs (#31) (TZ | 天猪 <>) 197 | 198 | 2.6.0 / 2019-05-16 199 | ================== 200 | 201 | **features** 202 | * [[`7eb534f`](http://github.com/eggjs/egg-multipart/commit/7eb534f3b2cdb44fda025cf831877b8be7e84b55)] - feat: support file mode on default stream mode (#30) (fengmk2 <>) 203 | 204 | 2.5.0 / 2019-05-01 205 | ================== 206 | 207 | **features** 208 | * [[`33c6b52`](http://github.com/eggjs/egg-multipart/commit/33c6b52fcd7cc4674cc2ff51dfe849adf078ad5c)] - feat(types): typescript support (#28) (George <>) 209 | 210 | **others** 211 | * [[`6be344f`](http://github.com/eggjs/egg-multipart/commit/6be344fd7cdaa04c8e0861f5295244c8a85d14e8)] - test: fix schedule task test case (#29) (George <>) 212 | 213 | 2.4.0 / 2018-12-26 214 | ================== 215 | 216 | **features** 217 | * [[`d7504b9`](http://github.com/eggjs/egg-multipart/commit/d7504b9635c68184181c751212c30a6eb53f87fe)] - feat: custom multipart parse options per request (#27) (fengmk2 <>) 218 | 219 | 2.3.0 / 2018-11-11 220 | ================== 221 | 222 | **features** 223 | * [[`8d63cea`](http://github.com/eggjs/egg-multipart/commit/8d63cea48134d4d2a69796a399f04117222efd70)] - feat: export ctx.cleanupRequestFiles to improve cleanup more easy (#22) (fengmk2 <>) 224 | 225 | 2.2.1 / 2018-09-29 226 | ================== 227 | 228 | * chore: fix egg docs build (#21) 229 | * chore: add azure pipelines badge 230 | 231 | 2.2.0 / 2018-09-29 232 | ================== 233 | 234 | **features** 235 | * [[`75c0733`](http://github.com/eggjs/egg-multipart/commit/75c0733bcbb68349970b5d2bb189bf8822954337)] - feat: Provide `file` mode to handle multipart request (#19) (fengmk2 <>) 236 | 237 | **others** 238 | * [[`0b4e118`](http://github.com/eggjs/egg-multipart/commit/0b4e118a8eef3e61262fb981999cc2173dc08cc3)] - chore: no need to consume stream on error throw (#18) (fengmk2 <>) 239 | 240 | 2.1.0 / 2018-08-07 241 | ================== 242 | 243 | **features** 244 | * [[`5ece18a`](http://github.com/eggjs/egg-multipart/commit/5ece18abd0a1026fa742e15a7480010619156051)] - feat: getFileStream() can accept non file request (#17) (fengmk2 <>) 245 | 246 | 2.0.0 / 2017-11-10 247 | ================== 248 | 249 | **others** 250 | * [[`6a7fa06`](http://github.com/eggjs/egg-multipart/commit/6a7fa06d8978d061950d339cdd685b1ace6995c3)] - refactor: use async function and support egg@2 (#15) (Yiyu He <>) 251 | 252 | 1.5.1 / 2017-10-27 253 | ================== 254 | 255 | **fixes** 256 | * [[`a7778e5`](http://github.com/eggjs/egg-multipart/commit/a7778e58f603c5efe298c8a651356d203afefed0)] - fix: fileSize typo (#10) (tangyao <<2001-wms@163.com>>) 257 | 258 | **others** 259 | * [[`f95e322`](http://github.com/eggjs/egg-multipart/commit/f95e32287570f8f79de3061abfdfcbc93823f44f)] - docs: add more example (#14) (Haoliang Gao <>) 260 | * [[`b0785e3`](http://github.com/eggjs/egg-multipart/commit/b0785e34bb68b18af0d9f50bc3bf40cb91987391)] - docs: s/extention/extension (#13) (Sen Yang <>) 261 | * [[`d67fcf5`](http://github.com/eggjs/egg-multipart/commit/d67fcf5b64d0252345e04325c170e14786bc55a4)] - test: improve code coverage (#12) (fengmk2 <>) 262 | * [[`c8b77df`](http://github.com/eggjs/egg-multipart/commit/c8b77dfa9ad44dace89ef62531f182a4960843f6)] - test: fix failing test (#11) (Haoliang Gao <>) 263 | 264 | 1.5.0 / 2017-06-09 265 | ================== 266 | 267 | * refactor: always log when exceed limt (#9) 268 | 269 | 1.4.0 / 2017-05-18 270 | ================== 271 | 272 | * feat: Add upper case extname support (#7) 273 | 274 | 1.3.0 / 2017-04-21 275 | ================== 276 | 277 | * feat: whitelist support fn && english readme (#6) 278 | 279 | 1.2.0 / 2017-03-18 280 | ================== 281 | 282 | * feat: should emit stream error event when file too large (#5) 283 | * deps: upgrade all dev deps (#4) 284 | 285 | 1.1.0 / 2017-02-08 286 | ================== 287 | 288 | * feat: getFileStream return promise (#3) 289 | 290 | 1.0.0 / 2016-08-02 291 | ================== 292 | 293 | * init version 294 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # @eggjs/multipart 2 | 3 | [![NPM version][npm-image]][npm-url] 4 | [![Node.js CI](https://github.com/eggjs/multipart/actions/workflows/nodejs.yml/badge.svg)](https://github.com/eggjs/multipart/actions/workflows/nodejs.yml) 5 | [![Test coverage][codecov-image]][codecov-url] 6 | [![Known Vulnerabilities][snyk-image]][snyk-url] 7 | [![npm download][download-image]][download-url] 8 | [![PRs Welcome](https://img.shields.io/badge/PRs-welcome-brightgreen.svg?style=flat-square)](https://makeapullrequest.com) 9 | ![CodeRabbit Pull Request Reviews](https://img.shields.io/coderabbit/prs/github/eggjs/multipart) 10 | 11 | [npm-image]: https://img.shields.io/npm/v/@eggjs/multipart.svg?style=flat-square 12 | [npm-url]: https://npmjs.org/package/@eggjs/multipart 13 | [codecov-image]: https://codecov.io/github/eggjs/multipart/coverage.svg?branch=master 14 | [codecov-url]: https://codecov.io/github/eggjs/multipart?branch=master 15 | [snyk-image]: https://snyk.io/test/npm/@eggjs/multipart/badge.svg?style=flat-square 16 | [snyk-url]: https://snyk.io/test/npm/@eggjs/multipart 17 | [download-image]: https://img.shields.io/npm/dm/@eggjs/multipart.svg?style=flat-square 18 | [download-url]: https://npmjs.org/package/@eggjs/multipart 19 | 20 | Use [co-busboy](https://github.com/cojs/busboy) to upload file by streaming and 21 | process it without save to disk(using the `stream` mode). 22 | 23 | Just use `ctx.multipart()` to got file stream, then pass to image processing module such as `gm` or upload to cloud storage such as `oss`. 24 | 25 | ## Whitelist of file extensions 26 | 27 | For security, if uploading file extension is not in white list, will response as `400 Bad request`. 28 | 29 | Default Whitelist: 30 | 31 | ```js 32 | const whitelist = [ 33 | // images 34 | '.jpg', '.jpeg', // image/jpeg 35 | '.png', // image/png, image/x-png 36 | '.gif', // image/gif 37 | '.bmp', // image/bmp 38 | '.wbmp', // image/vnd.wap.wbmp 39 | '.webp', 40 | '.tif', 41 | '.psd', 42 | // text 43 | '.svg', 44 | '.js', '.jsx', 45 | '.json', 46 | '.css', '.less', 47 | '.html', '.htm', 48 | '.xml', 49 | // tar 50 | '.zip', 51 | '.gz', '.tgz', '.gzip', 52 | // video 53 | '.mp3', 54 | '.mp4', 55 | '.avi', 56 | ]; 57 | ``` 58 | 59 | ### fileSize 60 | 61 | The default fileSize that multipart can accept is `10mb`. if you upload a large file, you should specify this config. 62 | 63 | ```js 64 | // config/config.default.js 65 | exports.multipart = { 66 | fileSize: '50mb', 67 | }; 68 | ``` 69 | 70 | ### Custom Config 71 | 72 | Developer can custom additional file extensions: 73 | 74 | ```js 75 | // config/config.default.js 76 | exports.multipart = { 77 | // will append to whilelist 78 | fileExtensions: [ 79 | '.foo', 80 | '.apk', 81 | ], 82 | }; 83 | ``` 84 | 85 | Can also **override** built-in whitelist, such as only allow png: 86 | 87 | ```js 88 | // config/config.default.js 89 | exports.multipart = { 90 | whitelist: [ 91 | '.png', 92 | ], 93 | }; 94 | ``` 95 | 96 | Or by function: 97 | 98 | ```js 99 | exports.multipart = { 100 | whitelist: (filename) => [ '.png' ].includes(path.extname(filename) || '') 101 | }; 102 | ``` 103 | 104 | **Note: if define `whitelist`, then `fileExtensions` will be ignored.** 105 | 106 | ## Examples 107 | 108 | More examples please follow: 109 | 110 | - [Handle multipart request in `stream` mode](https://github.com/eggjs/examples/tree/master/multipart) 111 | - [Handle multipart request in `file` mode](https://github.com/eggjs/examples/tree/master/multipart-file-mode) 112 | 113 | ## `file` mode: the easy way 114 | 115 | If you don't know the [Node.js Stream](https://nodejs.org/dist/latest-v18.x/docs/api/stream.html) work, 116 | maybe you should use the `file` mode to get started. 117 | 118 | The usage very similar to [bodyParser](https://eggjs.org/en/basics/controller.html#body). 119 | 120 | - `ctx.request.body`: Get all the multipart fields and values, except `file`. 121 | - `ctx.request.files`: Contains all `file` from the multipart request, it's an Array object. 122 | 123 | **WARNING: you should remove the temporary upload files after you use it**, 124 | the `async ctx.cleanupRequestFiles()` method will be very helpful. 125 | 126 | ### Enable `file` mode on config 127 | 128 | You need to set `config.multipart.mode = 'file'` to enable `file` mode: 129 | 130 | ```js 131 | // config/config.default.js 132 | exports.multipart = { 133 | mode: 'file', 134 | }; 135 | ``` 136 | 137 | 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. 138 | 139 | ```js 140 | config.multipart = { 141 | mode: 'file', 142 | tmpdir: path.join(os.tmpdir(), 'egg-multipart-tmp', appInfo.name), 143 | cleanSchedule: { 144 | // run tmpdir clean job on every day 04:30 am 145 | // cron style see https://github.com/eggjs/egg-schedule#cron-style-scheduling 146 | cron: '0 30 4 * * *', 147 | disable: false, 148 | }, 149 | }; 150 | ``` 151 | 152 | Default will use the last field which has same name, if need the all fields value, please set `allowArrayField` in config. 153 | 154 | ```js 155 | // config/config.default.js 156 | exports.multipart = { 157 | mode: 'file', 158 | allowArrayField: true, 159 | }; 160 | ``` 161 | 162 | ### Upload One File 163 | 164 | ```html 165 |
166 | title: 167 | file: 168 | 169 |
170 | ``` 171 | 172 | Controller which hanlder `POST /upload`: 173 | 174 | ```js 175 | // app/controller/upload.js 176 | const Controller = require('egg').Controller; 177 | const fs = require('mz/fs'); 178 | 179 | module.exports = class extends Controller { 180 | async upload() { 181 | const { ctx } = this; 182 | const file = ctx.request.files[0]; 183 | const name = 'egg-multipart-test/' + path.basename(file.filename); 184 | let result; 185 | try { 186 | // process file or upload to cloud storage 187 | result = await ctx.oss.put(name, file.filepath); 188 | } finally { 189 | // remove tmp files and don't block the request's response 190 | // cleanupRequestFiles won't throw error even remove file io error happen 191 | ctx.cleanupRequestFiles(); 192 | // remove tmp files before send response 193 | // await ctx.cleanupRequestFiles(); 194 | } 195 | 196 | ctx.body = { 197 | url: result.url, 198 | // get all field values 199 | requestBody: ctx.request.body, 200 | }; 201 | } 202 | }; 203 | ``` 204 | 205 | ### Upload Multiple Files 206 | 207 | ```html 208 |
209 | title: 210 | file1: 211 | file2: 212 | 213 |
214 | ``` 215 | 216 | Controller which hanlder `POST /upload`: 217 | 218 | ```js 219 | // app/controller/upload.js 220 | const Controller = require('egg').Controller; 221 | const fs = require('mz/fs'); 222 | 223 | module.exports = class extends Controller { 224 | async upload() { 225 | const { ctx } = this; 226 | console.log(ctx.request.body); 227 | console.log('got %d files', ctx.request.files.length); 228 | for (const file of ctx.request.files) { 229 | console.log('field: ' + file.fieldname); 230 | console.log('filename: ' + file.filename); 231 | console.log('encoding: ' + file.encoding); 232 | console.log('mime: ' + file.mime); 233 | console.log('tmp filepath: ' + file.filepath); 234 | let result; 235 | try { 236 | // process file or upload to cloud storage 237 | result = await ctx.oss.put('egg-multipart-test/' + file.filename, file.filepath); 238 | } finally { 239 | // remove tmp files and don't block the request's response 240 | // cleanupRequestFiles won't throw error even remove file io error happen 241 | ctx.cleanupRequestFiles([ file ]); 242 | } 243 | console.log(result); 244 | } 245 | } 246 | }; 247 | ``` 248 | 249 | ## `stream` mode: the hard way 250 | 251 | If you're well-known about know the Node.js Stream work, you should use the `stream` mode. 252 | 253 | ### Use with `for await...of` 254 | 255 | ```html 256 |
257 | title: 258 | file1: 259 | file2: 260 | 261 |
262 | ``` 263 | 264 | Controller which hanlder `POST /upload`: 265 | 266 | ```js 267 | // app/controller/upload.js 268 | const { Controller } = require('egg'); 269 | const fs = require('fs'); 270 | const stream = require('stream'); 271 | const util = require('util'); 272 | const { randomUUID } = require('crypto'); 273 | const pipeline = util.promisify(stream.pipeline); 274 | 275 | module.exports = class UploadController extends Controller { 276 | async upload() { 277 | const parts = this.ctx.multipart(); 278 | const fields = {}; 279 | const files = {}; 280 | 281 | for await (const part of parts) { 282 | if (Array.isArray(part)) { 283 | // fields 284 | console.log('field: ' + part[0]); 285 | console.log('value: ' + part[1]); 286 | } else { 287 | // otherwise, it's a stream 288 | const { filename, fieldname, encoding, mime } = part; 289 | 290 | console.log('field: ' + fieldname); 291 | console.log('filename: ' + filename); 292 | console.log('encoding: ' + encoding); 293 | console.log('mime: ' + mime); 294 | 295 | // how to handler? 296 | // 1. save to tmpdir with pipeline 297 | // 2. or send to oss 298 | // 3. or just consume it with another for await 299 | 300 | // WARNING: You should almost never use the origin filename as it could contain malicious input. 301 | const targetPath = path.join(os.tmpdir(), randomUUID() + path.extname(filename)); 302 | await pipeline(part, createWriteStream(targetPath)); // use `pipeline` not `pipe` 303 | } 304 | } 305 | 306 | this.ctx.body = 'ok'; 307 | } 308 | }; 309 | ``` 310 | 311 | ### Upload One File (DEPRECATED) 312 | 313 | You can got upload stream by `ctx.getFileStream*()`. 314 | 315 | ```html 316 |
317 | title: 318 | file: 319 | 320 |
321 | ``` 322 | 323 | Controller which handler `POST /upload`: 324 | 325 | ```js 326 | // app/controller/upload.js 327 | const path = require('node:path'); 328 | const { sendToWormhole } = require('stream-wormhole'); 329 | const { Controller } = require('egg'); 330 | 331 | module.exports = class extends Controller { 332 | async upload() { 333 | const { ctx } = this; 334 | // file not exists will response 400 error 335 | const stream = await ctx.getFileStream(); 336 | const name = 'egg-multipart-test/' + path.basename(stream.filename); 337 | // process file or upload to cloud storage 338 | const result = await ctx.oss.put(name, stream); 339 | 340 | ctx.body = { 341 | url: result.url, 342 | // process form fields by `stream.fields` 343 | fields: stream.fields, 344 | }; 345 | } 346 | 347 | async uploadNotRequiredFile() { 348 | const { ctx } = this; 349 | // file not required 350 | const stream = await ctx.getFileStream({ requireFile: false }); 351 | let result; 352 | if (stream.filename) { 353 | const name = 'egg-multipart-test/' + path.basename(stream.filename); 354 | // process file or upload to cloud storage 355 | const result = await ctx.oss.put(name, stream); 356 | } else { 357 | // must consume the empty stream 358 | await sendToWormhole(stream); 359 | } 360 | 361 | ctx.body = { 362 | url: result && result.url, 363 | // process form fields by `stream.fields` 364 | fields: stream.fields, 365 | }; 366 | } 367 | }; 368 | ``` 369 | 370 | ### Upload Multiple Files (DEPRECATED) 371 | 372 | ```html 373 |
374 | title: 375 | file1: 376 | file2: 377 | 378 |
379 | ``` 380 | 381 | Controller which hanlder `POST /upload`: 382 | 383 | ```js 384 | // app/controller/upload.js 385 | const Controller = require('egg').Controller; 386 | 387 | module.exports = class extends Controller { 388 | async upload() { 389 | const { ctx } = this; 390 | const parts = ctx.multipart(); 391 | let part; 392 | while ((part = await parts()) != null) { 393 | if (part.length) { 394 | // arrays are busboy fields 395 | console.log('field: ' + part[0]); 396 | console.log('value: ' + part[1]); 397 | console.log('valueTruncated: ' + part[2]); 398 | console.log('fieldnameTruncated: ' + part[3]); 399 | } else { 400 | if (!part.filename) { 401 | // user click `upload` before choose a file, 402 | // `part` will be file stream, but `part.filename` is empty 403 | // must handler this, such as log error. 404 | continue; 405 | } 406 | // otherwise, it's a stream 407 | console.log('field: ' + part.fieldname); 408 | console.log('filename: ' + part.filename); 409 | console.log('encoding: ' + part.encoding); 410 | console.log('mime: ' + part.mime); 411 | const result = await ctx.oss.put('egg-multipart-test/' + part.filename, part); 412 | console.log(result); 413 | } 414 | } 415 | console.log('and we are done parsing the form!'); 416 | } 417 | }; 418 | ``` 419 | 420 | ### Support `file` and `stream` mode in the same time 421 | 422 | If the default `mode` is `stream`, use the `fileModeMatch` options to match the request urls switch to `file` mode. 423 | 424 | ```js 425 | config.multipart = { 426 | mode: 'stream', 427 | // let POST /upload_file request use the file mode, other requests use the stream mode. 428 | fileModeMatch: /^\/upload_file$/, 429 | // or glob 430 | // fileModeMatch: '/upload_file', 431 | }; 432 | ``` 433 | 434 | NOTICE: `fileModeMatch` options only work on `stream` mode. 435 | 436 | ## License 437 | 438 | [MIT](LICENSE) 439 | 440 | ## Contributors 441 | 442 | [![Contributors](https://contrib.rocks/image?repo=eggjs/multipart)](https://github.com/eggjs/multipart/graphs/contributors) 443 | 444 | Made with [contributors-img](https://contrib.rocks). 445 | -------------------------------------------------------------------------------- /test/file-mode.test.ts: -------------------------------------------------------------------------------- 1 | import assert from 'node:assert'; 2 | import path from 'node:path'; 3 | import fs from 'node:fs/promises'; 4 | import { scheduler } from 'node:timers/promises'; 5 | import { fileURLToPath } from 'node:url'; 6 | import { mm, mock, MockApplication } from '@eggjs/mock'; 7 | import dayjs from 'dayjs'; 8 | import formstream from 'formstream'; 9 | import urllib from 'urllib'; 10 | 11 | const __filename = fileURLToPath(import.meta.url); 12 | const __dirname = path.dirname(__filename); 13 | 14 | describe('test/file-mode.test.ts', () => { 15 | let app: MockApplication; 16 | let server: any; 17 | let host: string; 18 | before(() => { 19 | app = mm.app({ 20 | baseDir: 'apps/file-mode', 21 | }); 22 | return app.ready(); 23 | }); 24 | before(() => { 25 | server = app.listen(); 26 | host = 'http://127.0.0.1:' + server.address().port; 27 | }); 28 | after(() => { 29 | return fs.rm(app.config.multipart.tmpdir, { force: true, recursive: true }); 30 | }); 31 | after(() => app.close()); 32 | after(() => server.close()); 33 | beforeEach(() => app.mockCsrf()); 34 | afterEach(mm.restore); 35 | 36 | it('should ignore non multipart request', async () => { 37 | const res = await app.httpRequest() 38 | .post('/upload') 39 | .send({ 40 | foo: 'bar', 41 | n: 1, 42 | }); 43 | assert(res.status === 200); 44 | assert.deepStrictEqual(res.body, { 45 | body: { 46 | foo: 'bar', 47 | n: 1, 48 | }, 49 | }); 50 | }); 51 | 52 | it('should upload', async () => { 53 | const form = formstream(); 54 | form.field('foo', 'fengmk2').field('love', 'egg'); 55 | form.file('file1', __filename, 'foooooooo.js'); 56 | form.file('file2', __filename); 57 | // will ignore empty file 58 | form.buffer('file3', Buffer.from(''), '', 'application/octet-stream'); 59 | form.file('bigfile', path.join(__dirname, 'fixtures', 'bigfile.js')); 60 | // other form fields 61 | form.field('work', 'with Node.js'); 62 | 63 | const headers = form.headers(); 64 | const res = await urllib.request(host + '/upload', { 65 | method: 'POST', 66 | headers, 67 | stream: form as any, 68 | }); 69 | 70 | assert(res.status === 200); 71 | const data = JSON.parse(res.data); 72 | assert.deepStrictEqual(data.body, { foo: 'fengmk2', love: 'egg', work: 'with Node.js' }); 73 | assert(data.files.length === 3); 74 | assert(data.files[0].field === 'file1'); 75 | assert.equal(data.files[0].filename, 'foooooooo.js'); 76 | assert(data.files[0].encoding === '7bit'); 77 | assert(data.files[0].mime === 'application/javascript'); 78 | assert(data.files[0].filepath.startsWith(app.config.multipart.tmpdir)); 79 | 80 | assert(data.files[1].field === 'file2'); 81 | assert(data.files[1].fieldname === 'file2'); 82 | assert.equal(data.files[1].filename, 'file-mode.test.ts'); 83 | assert(data.files[1].encoding === '7bit'); 84 | assert(data.files[1].transferEncoding === '7bit'); 85 | assert.equal(data.files[1].mime, 'video/mp2t'); 86 | assert.equal(data.files[1].mimeType, 'video/mp2t'); 87 | assert(data.files[1].filepath.startsWith(app.config.multipart.tmpdir)); 88 | 89 | assert(data.files[2].field === 'bigfile'); 90 | assert.equal(data.files[2].filename, 'bigfile.js'); 91 | assert(data.files[2].encoding === '7bit'); 92 | assert.equal(data.files[2].mime, 'application/javascript'); 93 | assert(data.files[2].filepath.startsWith(app.config.multipart.tmpdir)); 94 | }); 95 | 96 | it('should 200 when file size just 10mb', async () => { 97 | const form = formstream(); 98 | form.buffer('file', Buffer.alloc(10 * 1024 * 1024 - 1), '10mb.js', 'application/octet-stream'); 99 | const headers = form.headers(); 100 | const res = await urllib.request(host + '/upload', { 101 | method: 'POST', 102 | headers, 103 | stream: form as any, 104 | }); 105 | assert(res.status === 200); 106 | const data = JSON.parse(res.data); 107 | assert.equal(data.files.length, 1); 108 | assert.equal(data.files[0].field, 'file'); 109 | assert.equal(data.files[0].filename, '10mb.js'); 110 | assert.equal(data.files[0].encoding, '7bit'); 111 | assert.equal(data.files[0].mime, 'application/octet-stream'); 112 | assert(data.files[0].filepath.startsWith(app.config.multipart.tmpdir)); 113 | const stat = await fs.stat(data.files[0].filepath); 114 | assert.equal(stat.size, 10 * 1024 * 1024 - 1); 115 | }); 116 | 117 | it('should 200 when field size just 100kb', async () => { 118 | const form = formstream(); 119 | form.field('foo', 'a'.repeat(100 * 1024 - 1)); 120 | 121 | const headers = form.headers(); 122 | const res = await urllib.request(host + '/upload', { 123 | method: 'POST', 124 | headers, 125 | stream: form as any, 126 | }); 127 | 128 | assert(res.status === 200); 129 | const data = JSON.parse(res.data); 130 | assert(data.body.foo === 'a'.repeat(100 * 1024 - 1)); 131 | }); 132 | 133 | it('should 200 when request fields equal 10', async () => { 134 | const form = formstream(); 135 | for (let i = 0; i < 10; i++) { 136 | form.field('foo' + i, 'a' + i); 137 | } 138 | 139 | const headers = form.headers(); 140 | const res = await urllib.request(host + '/upload', { 141 | method: 'POST', 142 | headers, 143 | stream: form as any, 144 | }); 145 | 146 | assert(res.status === 200); 147 | const data = JSON.parse(res.data); 148 | assert(Object.keys(data.body).length === 10); 149 | }); 150 | 151 | it('should 200 when request files equal 10', async () => { 152 | const form = formstream(); 153 | for (let i = 0; i < 10; i++) { 154 | form.file('foo' + i, __filename); 155 | } 156 | 157 | const headers = form.headers(); 158 | const res = await urllib.request(host + '/upload', { 159 | method: 'POST', 160 | headers, 161 | stream: form as any, 162 | }); 163 | 164 | assert(res.status === 200); 165 | const data = JSON.parse(res.data); 166 | assert(data.files.length === 10); 167 | }); 168 | 169 | it('should handle non-ascii filename', async () => { 170 | const file = path.join(__dirname, 'fixtures', '中文名.js'); 171 | const res = await app.httpRequest() 172 | .post('/upload') 173 | .attach('file', file); 174 | assert(res.status === 200); 175 | assert(res.body.files[0].filename === '中文名.js'); 176 | }); 177 | 178 | it('should throw error when request fields limit', async () => { 179 | const form = formstream(); 180 | for (let i = 0; i < 11; i++) { 181 | form.field('foo' + i, 'a' + i); 182 | } 183 | 184 | const headers = form.headers(); 185 | const res = await urllib.request(host + '/upload', { 186 | method: 'POST', 187 | headers, 188 | stream: form as any, 189 | }); 190 | 191 | assert(res.status === 413); 192 | assert.match(res.data.toString(), /Error: Reach fields limit/); 193 | }); 194 | 195 | it('should throw error when request files limit', async () => { 196 | const form = formstream(); 197 | form.setMaxListeners(11); 198 | for (let i = 0; i < 11; i++) { 199 | form.file('foo' + i, __filename); 200 | } 201 | 202 | const headers = form.headers(); 203 | const res = await urllib.request(host + '/upload', { 204 | method: 'POST', 205 | headers, 206 | stream: form as any, 207 | }); 208 | 209 | assert(res.status === 413); 210 | assert.match(res.data.toString(), /Error: Reach files limit/); 211 | }); 212 | 213 | it('should throw error when request field size limit', async () => { 214 | const form = formstream(); 215 | form.field('foo', 'a'.repeat(100 * 1024 + 1)); 216 | 217 | const headers = form.headers(); 218 | const res = await urllib.request(host + '/upload', { 219 | method: 'POST', 220 | headers, 221 | stream: form as any, 222 | }); 223 | 224 | assert(res.status === 413); 225 | assert.match(res.data.toString(), /Error: Reach fieldSize limit/); 226 | }); 227 | 228 | // fieldNameSize is TODO on busboy 229 | // see https://github.com/mscdex/busboy/blob/v0.3.1/lib/types/multipart.js#L5 230 | it.skip('should throw error when request field name size limit', async () => { 231 | const form = formstream(); 232 | form.field('b'.repeat(101), 'a'); 233 | 234 | const headers = form.headers(); 235 | const res = await urllib.request(host + '/upload', { 236 | method: 'POST', 237 | headers, 238 | stream: form as any, 239 | }); 240 | 241 | assert(res.status === 413); 242 | assert.match(res.data.toString(), /Error: Reach fieldSize limit/); 243 | }); 244 | 245 | it('should throw error when request file size limit', async () => { 246 | const form = formstream(); 247 | form.field('foo', 'fengmk2').field('love', 'egg'); 248 | form.file('file1', __filename, 'foooooooo.js'); 249 | form.file('file2', __filename); 250 | form.buffer('file3', Buffer.alloc(10 * 1024 * 1024 + 1), 'toobigfile.js', 'application/octet-stream'); 251 | form.file('bigfile', path.join(__dirname, 'fixtures', 'bigfile.js')); 252 | // other form fields 253 | const headers = form.headers(); 254 | const res = await urllib.request(host + '/upload', { 255 | method: 'POST', 256 | headers, 257 | stream: form as any, 258 | }); 259 | 260 | assert(res.status === 413); 261 | assert.match(res.data.toString(), /Error: Reach fileSize limit/); 262 | }); 263 | 264 | it('should throw error when file name invalid', async () => { 265 | const form = formstream(); 266 | form.field('foo', 'fengmk2').field('love', 'egg'); 267 | form.file('file1', __filename, 'foooooooo.js.rar'); 268 | form.file('file2', __filename); 269 | // other form fields 270 | form.field('work', 'with Node.js'); 271 | 272 | const headers = form.headers(); 273 | const res = await urllib.request(host + '/upload', { 274 | method: 'POST', 275 | headers, 276 | stream: form as any, 277 | }); 278 | 279 | assert(res.status === 400); 280 | assert(res.data.toString().includes('Error: Invalid filename: foooooooo.js.rar')); 281 | }); 282 | 283 | it('should throw error on multipart() invoke twice', async () => { 284 | const form = formstream(); 285 | form.field('foo', 'fengmk2').field('love', 'egg'); 286 | form.file('file2', __filename); 287 | // other form fields 288 | form.field('work', 'with Node.js'); 289 | 290 | const headers = form.headers(); 291 | const res = await urllib.request(host + '/upload?call_multipart_twice=1', { 292 | method: 'POST', 293 | headers, 294 | stream: form as any, 295 | }); 296 | 297 | assert.equal(res.status, 500); 298 | assert(res.data.toString().includes('the multipart request can\'t be consumed twice')); 299 | }); 300 | 301 | it('should use cleanupRequestFiles after request end', async () => { 302 | const form = formstream(); 303 | form.field('foo', 'fengmk2').field('love', 'egg'); 304 | form.file('file2', __filename); 305 | // other form fields 306 | form.field('work', 'with Node.js'); 307 | 308 | const headers = form.headers(); 309 | const res = await urllib.request(host + '/upload?cleanup=true', { 310 | method: 'POST', 311 | headers, 312 | stream: form as any, 313 | }); 314 | 315 | assert(res.status === 200); 316 | const data = JSON.parse(res.data); 317 | assert(data.files.length === 1); 318 | }); 319 | 320 | it('should use cleanupRequestFiles in async way', async () => { 321 | const form = formstream(); 322 | form.field('foo', 'fengmk2').field('love', 'egg'); 323 | form.file('file2', __filename); 324 | // other form fields 325 | form.field('work', 'with Node.js'); 326 | 327 | const headers = form.headers(); 328 | const res = await urllib.request(host + '/upload?async_cleanup=true', { 329 | method: 'POST', 330 | headers, 331 | stream: form as any, 332 | }); 333 | 334 | assert(res.status === 200); 335 | const data = JSON.parse(res.data); 336 | assert(data.files.length === 1); 337 | }); 338 | 339 | describe('schedule/clean_tmpdir', () => { 340 | it('should register clean_tmpdir schedule', async () => { 341 | // [egg-schedule]: register schedule /hello/egg-multipart/app/schedule/clean_tmpdir.js 342 | const logger = app.loggers.scheduleLogger; 343 | const content = await fs.readFile(logger.options.file, 'utf8'); 344 | assert.match(content, /\[@eggjs\/schedule\]: register schedule .+clean_tmpdir\.ts/); 345 | }); 346 | 347 | it('should remove nothing', async () => { 348 | app.mockLog(); 349 | await app.runSchedule(path.join(__dirname, '../src/app/schedule/clean_tmpdir')); 350 | await scheduler.wait(1000); 351 | app.expectLog('[@eggjs/multipart:CleanTmpdir] start clean tmpdir: "', 'coreLogger'); 352 | app.expectLog('[@eggjs/multipart:CleanTmpdir] end', 'coreLogger'); 353 | }); 354 | 355 | it('should remove old dirs', async () => { 356 | const oldDirs = [ 357 | path.join(app.config.multipart.tmpdir, dayjs().subtract(1, 'years').format('YYYY/MM/DD/HH')), 358 | path.join(app.config.multipart.tmpdir, dayjs().subtract(1, 'months').format('YYYY/MM/DD/HH')), 359 | path.join(app.config.multipart.tmpdir, dayjs().subtract(2, 'months').format('YYYY/MM/DD/HH')), 360 | path.join(app.config.multipart.tmpdir, dayjs().subtract(3, 'months').format('YYYY/MM/DD/HH')), 361 | path.join(app.config.multipart.tmpdir, dayjs().subtract(1, 'days').format('YYYY/MM/DD/HH')), 362 | path.join(app.config.multipart.tmpdir, dayjs().subtract(7, 'days').format('YYYY/MM/DD/HH')), 363 | ]; 364 | const shouldKeepDirs = [ 365 | path.join(app.config.multipart.tmpdir, dayjs().subtract(2, 'years').format('YYYY/MM/DD/HH')), 366 | path.join(app.config.multipart.tmpdir, dayjs().format('YYYY/MM/DD/HH')), 367 | ]; 368 | const currentMonth = new Date().getMonth(); 369 | const fourMonthBefore = path.join(app.config.multipart.tmpdir, dayjs().subtract(4, 'months').format('YYYY/MM/DD/HH')); 370 | if (currentMonth < 4) { 371 | // if current month is less than April, four months before should be last year. 372 | oldDirs.push(fourMonthBefore); 373 | } else { 374 | shouldKeepDirs.push(fourMonthBefore); 375 | } 376 | await Promise.all(oldDirs.map(dir => fs.mkdir(dir, { recursive: true }))); 377 | await Promise.all(shouldKeepDirs.map(dir => fs.mkdir(dir, { recursive: true }))); 378 | 379 | await Promise.all(oldDirs.map(dir => { 380 | // create files 381 | return fs.writeFile(path.join(dir, Date.now() + ''), Date()); 382 | })); 383 | 384 | app.mockLog(); 385 | await app.runSchedule(path.join(__dirname, '../src/app/schedule/clean_tmpdir')); 386 | for (const dir of oldDirs) { 387 | const exists = await fs.access(dir).then(() => true).catch(() => false); 388 | assert(!exists, dir); 389 | } 390 | for (const dir of shouldKeepDirs) { 391 | const exists = await fs.access(dir).then(() => true).catch(() => false); 392 | assert(exists, dir); 393 | } 394 | app.expectLog('[@eggjs/multipart:CleanTmpdir] removing tmpdir: "', 'coreLogger'); 395 | app.expectLog('[@eggjs/multipart:CleanTmpdir:success] tmpdir: "', 'coreLogger'); 396 | }); 397 | }); 398 | 399 | it('should keep last field', async () => { 400 | mock(app.config.multipart, 'allowArrayField', false); 401 | const form = formstream(); 402 | form.field('foo', 'fengmk2') 403 | .field('foo', 'egg'); 404 | form.file('file2', __filename); 405 | 406 | const headers = form.headers(); 407 | const res = await urllib.request(host + '/upload', { 408 | method: 'POST', 409 | headers, 410 | stream: form as any, 411 | dataType: 'json', 412 | }); 413 | assert.deepStrictEqual(res.data.body, { foo: 'egg' }); 414 | }); 415 | 416 | it('should allow array field', async () => { 417 | mock(app.config.multipart, 'allowArrayField', true); 418 | const form = formstream(); 419 | form.field('foo', 'fengmk2') 420 | .field('foo', 'like') 421 | .field('foo', 'egg'); 422 | form.file('file2', __filename); 423 | 424 | const headers = form.headers(); 425 | const res = await urllib.request(host + '/upload', { 426 | method: 'POST', 427 | headers, 428 | stream: form as any, 429 | dataType: 'json', 430 | }); 431 | assert.deepStrictEqual(res.data.body, { foo: [ 'fengmk2', 'like', 'egg' ] }); 432 | }); 433 | }); 434 | -------------------------------------------------------------------------------- /test/multipart.test.ts: -------------------------------------------------------------------------------- 1 | import path from 'node:path'; 2 | import fs from 'node:fs/promises'; 3 | import assert from 'node:assert'; 4 | import { fileURLToPath } from 'node:url'; 5 | import { scheduler } from 'node:timers/promises'; 6 | import formstream from 'formstream'; 7 | import urllib from 'urllib'; 8 | import { mm, MockApplication } from '@eggjs/mock'; 9 | 10 | const __filename = fileURLToPath(import.meta.url); 11 | const __dirname = path.dirname(__filename); 12 | 13 | describe('test/multipart.test.ts', () => { 14 | describe('multipart', () => { 15 | let app: MockApplication; 16 | let server: any; 17 | let host: string; 18 | before(async () => { 19 | app = mm.app({ 20 | baseDir: 'apps/multipart', 21 | }); 22 | await app.ready(); 23 | server = app.listen(); 24 | host = 'http://127.0.0.1:' + server.address().port; 25 | }); 26 | after(() => app.close()); 27 | after(() => server.close()); 28 | beforeEach(() => app.mockCsrf()); 29 | afterEach(mm.restore); 30 | 31 | it('should not has clean_tmpdir schedule', async () => { 32 | try { 33 | await app.runSchedule('clean_tmpdir'); 34 | throw new Error('should not run this'); 35 | } catch (err: any) { 36 | assert.equal(err.message, '[@eggjs/schedule] Cannot find schedule clean_tmpdir'); 37 | } 38 | }); 39 | 40 | it('should alway register clean_tmpdir schedule in stream mode', async () => { 41 | const logger = app.loggers.scheduleLogger; 42 | const content = await fs.readFile(logger.options.file, 'utf8'); 43 | assert.match(content, /\[@eggjs\/schedule\]: register schedule .+clean_tmpdir\.ts/); 44 | }); 45 | 46 | it('should upload with csrf', async () => { 47 | const form = formstream(); 48 | // form.file('file', filepath, filename); 49 | form.file('file', __filename); 50 | // other form fields 51 | form.field('foo', 'fengmk2').field('love', 'chair'); 52 | 53 | const headers = form.headers(); 54 | const res = await urllib.request(host + '/upload', { 55 | method: 'POST', 56 | headers, 57 | stream: form as any, 58 | }); 59 | 60 | assert.equal(res.status, 200); 61 | const data = JSON.parse(res.data); 62 | assert.equal(data.filename, 'multipart.test.ts'); 63 | }); 64 | 65 | it('should upload.json with ctoken', async () => { 66 | const form = formstream(); 67 | // form.file('file', filepath, filename); 68 | form.file('file', __filename); 69 | // other form fields 70 | form.field('foo', 'fengmk2').field('love', 'chair'); 71 | 72 | const headers = form.headers(); 73 | const res = await urllib.request(host + '/upload.json', { 74 | method: 'POST', 75 | headers, 76 | stream: form as any, 77 | }); 78 | 79 | assert.equal(res.status, 200); 80 | const data = JSON.parse(res.data); 81 | assert.equal(data.filename, 'multipart.test.ts'); 82 | }); 83 | 84 | it('should handle unread stream and return error response', async () => { 85 | const form = formstream(); 86 | // form.file('file', filepath, filename); 87 | form.file('file', __filename); 88 | // other form fields 89 | form.field('foo', 'fengmk2').field('love', 'chair'); 90 | 91 | const headers = form.headers(); 92 | const res = await urllib.request(host + '/upload?mock_stream_error=1', { 93 | method: 'POST', 94 | headers, 95 | stream: form as any, 96 | }); 97 | 98 | assert.match(res.data.toString(), /ENOENT:/); 99 | }); 100 | 101 | it('should auto consumed file stream on error throw', async () => { 102 | for (let i = 0; i < 10; i++) { 103 | const form = formstream(); 104 | form.file('file', path.join(__dirname, 'fixtures/bigfile.js')); 105 | 106 | const headers = form.headers(); 107 | const url = host + '/upload?mock_undefined_error=1'; 108 | const result = await urllib.request(url, { 109 | method: 'POST', 110 | headers, 111 | stream: form as any, 112 | dataType: 'json', 113 | }); 114 | 115 | assert(result.status === 500); 116 | const data = result.data; 117 | assert(data.message === 'part.foo is not a function'); 118 | await scheduler.wait(100); 119 | } 120 | }); 121 | 122 | it('should throw 400 when extname wrong', async () => { 123 | const form = formstream(); 124 | form.file('file', __filename, 'foo.rar'); 125 | const headers = form.headers(); 126 | const res = await urllib.request(host + '/upload.json', { 127 | method: 'POST', 128 | headers, 129 | stream: form as any, 130 | }); 131 | 132 | assert(res.status === 400); 133 | const data = JSON.parse(res.data); 134 | assert(data.message === 'Invalid filename: foo.rar'); 135 | }); 136 | 137 | it('should not throw 400 when file not speicified', async () => { 138 | const form = formstream(); 139 | // 模拟用户未选择文件点击了上传,这时 cotroller 是有 file stream 的,因为指定了 MIME application/octet-stream 140 | form.buffer('file', Buffer.from(''), '', 'application/octet-stream'); 141 | const headers = form.headers(); 142 | const res = await urllib.request(host + '/upload.json', { 143 | method: 'POST', 144 | headers, 145 | stream: form as any, 146 | }); 147 | 148 | assert(res.status === 200); 149 | const data = JSON.parse(res.data); 150 | assert(data.message === 'no file'); 151 | }); 152 | 153 | it('should not throw 400 when file stream empty', async () => { 154 | const form = formstream(); 155 | form.field('foo', 'bar'); 156 | // 模拟用户未选择文件点击了上传,这时 cotroller 是有 file stream 的,因为指定了 MIME application/octet-stream 157 | // form.buffer('file', Buffer.from(''), '', 'application/octet-stream'); 158 | const headers = form.headers(); 159 | const res = await urllib.request(host + '/upload.json', { 160 | method: 'POST', 161 | headers, 162 | stream: form as any, 163 | }); 164 | 165 | assert(res.status === 200); 166 | const data = JSON.parse(res.data); 167 | assert(data.message === 'no file'); 168 | }); 169 | 170 | it('should upload when extname speicified in fileExtensions', async () => { 171 | const form = formstream(); 172 | form.file('file', __filename, 'bar.foo'); 173 | const headers = form.headers(); 174 | const res = await urllib.request(host + '/upload.json', { 175 | method: 'POST', 176 | headers, 177 | stream: form as any, 178 | }); 179 | 180 | assert(res.status === 200); 181 | const data = JSON.parse(res.data); 182 | assert(data.filename === 'bar.foo'); 183 | }); 184 | 185 | it('should upload when extname speicified in fileExtensions and extname is in upper case', async () => { 186 | const form = formstream(); 187 | form.file('file', __filename, 'bar.BAR'); 188 | const headers = form.headers(); 189 | const res = await urllib.request(host + '/upload.json', { 190 | method: 'POST', 191 | headers, 192 | stream: form as any, 193 | }); 194 | 195 | assert(res.status === 200); 196 | const data = JSON.parse(res.data); 197 | assert(data.filename === 'bar.BAR'); 198 | }); 199 | 200 | it('should upload when extname speicified in fileExtensions and extname is missing dot', async () => { 201 | const form = formstream(); 202 | form.file('file', __filename, 'bar.abc'); 203 | const headers = form.headers(); 204 | const res = await urllib.request(host + '/upload.json', { 205 | method: 'POST', 206 | headers, 207 | stream: form as any, 208 | }); 209 | 210 | assert(res.status === 200); 211 | const data = JSON.parse(res.data); 212 | assert(data.filename === 'bar.abc'); 213 | }); 214 | 215 | it('should upload when extname is not speicified', async () => { 216 | const form = formstream(); 217 | form.file('file', __filename, 'bar'); 218 | const headers = form.headers(); 219 | const res = await urllib.request(host + '/upload.json', { 220 | method: 'POST', 221 | headers, 222 | stream: form as any, 223 | }); 224 | 225 | assert(res.status === 200); 226 | const data = JSON.parse(res.data); 227 | assert(data.filename === 'bar'); 228 | }); 229 | 230 | it('should 400 upload with wrong content-type', async () => { 231 | const res = await urllib.request(host + '/upload', { 232 | method: 'POST', 233 | }); 234 | 235 | assert(res.status === 400); 236 | assert(/Content-Type must be multipart/.test(res.data)); 237 | }); 238 | 239 | it('should 400 upload.json with wrong content-type', async () => { 240 | const res = await urllib.request(host + '/upload.json', { 241 | method: 'POST', 242 | dataType: 'json', 243 | }); 244 | 245 | assert(res.status === 400); 246 | assert(res.data.message === 'Content-Type must be multipart/*'); 247 | }); 248 | }); 249 | 250 | describe('whitelist', () => { 251 | let app: MockApplication; 252 | let server: any; 253 | let host: string; 254 | before(async () => { 255 | app = mm.app({ 256 | baseDir: 'apps/multipart-with-whitelist', 257 | }); 258 | await app.ready(); 259 | server = app.listen(); 260 | host = 'http://127.0.0.1:' + server.address().port; 261 | }); 262 | after(() => app.close()); 263 | after(() => server.close()); 264 | beforeEach(() => app.mockCsrf()); 265 | afterEach(mm.restore); 266 | 267 | it('should upload when extname speicified in whitelist', async () => { 268 | const form = formstream(); 269 | form.file('file', __filename, 'bar.whitelist'); 270 | const headers = form.headers(); 271 | const res = await urllib.request(host + '/upload.json', { 272 | method: 'POST', 273 | headers, 274 | stream: form as any, 275 | }); 276 | 277 | assert(res.status === 200); 278 | const data = JSON.parse(res.data); 279 | assert(data.filename === 'bar.whitelist'); 280 | }); 281 | 282 | it('should upload when extname speicified in whitelist and extname is in upper case', async () => { 283 | const form = formstream(); 284 | form.file('file', __filename, 'bar.WHITELIST'); 285 | const headers = form.headers(); 286 | const res = await urllib.request(host + '/upload.json', { 287 | method: 'POST', 288 | headers, 289 | stream: form as any, 290 | }); 291 | 292 | assert(res.status === 200); 293 | const data = JSON.parse(res.data); 294 | assert(data.filename === 'bar.WHITELIST'); 295 | }); 296 | 297 | 298 | it('should throw 400 when extname speicified in fileExtensions, but not in whitelist', async () => { 299 | const form = formstream(); 300 | form.file('file', __filename, 'foo.foo'); 301 | const headers = form.headers(); 302 | const res = await urllib.request(host + '/upload.json', { 303 | method: 'POST', 304 | headers, 305 | stream: form as any, 306 | }); 307 | 308 | assert(res.status === 400); 309 | const data = JSON.parse(res.data); 310 | assert(data.message === 'Invalid filename: foo.foo'); 311 | }); 312 | }); 313 | 314 | describe('whitelist-function', () => { 315 | let app: MockApplication; 316 | let server: any; 317 | let host: string; 318 | before(async () => { 319 | app = mm.app({ 320 | baseDir: 'apps/whitelist-function', 321 | }); 322 | await app.ready(); 323 | server = app.listen(); 324 | host = 'http://127.0.0.1:' + server.address().port; 325 | }); 326 | after(() => app.close()); 327 | after(() => server.close()); 328 | beforeEach(() => app.mockCsrf()); 329 | afterEach(mm.restore); 330 | 331 | it('should upload when extname pass whitelist function', async () => { 332 | const form = formstream(); 333 | form.file('file', __filename, 'bar'); 334 | const headers = form.headers(); 335 | const res = await urllib.request(host + '/upload.json', { 336 | method: 'POST', 337 | headers, 338 | stream: form as any, 339 | }); 340 | 341 | assert(res.status === 200); 342 | const data = JSON.parse(res.data); 343 | assert(data.filename === 'bar'); 344 | }); 345 | 346 | it('should throw 400 when extname not match whitelist function', async () => { 347 | const form = formstream(); 348 | form.file('file', __filename, 'foo.png'); 349 | const headers = form.headers(); 350 | const res = await urllib.request(host + '/upload.json', { 351 | method: 'POST', 352 | headers, 353 | stream: form as any, 354 | }); 355 | 356 | assert(res.status === 400); 357 | const data = JSON.parse(res.data); 358 | assert(data.message === 'Invalid filename: foo.png'); 359 | }); 360 | 361 | it('should throw 400 when whitelist function throw error', async () => { 362 | const form = formstream(); 363 | form.file('file', __filename, 'error'); 364 | const headers = form.headers(); 365 | const res = await urllib.request(host + '/upload.json', { 366 | method: 'POST', 367 | headers, 368 | stream: form as any, 369 | }); 370 | 371 | assert(res.status === 400); 372 | const data = JSON.parse(res.data); 373 | assert(data.message === 'mock checkExt error'); 374 | }); 375 | }); 376 | 377 | describe('upload one file', () => { 378 | let app: MockApplication; 379 | let server: any; 380 | let host: string; 381 | before(async () => { 382 | app = mm.app({ 383 | baseDir: 'apps/upload-one-file', 384 | }); 385 | await app.ready(); 386 | server = app.listen(); 387 | host = 'http://127.0.0.1:' + server.address().port; 388 | }); 389 | before(async () => { 390 | await app.httpRequest() 391 | .get('/upload') 392 | .expect(200); 393 | }); 394 | after(() => app.close()); 395 | after(() => server.close()); 396 | beforeEach(() => app.mockCsrf()); 397 | afterEach(mm.restore); 398 | 399 | it('should handle one upload file in simple way', async () => { 400 | const form = formstream(); 401 | form.field('foo', 'bar').field('[', 'toString').field(']', 'toString'); 402 | form.file('file', __filename); 403 | 404 | const headers = form.headers(); 405 | const url = host + '/upload'; 406 | const res = await urllib.request(url, { 407 | method: 'POST', 408 | headers, 409 | stream: form as any, 410 | dataType: 'json', 411 | }); 412 | 413 | const data = res.data; 414 | assert.deepEqual(data.fields, { 415 | '[': 'toString', 416 | ']': 'toString', 417 | foo: 'bar', 418 | }); 419 | assert(data.status === 200); 420 | assert(typeof data.name === 'string'); 421 | assert(data.url.includes('http://mockoss.com/egg-multipart-test/')); 422 | }); 423 | 424 | it('should handle one upload file in simple way with async function controller', async () => { 425 | const form = formstream(); 426 | form.file('file', __filename); 427 | 428 | const headers = form.headers(); 429 | const url = host + '/upload/async'; 430 | const res = await urllib.request(url, { 431 | method: 'POST', 432 | headers, 433 | stream: form as any, 434 | dataType: 'json', 435 | }); 436 | 437 | const data = res.data; 438 | assert.deepEqual(data.fields, {}); 439 | assert(data.status === 200); 440 | assert(typeof data.name === 'string'); 441 | assert(data.url.includes('http://mockoss.com/egg-multipart-test/')); 442 | }); 443 | 444 | it('should handle one upload file and all fields', async () => { 445 | const form = formstream(); 446 | form.field('f1', 'f1-value'); 447 | form.field('f2', 'f2-value-中文'); 448 | form.file('file', __filename); 449 | 450 | const headers = form.headers(); 451 | const url = host + '/upload'; 452 | const res = await urllib.request(url, { 453 | method: 'POST', 454 | headers, 455 | stream: form as any, 456 | dataType: 'json', 457 | }); 458 | 459 | const data = res.data; 460 | assert(res.status === 200); 461 | assert(data.status === 200); 462 | assert(typeof data.name === 'string'); 463 | assert(data.url.includes('http://mockoss.com/egg-multipart-test/')); 464 | assert.deepEqual(data.fields, { 465 | f1: 'f1-value', 466 | f2: 'f2-value-中文', 467 | }); 468 | }); 469 | 470 | it('should handle non-ascii filename', async () => { 471 | const file = path.join(__dirname, 'fixtures', '中文名.js'); 472 | const form = formstream(); 473 | form.file('file', file); 474 | 475 | const headers = form.headers(); 476 | const url = host + '/upload/async'; 477 | const res = await urllib.request(url, { 478 | method: 'POST', 479 | headers, 480 | stream: form as any, 481 | dataType: 'json', 482 | }); 483 | 484 | const data = res.data; 485 | assert(data.name.includes('中文名')); 486 | }); 487 | 488 | it('should 400 when no file upload', async () => { 489 | const form = formstream(); 490 | form.field('hi', 'ok'); 491 | 492 | const headers = form.headers(); 493 | const url = host + '/upload'; 494 | const res = await urllib.request(url, { 495 | method: 'POST', 496 | headers, 497 | stream: form as any, 498 | }); 499 | 500 | assert(res.status === 400); 501 | assert(res.data.toString().includes('Can\'t found upload file')); 502 | }); 503 | 504 | it('should no file upload and only fields', async () => { 505 | const form = formstream(); 506 | form.field('hi', 'ok'); 507 | form.field('hi2', 'ok2'); 508 | 509 | const headers = form.headers(); 510 | const url = host + '/upload/allowEmpty'; 511 | const res = await urllib.request(url, { 512 | method: 'POST', 513 | headers, 514 | stream: form as any, 515 | dataType: 'json', 516 | }); 517 | 518 | assert(res.status === 200); 519 | assert.deepEqual(res.data, { 520 | fields: { 521 | hi: 'ok', 522 | hi2: 'ok2', 523 | }, 524 | }); 525 | }); 526 | 527 | it('should 400 when no file speicified', async () => { 528 | const form = formstream(); 529 | form.buffer('file', Buffer.from(''), '', 'application/octet-stream'); 530 | const headers = form.headers(); 531 | const url = host + '/upload'; 532 | const res = await urllib.request(url, { 533 | method: 'POST', 534 | headers, 535 | stream: form as any, 536 | }); 537 | assert(res.status === 400); 538 | assert(res.data.toString().includes('Can\'t found upload file')); 539 | }); 540 | 541 | it('should auto consumed file stream on error throw', async () => { 542 | for (let i = 0; i < 10; i++) { 543 | const form = formstream(); 544 | form.file('file', path.join(__dirname, 'fixtures/bigfile.js')); 545 | 546 | const headers = form.headers(); 547 | const url = host + '/upload/async?foo=error'; 548 | const result = await urllib.request(url, { 549 | method: 'POST', 550 | headers, 551 | stream: form as any, 552 | dataType: 'json', 553 | }); 554 | 555 | assert(result.status === 500); 556 | const data = result.data; 557 | assert(data.message === 'stream.foo is not a function'); 558 | await scheduler.wait(100); 559 | } 560 | }); 561 | 562 | it('should file hit limits fileSize', async () => { 563 | const form = formstream(); 564 | form.buffer('file', Buffer.from('a'.repeat(1024 * 1024 * 100)), 'foo.js'); 565 | const headers = form.headers(); 566 | const url = host + '/upload/async?fileSize=100000'; 567 | const result = await urllib.request(url, { 568 | method: 'POST', 569 | headers, 570 | stream: form as any, 571 | dataType: 'json', 572 | }); 573 | 574 | assert(result.status === 413); 575 | const data = result.data; 576 | assert(data.message.includes('Request file too large')); 577 | }); 578 | 579 | it('should file hit limits fileSize (byte)', async () => { 580 | const form = formstream(); 581 | form.buffer('file', Buffer.alloc(1024 * 1024 * 100), 'foo.js'); 582 | 583 | const headers = form.headers(); 584 | const url = host + '/upload2'; 585 | const result = await urllib.request(url, { 586 | method: 'POST', 587 | headers, 588 | stream: form as any, 589 | dataType: 'json', 590 | }); 591 | 592 | assert(result.status === 413); 593 | const data = result.data; 594 | assert(data.message.includes('Request file too large')); 595 | }); 596 | }); 597 | 598 | describe('upload over fileSize limit', () => { 599 | let app: MockApplication; 600 | let server: any; 601 | let host: string; 602 | const bigfile = path.join(__dirname, 'big.js'); 603 | before(async () => { 604 | app = mm.app({ 605 | baseDir: 'apps/upload-limit', 606 | }); 607 | await app.ready(); 608 | await fs.writeFile(bigfile, Buffer.alloc(1024 * 1024 * 2)); 609 | server = app.listen(); 610 | host = 'http://127.0.0.1:' + server.address().port; 611 | await app.httpRequest() 612 | .get('/upload') 613 | .expect(200); 614 | }); 615 | after(async () => { 616 | await fs.rm(bigfile, { force: true }); 617 | server.close(); 618 | await app.close(); 619 | }); 620 | beforeEach(() => app.mockCsrf()); 621 | afterEach(mm.restore); 622 | 623 | it('should show error', async () => { 624 | const form = formstream(); 625 | form.field('foo', 'bar').field('[', 'toString').field(']', 'toString'); 626 | form.file('file', bigfile); 627 | 628 | const headers = form.headers(); 629 | const url = host + '/upload'; 630 | const res = await urllib.request(url, { 631 | method: 'POST', 632 | headers, 633 | stream: form as any, 634 | dataType: 'json', 635 | }); 636 | 637 | const data = res.data; 638 | assert.equal(res.status, 413); 639 | assert(data.message.includes('Request file too large')); 640 | const content = await fs.readFile(app.coreLogger.options.file, 'utf-8'); 641 | assert(content.includes('nodejs.MultipartFileTooLargeError: Request file too large')); 642 | // app.expectLog('nodejs.MultipartFileTooLargeError: Request file too large', 'coreLogger'); 643 | }); 644 | 645 | it('should ignore error when stream not handle error event', async () => { 646 | const form = formstream(); 647 | form.field('foo', 'bar').field('[', 'toString').field(']', 'toString'); 648 | form.file('file', bigfile, 'not-handle-error-event.js'); 649 | 650 | const headers = form.headers(); 651 | const url = host + '/upload'; 652 | const res = await urllib.request(url, { 653 | method: 'POST', 654 | headers, 655 | stream: form as any, 656 | dataType: 'json', 657 | }); 658 | 659 | const data = res.data; 660 | assert.equal(res.status, 200); 661 | assert(data.url); 662 | 663 | app.expectLog('nodejs.MultipartFileTooLargeError: Request file too large', 'coreLogger'); 664 | app.expectLog(/filename: ['"]not-handle-error-event.js['"]/, 'coreLogger'); 665 | }); 666 | 667 | it('should ignore stream next errors after limit event fire', async () => { 668 | const form = formstream(); 669 | form.field('foo', 'bar').field('[', 'toString').field(']', 'toString'); 670 | form.file('file', bigfile, 'not-handle-error-event-and-mock-stream-error.js'); 671 | 672 | const headers = form.headers(); 673 | const url = host + '/upload'; 674 | const res = await urllib.request(url, { 675 | method: 'POST', 676 | headers, 677 | stream: form as any, 678 | dataType: 'json', 679 | }); 680 | 681 | const data = res.data; 682 | assert(res.status === 200); 683 | assert(data.url); 684 | 685 | app.expectLog('nodejs.MultipartFileTooLargeError: Request file too large', 'coreLogger'); 686 | app.expectLog(/filename: ['"]not-handle-error-event-and-mock-stream-error.js['"]/, 'coreLogger'); 687 | }); 688 | }); 689 | }); 690 | --------------------------------------------------------------------------------