Better type validation

This commit is contained in:
rubikscraft 2022-02-24 11:22:28 +01:00
parent c855eb7c2b
commit 6e463ec828
No known key found for this signature in database
GPG key ID: 1463EBE9200A5CD4
17 changed files with 186 additions and 83 deletions

View file

@ -1,10 +1,11 @@
import { IsBoolean, IsNotEmpty, IsString } from 'class-validator';
import { IsBoolean, IsDefined, IsNotEmpty, IsString } from 'class-validator';
export class User {
@IsString()
@IsNotEmpty()
username: string;
@IsDefined()
@IsBoolean()
isAdmin: boolean;
}

View file

@ -2,12 +2,19 @@ import {
BadRequestException,
createParamDecorator,
ExecutionContext,
Logger,
Type,
} from '@nestjs/common';
import { validate } from 'class-validator';
import { FastifyRequest } from 'fastify';
import { Multipart } from 'fastify-multipart';
import { Multipart, MultipartFields, MultipartFile } from 'fastify-multipart';
import { request } from 'http';
import Config from 'src/env';
import { Newable } from 'src/types/newable';
import { isArray } from 'util';
import { MultiPartFieldDto, MultiPartFileDto } from './multipart.dto';
const logger = new Logger('MultiPart');
export interface MPFile {
fieldname: string;
}
@ -18,9 +25,7 @@ export const PostFile = createParamDecorator(
if (!req.isMultipart()) throw new BadRequestException('Invalid file');
const file = await req.file({
limits: { fileSize: Config.limits.maxFileSize, files: 1 },
});
const file = await req.file();
if (file === undefined) throw new BadRequestException('Invalid file');
const allFields: Multipart[] = Object.values(file.fields).filter(
@ -35,4 +40,46 @@ export const PostFile = createParamDecorator(
},
);
export class MultiPartDto {}
export const MultiPart = createParamDecorator(
async <T extends MultiPartDto>(
data: Newable<T>,
ctx: ExecutionContext,
) => {
const req: FastifyRequest = ctx.switchToHttp().getRequest();
const dtoClass = new data();
if (!req.isMultipart()) throw new BadRequestException('Invalid file');
let fields: MultipartFields;
try {
fields = (await req.file()).fields;
} catch (e) {
console.warn(e);
}
if (!fields) throw new BadRequestException('Invalid file');
for (const key of Object.keys(fields)) {
if (Array.isArray(fields[key])) {
continue;
}
if ((fields[key] as any).value) {
dtoClass[key] = new MultiPartFieldDto(fields[key] as MultipartFile);
} else {
dtoClass[key] = new MultiPartFileDto(fields[key] as MultipartFile);
}
}
const errors = await validate(dtoClass, { forbidUnknownValues: true });
if (errors.length > 0) {
logger.warn(errors);
throw new BadRequestException('Invalid file');
}
return dtoClass;
},
);
// TODO: Make better multipart decoder

View file

@ -0,0 +1,56 @@
import { MultipartFile } from 'fastify-multipart';
import { BusboyFileStream } from '@fastify/busboy';
import { IsDefined, IsNotEmpty, IsOptional, IsString } from 'class-validator';
export class MultiPartFileDto {
@IsString()
@IsNotEmpty()
fieldname: string;
@IsString()
@IsNotEmpty()
encoding: string;
@IsString()
@IsNotEmpty()
filename: string;
@IsString()
@IsNotEmpty()
mimetype: string;
@IsDefined()
toBuffer: () => Promise<Buffer>;
@IsDefined()
file: BusboyFileStream;
constructor(file: MultipartFile) {
this.fieldname = file.fieldname;
this.encoding = file.encoding;
this.filename = file.filename;
this.mimetype = file.mimetype;
this.toBuffer = file.toBuffer;
this.file = file.file;
}
}
export class MultiPartFieldDto {
@IsString()
@IsNotEmpty()
fieldname: string;
@IsString()
@IsNotEmpty()
encoding: string;
@IsString()
@IsNotEmpty()
value: string;
constructor(file: MultipartFile) {
this.fieldname = file.fieldname;
this.encoding = file.encoding;
this.value = (file as any).value;
}
}

View file

@ -0,0 +1,24 @@
import {
ExceptionFilter,
Catch,
ArgumentsHost,
HttpException,
} from '@nestjs/common';
import { FastifyRequest } from 'fastify';
import { FastifyReply } from 'fastify';
@Catch(HttpException)
export class HttpExceptionFilter implements ExceptionFilter {
catch(exception: HttpException, host: ArgumentsHost) {
const ctx = host.switchToHttp();
const response = ctx.getResponse<FastifyReply>();
const request = ctx.getRequest<FastifyRequest>();
const status = exception.getStatus();
response.status(status).send({
statusCode: status,
timestamp: new Date().toISOString(),
path: request.url,
});
}
}

View file

@ -1,8 +1,4 @@
import { dirname, resolve, join } from 'path';
import { fileURLToPath } from 'url';
const __dirname = resolve(dirname(fileURLToPath(import.meta.url)));
import { ValidationPipe } from '@nestjs/common';
import { Res, ValidationPipe } from '@nestjs/common';
import { NestFactory } from '@nestjs/core';
import {
FastifyAdapter,
@ -11,11 +7,22 @@ import {
import { AppModule } from './app.module';
import * as multipart from 'fastify-multipart';
import { FrontendMiddleware } from './middleware/frontend.middleware';
import Config from './env';
async function bootstrap() {
const fastifyAdapter = new FastifyAdapter();
fastifyAdapter.register(multipart as any);
// Todo: generic error messages
fastifyAdapter.register(multipart as any, {
limits: {
fieldNameSize: 128,
fieldSize: 1024,
fields: 16,
fileSize: Config.limits.maxFileSize,
files: 16,
},
logLevel: 'error',
});
const app = await NestFactory.create<NestFastifyApplication>(
AppModule,

View file

@ -1,47 +0,0 @@
import { StreamableFile } from '@nestjs/common';
import { FastifyReply, FastifyRequest } from 'fastify';
import { createReadStream } from 'fs';
import path from 'path';
import Config from 'src/env';
const allowedExt = [
'.js',
'.ico',
'.css',
'.png',
'.jpg',
'.woff2',
'.woff',
'.ttf',
'.svg',
];
const resolvePath = (file: string) =>
path.resolve(Config.static.frontendRoot, file);
export function FrontendMiddleware(
req: FastifyRequest,
res: FastifyReply,
next: () => void,
) {
const { url } = req;
for (let i = 0; i < Config.static.backendRoutes.length; i++) {
if (url.startsWith(`/${Config.static.backendRoutes[i]}`)) {
return next();
}
}
const { ext } = path.parse(url);
if (ext != '') {
// it has a file extension --> resolve the file
// Fastifty
res.send(new StreamableFile(createReadStream(resolvePath(url))));
} else {
// in all other cases, redirect to the index.html!
res.send(new StreamableFile(createReadStream(resolvePath('index.html'))));
}
next();
}

View file

@ -1,12 +0,0 @@
import { Module, NestModule, RequestMethod } from '@nestjs/common';
import { FrontendMiddleware } from './frontend.middleware';
@Module({})
export class FrontendModule implements NestModule {
configure(consumer: any) {
consumer.apply(FrontendMiddleware).forRoutes({
path: '*',
method: RequestMethod.ALL,
});
}
}

View file

@ -16,9 +16,9 @@ export class AdminGuard implements CanActivate {
const request = context.switchToHttp().getRequest();
const user = plainToClass(User, request.user);
const errors = await validate(user);
const errors = await validate(user, {forbidUnknownValues: true});
if (errors.length > 0) {
this.logger.warn(`Invalid user payload: ${JSON.stringify(request.user)}`);
this.logger.warn(errors);
return false;
}

View file

@ -1,5 +1,6 @@
import {
IsBoolean,
IsDefined,
IsNotEmpty,
IsOptional,
IsString,
@ -9,6 +10,7 @@ import { User } from 'src/collections/userdb/user.dto';
export class LoginResponseDto {
@IsString()
@IsDefined()
access_token: string;
}
@ -34,5 +36,6 @@ export class DeleteRequestDto {
export class JwtDataDto {
@ValidateNested()
@IsDefined()
user: User;
}

View file

@ -22,9 +22,9 @@ export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
async validate(payload: any): Promise<User> {
const jwt = plainToClass(JwtDataDto, payload);
const errors = await validate(jwt);
const errors = await validate(jwt, {forbidUnknownValues: true});
if (errors.length > 0) {
this.logger.warn(`Invalid JWT payload: ${JSON.stringify(payload)}`);
this.logger.warn(errors);
throw new UnauthorizedException();
}

View file

@ -9,11 +9,19 @@ import {
Req,
Res,
} from '@nestjs/common';
import {
IsBoolean,
IsDefined,
Validate,
ValidateNested,
} from 'class-validator';
import { FastifyReply, FastifyRequest } from 'fastify';
import { PostFile } from 'src/decorators/multipart.decorator';
import { User } from 'src/collections/userdb/user.dto';
import { MultiPart, MultiPartDto } from 'src/decorators/multipart.decorator';
import { MultiPartFileDto } from 'src/decorators/multipart.dto';
import { ImageManagerService } from 'src/managers/imagemanager/imagemanager.service';
import { HasFailed } from 'src/types/failable';
import { ImageUploadDto } from './imageroute.dto';
@Controller('i')
export class ImageController {
constructor(private readonly imagesService: ImageManagerService) {}
@ -35,8 +43,12 @@ export class ImageController {
}
@Post()
async uploadImage(@Req() req: FastifyRequest, @PostFile() file: Buffer) {
const hash = await this.imagesService.upload(file);
async uploadImage(
@Req() req: FastifyRequest,
@MultiPart(ImageUploadDto) multipart: ImageUploadDto,
) {
const fileBuffer = await multipart.image.toBuffer();
const hash = await this.imagesService.upload(fileBuffer);
if (HasFailed(hash)) {
throw new InternalServerErrorException('Failed to upload image');
}

View file

@ -0,0 +1,9 @@
import { IsDefined, ValidateNested } from 'class-validator';
import { MultiPartDto } from 'src/decorators/multipart.decorator';
import { MultiPartFileDto } from 'src/decorators/multipart.dto';
export class ImageUploadDto extends MultiPartDto {
@IsDefined()
@ValidateNested()
image: MultiPartFileDto;
}

View file

@ -0,0 +1 @@
export type Newable<T> = { new (...args: any[]): T };

View file

@ -19,6 +19,8 @@ export async function UploadImage(image: File): AsyncFailable<string> {
body: formData,
}).then((res) => res.json());
console.log(result);
if (!result.hash) return Fail(result.error);
return result.hash;

View file

@ -5,6 +5,4 @@
border-style: solid;
border-width: 5px;
padding: 2rem;
}

View file

@ -16,8 +16,6 @@ function ProcessingView(props: any) {
async function onRendered() {
if (!state) navigate('/');
console.log(state.imageFile);
const hash = await UploadImage(state.imageFile);
if (HasFailed(hash)) navigate('/'); // TODO: handle error

View file

@ -6,3 +6,7 @@
border-radius: 20px;
}
.contentwindow {
padding: 2rem;
}