Better type validation
This commit is contained in:
parent
c855eb7c2b
commit
6e463ec828
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
56
backend/src/decorators/multipart.dto.ts
Normal file
56
backend/src/decorators/multipart.dto.ts
Normal 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;
|
||||
}
|
||||
}
|
24
backend/src/filters/http-exception/http-exception.filter.ts
Normal file
24
backend/src/filters/http-exception/http-exception.filter.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
}
|
|
@ -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,
|
||||
|
|
|
@ -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();
|
||||
}
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
|
||||
|
|
|
@ -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');
|
||||
}
|
||||
|
|
9
backend/src/routes/image/imageroute.dto.ts
Normal file
9
backend/src/routes/image/imageroute.dto.ts
Normal 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;
|
||||
}
|
1
backend/src/types/newable.ts
Normal file
1
backend/src/types/newable.ts
Normal file
|
@ -0,0 +1 @@
|
|||
export type Newable<T> = { new (...args: any[]): T };
|
|
@ -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;
|
||||
|
|
|
@ -5,6 +5,4 @@
|
|||
|
||||
border-style: solid;
|
||||
border-width: 5px;
|
||||
|
||||
padding: 2rem;
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -6,3 +6,7 @@
|
|||
|
||||
border-radius: 20px;
|
||||
}
|
||||
|
||||
.contentwindow {
|
||||
padding: 2rem;
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue