├── 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 |
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 |
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 |
9 |
--------------------------------------------------------------------------------
/test/fixtures/apps/dynamic-option/app/views/home.html:
--------------------------------------------------------------------------------
1 |
9 |
--------------------------------------------------------------------------------
/test/fixtures/apps/fileModeMatch/app/views/home.html:
--------------------------------------------------------------------------------
1 |
9 |
--------------------------------------------------------------------------------
/test/fixtures/apps/fileModeMatch-glob/app/views/home.html:
--------------------------------------------------------------------------------
1 |
9 |
--------------------------------------------------------------------------------
/test/fixtures/apps/fileModeMatch-glob-with-pathToRegexpModule/app/views/home.html:
--------------------------------------------------------------------------------
1 |
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 | [](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 | [](https://makeapullrequest.com)
9 | 
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 |
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 |
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 |
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 |
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 |
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 | [](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 |
--------------------------------------------------------------------------------