diff --git a/packages/common/pipes/file/file-type.validator.ts b/packages/common/pipes/file/file-type.validator.ts new file mode 100644 index 00000000000..33404914992 --- /dev/null +++ b/packages/common/pipes/file/file-type.validator.ts @@ -0,0 +1,30 @@ +import { FileValidator } from './file-validator.interface'; + +export type FileTypeValidatorOptions = { + fileType: string; +}; + +/** + * Defines the built-in FileType File Validator + * + * @see [File Validators](https://docs.nestjs.com/techniques/file-upload#validators) + * + * @publicApi + */ +export class FileTypeValidator extends FileValidator { + buildErrorMessage(): string { + return `Validation failed (expected type is ${this.validationOptions.fileType})`; + } + + isValid(file: any): boolean { + if (!this.validationOptions) { + return true; + } + + if (!file.mimetype) { + return false; + } + + return (file.mimetype as string).endsWith(this.validationOptions.fileType); + } +} diff --git a/packages/common/pipes/file/file-validator.interface.ts b/packages/common/pipes/file/file-validator.interface.ts new file mode 100644 index 00000000000..ad453cdae5f --- /dev/null +++ b/packages/common/pipes/file/file-validator.interface.ts @@ -0,0 +1,18 @@ +/** + * Interface describing FileValidators, which can be added to a {@link ParseFilePipe}. + */ +export abstract class FileValidator> { + constructor(protected readonly validationOptions: TValidationOptions) {} + + /** + * Indicates if this file should be considered valid, according to the options passed in the constructor. + * @param file the file from the request object + */ + abstract isValid(file?: any): boolean | Promise; + + /** + * Builds an error message in case the validation fails. + * @param file the file from the request object + */ + abstract buildErrorMessage(file: any): string; +} diff --git a/packages/common/pipes/file/index.ts b/packages/common/pipes/file/index.ts new file mode 100644 index 00000000000..11d19e3188e --- /dev/null +++ b/packages/common/pipes/file/index.ts @@ -0,0 +1,6 @@ +export * from './file-type.validator'; +export * from './file-validator.interface'; +export * from './max-file-size.validator'; +export * from './parse-file-options.interface'; +export * from './parse-file.pipe'; +export * from './parse-file-pipe.builder'; diff --git a/packages/common/pipes/file/max-file-size.validator.ts b/packages/common/pipes/file/max-file-size.validator.ts new file mode 100644 index 00000000000..847ee19f07b --- /dev/null +++ b/packages/common/pipes/file/max-file-size.validator.ts @@ -0,0 +1,26 @@ +import { FileValidator } from './file-validator.interface'; + +export type MaxFileSizeValidatorOptions = { + maxSize: number; +}; + +/** + * Defines the built-in MaxSize File Validator + * + * @see [File Validators](https://docs.nestjs.com/techniques/file-upload#validators) + * + * @publicApi + */ +export class MaxFileSizeValidator extends FileValidator { + buildErrorMessage(): string { + return `Validation failed (expected size is less than ${this.validationOptions.maxSize})`; + } + + public isValid(file: any): boolean { + if (!this.validationOptions) { + return true; + } + + return file.size < this.validationOptions.maxSize; + } +} diff --git a/packages/common/pipes/file/parse-file-options.interface.ts b/packages/common/pipes/file/parse-file-options.interface.ts new file mode 100644 index 00000000000..e74630999a6 --- /dev/null +++ b/packages/common/pipes/file/parse-file-options.interface.ts @@ -0,0 +1,8 @@ +import { ErrorHttpStatusCode } from '../../utils/http-error-by-code.util'; +import { FileValidator } from './file-validator.interface'; + +export interface ParseFileOptions { + validators?: FileValidator[]; + errorHttpStatusCode?: ErrorHttpStatusCode; + exceptionFactory?: (error: string) => any; +} diff --git a/packages/common/pipes/file/parse-file-pipe.builder.ts b/packages/common/pipes/file/parse-file-pipe.builder.ts new file mode 100644 index 00000000000..7835322d972 --- /dev/null +++ b/packages/common/pipes/file/parse-file-pipe.builder.ts @@ -0,0 +1,37 @@ +import { + FileTypeValidator, + FileTypeValidatorOptions, +} from './file-type.validator'; +import { FileValidator } from './file-validator.interface'; +import { + MaxFileSizeValidator, + MaxFileSizeValidatorOptions, +} from './max-file-size.validator'; +import { ParseFileOptions } from './parse-file-options.interface'; +import { ParseFilePipe } from './parse-file.pipe'; + +export class ParseFilePipeBuilder { + private validators: FileValidator[] = []; + + addMaxSizeValidator(options: MaxFileSizeValidatorOptions) { + this.validators.push(new MaxFileSizeValidator(options)); + return this; + } + + addFileTypeValidator(options: FileTypeValidatorOptions) { + this.validators.push(new FileTypeValidator(options)); + return this; + } + + build( + additionalOptions?: Omit, + ): ParseFilePipe { + const parseFilePipe = new ParseFilePipe({ + ...additionalOptions, + validators: this.validators, + }); + + this.validators = []; + return parseFilePipe; + } +} diff --git a/packages/common/pipes/file/parse-file.pipe.ts b/packages/common/pipes/file/parse-file.pipe.ts new file mode 100644 index 00000000000..e952c38f529 --- /dev/null +++ b/packages/common/pipes/file/parse-file.pipe.ts @@ -0,0 +1,68 @@ +import { Injectable, Optional } from '../../decorators/core'; +import { HttpStatus } from '../../enums'; +import { HttpErrorByCode } from '../../utils/http-error-by-code.util'; +import { PipeTransform } from '../../interfaces/features/pipe-transform.interface'; +import { ParseFileOptions } from './parse-file-options.interface'; +import { FileValidator } from './file-validator.interface'; +import { throws } from 'assert'; + +/** + * Defines the built-in ParseFile Pipe. This pipe can be used to validate incoming files + * with `@UploadedFile()` decorator. You can use either other specific built-in validators + * or provide one of your own, simply implementing it through {@link FileValidator} + * interface and adding it to ParseFilePipe's constructor. + * + * @see [Built-in Pipes](https://docs.nestjs.com/pipes#built-in-pipes) + * + * @publicApi + */ +@Injectable() +export class ParseFilePipe implements PipeTransform { + protected exceptionFactory: (error: string) => any; + private readonly validators: FileValidator[]; + + constructor(@Optional() options: ParseFileOptions = {}) { + const { + exceptionFactory, + errorHttpStatusCode = HttpStatus.BAD_REQUEST, + validators = [], + } = options; + + this.exceptionFactory = + exceptionFactory || + (error => new HttpErrorByCode[errorHttpStatusCode](error)); + + this.validators = validators; + } + + async transform(value: any): Promise { + if (this.validators.length) { + await this.validate(value); + } + return value; + } + + protected async validate(file: any): Promise { + for (const validator of this.validators) { + await this.validateOrThrow(file, validator); + } + + return file; + } + + private async validateOrThrow(file: any, validator: FileValidator) { + const isValid = await validator.isValid(file); + + if (!isValid) { + const errorMessage = validator.buildErrorMessage(file); + throw this.exceptionFactory(errorMessage); + } + } + + /** + * @returns list of validators used in this pipe. + */ + getValidators() { + return this.validators; + } +} diff --git a/packages/common/pipes/index.ts b/packages/common/pipes/index.ts index a6936549377..ebbc55f272a 100644 --- a/packages/common/pipes/index.ts +++ b/packages/common/pipes/index.ts @@ -6,3 +6,4 @@ export * from './parse-float.pipe'; export * from './parse-enum.pipe'; export * from './parse-uuid.pipe'; export * from './validation.pipe'; +export * from './file'; diff --git a/packages/common/test/pipes/file/file-type.validator.spec.ts b/packages/common/test/pipes/file/file-type.validator.spec.ts new file mode 100644 index 00000000000..c4248f66a08 --- /dev/null +++ b/packages/common/test/pipes/file/file-type.validator.spec.ts @@ -0,0 +1,65 @@ +import { FileTypeValidator } from '../../../pipes'; +import { expect } from 'chai'; + +describe('FileTypeValidator', () => { + describe('isValid', () => { + it('should return true when the file mimetype is the same as the specified', () => { + const fileTypeValidator = new FileTypeValidator({ + fileType: 'image/jpeg', + }); + + const requestFile = { + mimetype: 'image/jpeg', + }; + + expect(fileTypeValidator.isValid(requestFile)).to.equal(true); + }); + + it('should return true when the file mimetype ends with the specified option type', () => { + const fileTypeValidator = new FileTypeValidator({ + fileType: 'jpeg', + }); + + const requestFile = { + mimetype: 'image/jpeg', + }; + + expect(fileTypeValidator.isValid(requestFile)).to.equal(true); + }); + + it('should return false when the file mimetype is different from the specified', () => { + const fileTypeValidator = new FileTypeValidator({ + fileType: 'image/jpeg', + }); + + const requestFile = { + mimetype: 'image/png', + }; + + expect(fileTypeValidator.isValid(requestFile)).to.equal(false); + }); + + it('should return false when the file mimetype was not provided', () => { + const fileTypeValidator = new FileTypeValidator({ + fileType: 'image/jpeg', + }); + + const requestFile = {}; + + expect(fileTypeValidator.isValid(requestFile)).to.equal(false); + }); + }); + + describe('buildErrorMessage', () => { + it('should return a string with the format "Validation failed (expected type is #fileType)"', () => { + const fileType = 'image/jpeg'; + const fileTypeValidator = new FileTypeValidator({ + fileType, + }); + + expect(fileTypeValidator.buildErrorMessage()).to.equal( + `Validation failed (expected type is ${fileType})`, + ); + }); + }); +}); diff --git a/packages/common/test/pipes/file/max-file-size.validator.spec.ts b/packages/common/test/pipes/file/max-file-size.validator.spec.ts new file mode 100644 index 00000000000..9ba8e4283bc --- /dev/null +++ b/packages/common/test/pipes/file/max-file-size.validator.spec.ts @@ -0,0 +1,56 @@ +import { expect } from 'chai'; +import { MaxFileSizeValidator } from '../../../pipes'; + +describe('MaxFileSizeValidator', () => { + const oneKb = 1024; + + describe('isValid', () => { + it('should return true when the file size is less than the maximum size', () => { + const maxFileSizeValidator = new MaxFileSizeValidator({ + maxSize: oneKb, + }); + + const requestFile = { + size: 100, + }; + + expect(maxFileSizeValidator.isValid(requestFile)).to.equal(true); + }); + + it('should return false when the file size is greater than the maximum size', () => { + const maxFileSizeValidator = new MaxFileSizeValidator({ + maxSize: oneKb, + }); + + const requestFile = { + size: oneKb + 1, + }; + + expect(maxFileSizeValidator.isValid(requestFile)).to.equal(false); + }); + + it('should return false when the file size is equal to the maximum size', () => { + const maxFileSizeValidator = new MaxFileSizeValidator({ + maxSize: oneKb, + }); + + const requestFile = { + size: oneKb, + }; + + expect(maxFileSizeValidator.isValid(requestFile)).to.equal(false); + }); + }); + + describe('buildErrorMessage', () => { + it('should return a string with the format "Validation failed (expected size is less than #maxSize")', () => { + const maxFileSizeValidator = new MaxFileSizeValidator({ + maxSize: oneKb, + }); + + expect(maxFileSizeValidator.buildErrorMessage()).to.equal( + `Validation failed (expected size is less than ${oneKb})`, + ); + }); + }); +}); diff --git a/packages/common/test/pipes/file/parse-file-pipe.builder.spec.ts b/packages/common/test/pipes/file/parse-file-pipe.builder.spec.ts new file mode 100644 index 00000000000..8c31904aaaf --- /dev/null +++ b/packages/common/test/pipes/file/parse-file-pipe.builder.spec.ts @@ -0,0 +1,77 @@ +import { expect } from 'chai'; +import { + FileTypeValidator, + MaxFileSizeValidator, + ParseFilePipeBuilder, +} from '../../../pipes'; + +describe('ParseFilePipeBuilder', () => { + let parseFilePipeBuilder: ParseFilePipeBuilder; + + beforeEach(() => { + parseFilePipeBuilder = new ParseFilePipeBuilder(); + }); + + describe('build', () => { + describe('when no validator was passed', () => { + it('should return a ParseFilePipe with no validators', () => { + const parseFilePipe = parseFilePipeBuilder.build(); + expect(parseFilePipe.getValidators()).to.be.empty; + }); + }); + + describe('when addMaxSizeValidator was chained', () => { + it('should return a ParseFilePipe with MaxSizeValidator and given options', () => { + const options = { + maxSize: 1000, + }; + const parseFilePipe = parseFilePipeBuilder + .addMaxSizeValidator(options) + .build(); + + expect(parseFilePipe.getValidators()).to.deep.include( + new MaxFileSizeValidator(options), + ); + }); + }); + + describe('when addFileTypeValidator was chained', () => { + it('should return a ParseFilePipe with FileTypeValidator and given options', () => { + const options = { + fileType: 'image/jpeg', + }; + const parseFilePipe = parseFilePipeBuilder + .addFileTypeValidator(options) + .build(); + + expect(parseFilePipe.getValidators()).to.deep.include( + new FileTypeValidator(options), + ); + }); + }); + + describe('when it is called twice with different validators', () => { + it('should not reuse validators', () => { + const maxSizeValidatorOptions = { + maxSize: 1000, + }; + + const pipeWithMaxSizeValidator = parseFilePipeBuilder + .addMaxSizeValidator(maxSizeValidatorOptions) + .build(); + + const fileTypeValidatorOptions = { + fileType: 'image/jpeg', + }; + + const pipeWithFileTypeValidator = parseFilePipeBuilder + .addFileTypeValidator(fileTypeValidatorOptions) + .build(); + + expect(pipeWithFileTypeValidator.getValidators()).not.to.deep.equal( + pipeWithMaxSizeValidator.getValidators(), + ); + }); + }); + }); +}); diff --git a/packages/common/test/pipes/file/parse-file.pipe.spec.ts b/packages/common/test/pipes/file/parse-file.pipe.spec.ts new file mode 100644 index 00000000000..ee06ef44b8f --- /dev/null +++ b/packages/common/test/pipes/file/parse-file.pipe.spec.ts @@ -0,0 +1,120 @@ +import { HttpStatus } from '../../../enums'; +import { BadRequestException, ConflictException } from '../../../exceptions'; +import { FileValidator, ParseFilePipe } from '../../../pipes'; +import { expect } from 'chai'; + +class AlwaysValidValidator extends FileValidator { + isValid(): boolean { + return true; + } + buildErrorMessage(): string { + return ''; + } +} + +const customErrorMessage = 'Error!'; + +class AlwaysInvalidValidator extends FileValidator { + isValid(): boolean { + return false; + } + buildErrorMessage(): string { + return customErrorMessage; + } +} + +describe('ParseFilePipe', () => { + let parseFilePipe: ParseFilePipe; + describe('transform', () => { + describe('when there are no validators (explicit)', () => { + beforeEach(() => { + parseFilePipe = new ParseFilePipe({ + validators: [], + }); + }); + + it('should return the file object', async () => { + const requestFile = { + path: 'some-path', + }; + + await expect(parseFilePipe.transform(requestFile)).to.eventually.eql( + requestFile, + ); + }); + }); + + describe('when there are no validators (by default constructor)', () => { + beforeEach(() => { + parseFilePipe = new ParseFilePipe(); + }); + + it('should return the file object', async () => { + const requestFile = { + path: 'some-path', + }; + + await expect(parseFilePipe.transform(requestFile)).to.eventually.eql( + requestFile, + ); + }); + }); + + describe('when all the validators validate the file', () => { + beforeEach(() => { + parseFilePipe = new ParseFilePipe({ + validators: [new AlwaysValidValidator({})], + }); + }); + + it('should return the file object', async () => { + const requestFile = { + path: 'some-path', + }; + + await expect(parseFilePipe.transform(requestFile)).to.eventually.eql( + requestFile, + ); + }); + }); + + describe('when some validator invalidates the file', () => { + describe('and the pipe has the default error', () => { + beforeEach(() => { + parseFilePipe = new ParseFilePipe({ + validators: [new AlwaysInvalidValidator({})], + }); + }); + + it('should throw a BadRequestException', async () => { + const requestFile = { + path: 'some-path', + }; + + await expect(parseFilePipe.transform(requestFile)).to.be.rejectedWith( + BadRequestException, + ); + }); + }); + + describe('and the pipe has a custom error code', () => { + beforeEach(() => { + parseFilePipe = new ParseFilePipe({ + validators: [new AlwaysInvalidValidator({})], + errorHttpStatusCode: HttpStatus.CONFLICT, + }); + }); + + it('should throw this custom Error', async () => { + const requestFile = { + path: 'some-path', + }; + + await expect(parseFilePipe.transform(requestFile)).to.be.rejectedWith( + ConflictException, + ); + }); + }); + }); + }); +}); diff --git a/sample/29-file-upload/e2e/app/app.e2e-spec.ts b/sample/29-file-upload/e2e/app/app.e2e-spec.ts index 826b5ba8d5f..4107251ff62 100644 --- a/sample/29-file-upload/e2e/app/app.e2e-spec.ts +++ b/sample/29-file-upload/e2e/app/app.e2e-spec.ts @@ -1,7 +1,6 @@ import { INestApplication } from '@nestjs/common'; import { Test } from '@nestjs/testing'; import { readFileSync } from 'fs'; -import { join } from 'path'; import * as request from 'supertest'; import { AppModule } from '../../src/app.module'; @@ -30,6 +29,28 @@ describe('E2E FileTest', () => { }); }); + it('should allow for file uploads that pass validation', async () => { + return request(app.getHttpServer()) + .post('/file/pass-validation') + .attach('file', './package.json') + .field('name', 'test') + .expect(201) + .expect({ + body: { + name: 'test', + }, + file: readFileSync('./package.json').toString(), + }); + }); + + it('should throw for file uploads that do not pass validation', async () => { + return request(app.getHttpServer()) + .post('/file/fail-validation') + .attach('file', './package.json') + .field('name', 'test') + .expect(400); + }); + afterAll(async () => { await app.close(); }); diff --git a/sample/29-file-upload/src/app.controller.ts b/sample/29-file-upload/src/app.controller.ts index dc59cd260e9..39d3af1fb75 100644 --- a/sample/29-file-upload/src/app.controller.ts +++ b/sample/29-file-upload/src/app.controller.ts @@ -2,6 +2,7 @@ import { Body, Controller, Get, + ParseFilePipeBuilder, Post, UploadedFile, UseInterceptors, @@ -31,4 +32,42 @@ export class AppController { file: file.buffer.toString(), }; } + + @UseInterceptors(FileInterceptor('file')) + @Post('file/pass-validation') + uploadFileAndPassValidation( + @Body() body: SampleDto, + @UploadedFile( + new ParseFilePipeBuilder() + .addFileTypeValidator({ + fileType: 'json', + }) + .build(), + ) + file: Express.Multer.File, + ) { + return { + body, + file: file.buffer.toString(), + }; + } + + @UseInterceptors(FileInterceptor('file')) + @Post('file/fail-validation') + uploadFileAndFailValidation( + @Body() body: SampleDto, + @UploadedFile( + new ParseFilePipeBuilder() + .addFileTypeValidator({ + fileType: 'jpg', + }) + .build(), + ) + file: Express.Multer.File, + ) { + return { + body, + file: file.buffer.toString(), + }; + } }