diff --git a/package-lock.json b/package-lock.json index f4bd236..1b5cac9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,11 +8,13 @@ "name": "six-cities", "version": "5.0.0", "dependencies": { + "@types/cors": "^2.8.19", "chalk": "5.6.2", "class-transformer": "0.5.1", "class-validator": "0.15.1", "convict": "6.2.5", "convict-format-with-validator": "6.2.0", + "cors": "^2.8.6", "dayjs": "1.11.19", "express": "5.2.1", "express-async-handler": "1.2.0", @@ -809,6 +811,15 @@ "@types/convict": "*" } }, + "node_modules/@types/cors": { + "version": "2.8.19", + "resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.19.tgz", + "integrity": "sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/express": { "version": "5.0.6", "resolved": "https://registry.npmjs.org/@types/express/-/express-5.0.6.tgz", @@ -1739,6 +1750,23 @@ "node": ">=6.6.0" } }, + "node_modules/cors": { + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", + "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/create-require": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz", @@ -4515,7 +4543,6 @@ "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", - "dev": true, "engines": { "node": ">=0.10.0" } @@ -7112,6 +7139,14 @@ "@types/convict": "*" } }, + "@types/cors": { + "version": "2.8.19", + "resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.19.tgz", + "integrity": "sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg==", + "requires": { + "@types/node": "*" + } + }, "@types/express": { "version": "5.0.6", "resolved": "https://registry.npmjs.org/@types/express/-/express-5.0.6.tgz", @@ -7742,6 +7777,15 @@ "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==" }, + "cors": { + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", + "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", + "requires": { + "object-assign": "^4", + "vary": "^1" + } + }, "create-require": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz", @@ -9613,8 +9657,7 @@ "object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", - "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", - "dev": true + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==" }, "object-inspect": { "version": "1.13.4", diff --git a/package.json b/package.json index d574872..5ddbee6 100644 --- a/package.json +++ b/package.json @@ -44,11 +44,13 @@ "npm": ">=8" }, "dependencies": { + "@types/cors": "^2.8.19", "chalk": "5.6.2", "class-transformer": "0.5.1", "class-validator": "0.15.1", "convict": "6.2.5", "convict-format-with-validator": "6.2.0", + "cors": "^2.8.6", "dayjs": "1.11.19", "express": "5.2.1", "express-async-handler": "1.2.0", diff --git a/specification/project.spec.yml b/specification/project.spec.yml index 1d882f0..aa785a4 100644 --- a/specification/project.spec.yml +++ b/specification/project.spec.yml @@ -75,17 +75,6 @@ paths: '401': $ref: '#/components/responses/Unauthorized' - /users/logout: - post: - tags: [users] - summary: Выход пользователя - description: Завершает пользовательскую сессию - responses: - '204': - description: Успешный выход - '401': - $ref: '#/components/responses/Unauthorized' - /users/{userId}/avatar: post: tags: [users] diff --git a/src/rest/rest.application.ts b/src/rest/rest.application.ts index f7a760d..2bdaf83 100644 --- a/src/rest/rest.application.ts +++ b/src/rest/rest.application.ts @@ -1,14 +1,17 @@ +import cors from 'cors'; import express, { Express } from 'express'; import { inject, injectable } from 'inversify'; import { Logger } from '../shared/libs/logger/index.js'; import { Config, RestSchema } from '../shared/libs/config/index.js'; import { Component } from '../shared/types/index.js'; import { DatabaseClient } from '../shared/libs/database-client/index.js'; -import { getMongoURI } from '../shared/helpers/index.js'; +import { getFullServerPath, getMongoURI } from '../shared/helpers/index.js'; import { Controller, ExceptionFilter, ParseTokenMiddleware, + STATIC_FILES_ROUTE, + STATIC_UPLOAD_ROUTE, } from '../shared/libs/rest/index.js'; @injectable() @@ -30,6 +33,10 @@ export class RestApplication { private readonly commentController: Controller, @inject(Component.AuthExceptionFilter) private readonly authExceptionFilter: ExceptionFilter, + @inject(Component.HttpExceptionFilter) + private readonly httpExceptionFilter: ExceptionFilter, + @inject(Component.ValidationExceptionFilter) + private readonly validationExceptionFilter: ExceptionFilter, ) { this.server = express(); } @@ -58,15 +65,20 @@ export class RestApplication { } private async _initMiddleware() { - const authenticateMiddleware = new ParseTokenMiddleware( - this.config.get('JWT_SECRET'), - ); - + this.server.use(cors()); this.server.use(express.json()); this.server.use( - '/upload', + STATIC_UPLOAD_ROUTE, express.static(this.config.get('UPLOAD_DIRECTORY')), ); + this.server.use( + STATIC_FILES_ROUTE, + express.static(this.config.get('STATIC_DIRECTORY_PATH')), + ); + + const authenticateMiddleware = new ParseTokenMiddleware( + this.config.get('JWT_SECRET'), + ); this.server.use( authenticateMiddleware.execute.bind(authenticateMiddleware), ); @@ -76,6 +88,12 @@ export class RestApplication { this.server.use( this.authExceptionFilter.catch.bind(this.authExceptionFilter), ); + this.server.use( + this.validationExceptionFilter.catch.bind(this.validationExceptionFilter), + ); + this.server.use( + this.httpExceptionFilter.catch.bind(this.httpExceptionFilter), + ); this.server.use( this.appExceptionFilter.catch.bind(this.appExceptionFilter), ); @@ -103,7 +121,7 @@ export class RestApplication { this.logger.info('Try to init server...'); await this._initServer(); this.logger.info( - `Server started on http://localhost:${this.config.get('PORT')}`, + `Server started on ${getFullServerPath(this.config.get('HOST'), this.config.get('PORT'))}`, ); } } diff --git a/src/rest/rest.container.ts b/src/rest/rest.container.ts index 052f80d..a8808a6 100644 --- a/src/rest/rest.container.ts +++ b/src/rest/rest.container.ts @@ -10,7 +10,10 @@ import { import { AppExceptionFilter, ExceptionFilter, + HttpErrorExceptionFilter, + ValidationExceptionFilter, } from '../shared/libs/rest/index.js'; +import { PathTransformer } from '../shared/libs/rest/transform/path-transformer.js'; export function createRestApplicationContainer() { const restApplicationContainer = new Container(); @@ -35,6 +38,18 @@ export function createRestApplicationContainer() { .bind(Component.ExceptionFilter) .to(AppExceptionFilter) .inSingletonScope(); + restApplicationContainer + .bind(Component.HttpExceptionFilter) + .to(HttpErrorExceptionFilter) + .inSingletonScope(); + restApplicationContainer + .bind(Component.ValidationExceptionFilter) + .to(ValidationExceptionFilter) + .inSingletonScope(); + restApplicationContainer + .bind(Component.PathTransformer) + .to(PathTransformer) + .inSingletonScope(); return restApplicationContainer; } diff --git a/src/shared/helpers/common.ts b/src/shared/helpers/common.ts index 7358069..8e11cba 100644 --- a/src/shared/helpers/common.ts +++ b/src/shared/helpers/common.ts @@ -1,12 +1,20 @@ import { ClassConstructor, plainToInstance } from 'class-transformer'; +import { ValidationError } from 'class-validator'; +import { ApplicationError, ValidationErrorField } from '../libs/rest/index.js'; export function getErrorMessage(error: unknown): string { return error instanceof Error ? error.message : ''; } -export function createErrorObject(message: string) { +export function createErrorObject( + errorType: ApplicationError, + error: string, + details: ValidationErrorField[] = [], +) { return { - error: message, + errorType, + error, + details, }; } @@ -15,3 +23,17 @@ export function fillDTO(someDto: ClassConstructor, plainObject: V) { excludeExtraneousValues: true, }); } + +export function reduceValidationErrors( + errors: ValidationError[], +): ValidationErrorField[] { + return errors.map(({ property, value, constraints }) => ({ + property, + value, + messages: constraints ? Object.values(constraints) : [], + })); +} + +export function getFullServerPath(host: string, port: number) { + return `http://${host}:${port}`; +} diff --git a/src/shared/libs/config/rest.schema.ts b/src/shared/libs/config/rest.schema.ts index 151c9f4..74c52db 100644 --- a/src/shared/libs/config/rest.schema.ts +++ b/src/shared/libs/config/rest.schema.ts @@ -27,6 +27,8 @@ export type RestSchema = { DB_NAME: string; UPLOAD_DIRECTORY: string; JWT_SECRET: string; + HOST: string; + STATIC_DIRECTORY_PATH: string; }; export const configRestSchema = convict({ @@ -84,4 +86,16 @@ export const configRestSchema = convict({ env: 'JWT_SECRET', default: null, }, + HOST: { + doc: 'Host where service started', + format: String, + env: 'HOST', + default: 'localhost', + }, + STATIC_DIRECTORY_PATH: { + doc: 'Path to directory with static resources', + format: String, + env: 'STATIC_DIRECTORY_PATH', + default: 'static', + }, }); diff --git a/src/shared/libs/rest/controller/base-controller.abstract.ts b/src/shared/libs/rest/controller/base-controller.abstract.ts index 0e077b7..a5572d5 100644 --- a/src/shared/libs/rest/controller/base-controller.abstract.ts +++ b/src/shared/libs/rest/controller/base-controller.abstract.ts @@ -1,11 +1,13 @@ import { Response, Router } from 'express'; import { ParamsDictionary } from 'express-serve-static-core'; import asyncHandler from 'express-async-handler'; -import { injectable } from 'inversify'; +import { injectable, inject } from 'inversify'; import { Controller } from './controller.interface.js'; import { Logger } from '../../logger/index.js'; import { Route } from '../types/route.interface.js'; import { StatusCodes } from 'http-status-codes'; +import { Component } from '../../../types/index.js'; +import { PathTransformer } from '../transform/path-transformer.js'; const DEFAULT_CONTENT_TYPE = 'application/json'; @@ -13,6 +15,9 @@ const DEFAULT_CONTENT_TYPE = 'application/json'; export abstract class BaseController implements Controller { private readonly _router: Router; + @inject(Component.PathTransformer) + private pathTransformer: PathTransformer; + constructor(protected readonly logger: Logger) { this._router = Router(); } @@ -39,7 +44,11 @@ export abstract class BaseController implements Controller { } public send(res: Response, statusCode: number, data: T): void { - res.type(DEFAULT_CONTENT_TYPE).status(statusCode).json(data); + const modifiedData = this.pathTransformer.execute( + data as Record, + ); + + res.type(DEFAULT_CONTENT_TYPE).status(statusCode).json(modifiedData); } public created(res: Response, data: T): void { diff --git a/src/shared/libs/rest/errors/index.ts b/src/shared/libs/rest/errors/index.ts index 0c80068..12e21a7 100644 --- a/src/shared/libs/rest/errors/index.ts +++ b/src/shared/libs/rest/errors/index.ts @@ -1 +1,2 @@ export * from './http-error.js'; +export * from './validation.error.js'; diff --git a/src/shared/libs/rest/errors/validation.error.ts b/src/shared/libs/rest/errors/validation.error.ts new file mode 100644 index 0000000..d5dd8fc --- /dev/null +++ b/src/shared/libs/rest/errors/validation.error.ts @@ -0,0 +1,12 @@ +import { StatusCodes } from 'http-status-codes'; +import { ValidationErrorField } from '../types/validation-error-field.type.js'; +import { HttpError } from './http-error.js'; + +export class ValidationError extends HttpError { + public details: ValidationErrorField[] = []; + + constructor(message: string, errors: ValidationErrorField[]) { + super(StatusCodes.BAD_REQUEST, message); + this.details = errors; + } +} diff --git a/src/shared/libs/rest/exception-filter/app-exception-filter.ts b/src/shared/libs/rest/exception-filter/app.exception-filter.ts similarity index 53% rename from src/shared/libs/rest/exception-filter/app-exception-filter.ts rename to src/shared/libs/rest/exception-filter/app.exception-filter.ts index 8458604..82c3f04 100644 --- a/src/shared/libs/rest/exception-filter/app-exception-filter.ts +++ b/src/shared/libs/rest/exception-filter/app.exception-filter.ts @@ -4,8 +4,8 @@ import { inject, injectable } from 'inversify'; import { ExceptionFilter } from './exception-filter.interface.js'; import { Component } from '../../../types/index.js'; import { Logger } from '../../logger/index.js'; -import { HttpError } from '../errors/index.js'; import { createErrorObject } from '../../../helpers/index.js'; +import { ApplicationError } from '../types/application-error.enum.js'; @injectable() export class AppExceptionFilter implements ExceptionFilter { @@ -13,41 +13,15 @@ export class AppExceptionFilter implements ExceptionFilter { this.logger.info('Register AppExceptionFilter'); } - private handleHttpError( - error: HttpError, - _req: Request, - res: Response, - _next: NextFunction, - ) { - this.logger.error( - `[${error.detail}]: ${error.httpStatusCode} — ${error.message}`, - error, - ); - res.status(error.httpStatusCode).json(createErrorObject(error.message)); - } - - private handleOtherError( + public catch( error: Error, _req: Request, res: Response, _next: NextFunction, - ) { + ): void { this.logger.error(error.message, error); res .status(StatusCodes.INTERNAL_SERVER_ERROR) - .json(createErrorObject(error.message)); - } - - public catch( - error: Error | HttpError, - req: Request, - res: Response, - next: NextFunction, - ): void { - if (error instanceof HttpError) { - return this.handleHttpError(error, req, res, next); - } - - return this.handleOtherError(error, req, res, next); + .json(createErrorObject(ApplicationError.ServiceError, error.message)); } } diff --git a/src/shared/libs/rest/exception-filter/http.exception-filter.ts b/src/shared/libs/rest/exception-filter/http.exception-filter.ts new file mode 100644 index 0000000..eadf442 --- /dev/null +++ b/src/shared/libs/rest/exception-filter/http.exception-filter.ts @@ -0,0 +1,36 @@ +import { Request, Response, NextFunction } from 'express'; +import { StatusCodes } from 'http-status-codes'; +import { inject, injectable } from 'inversify'; +import { ExceptionFilter } from './exception-filter.interface.js'; +import { Component } from '../../../types/index.js'; +import { Logger } from '../../logger/index.js'; +import { HttpError } from '../errors/http-error.js'; +import { createErrorObject } from '../../../helpers/index.js'; +import { ApplicationError } from '../types/application-error.enum.js'; + +@injectable() +export class HttpErrorExceptionFilter implements ExceptionFilter { + constructor(@inject(Component.Logger) private readonly logger: Logger) { + this.logger.info('Register HttpErrorExceptionFilter'); + } + + public catch( + error: unknown, + req: Request, + res: Response, + next: NextFunction, + ): void { + if (!(error instanceof HttpError)) { + return next(error); + } + + this.logger.error( + `[HttpErrorException]: ${req.path} # ${error.message}`, + error, + ); + + res + .status(StatusCodes.BAD_REQUEST) + .json(createErrorObject(ApplicationError.CommonError, error.message)); + } +} diff --git a/src/shared/libs/rest/exception-filter/validation.exception-filter.ts b/src/shared/libs/rest/exception-filter/validation.exception-filter.ts new file mode 100644 index 0000000..cd93721 --- /dev/null +++ b/src/shared/libs/rest/exception-filter/validation.exception-filter.ts @@ -0,0 +1,43 @@ +import { Request, Response, NextFunction } from 'express'; +import { StatusCodes } from 'http-status-codes'; +import { inject, injectable } from 'inversify'; +import { ExceptionFilter } from './exception-filter.interface.js'; +import { Component } from '../../../types/index.js'; +import { Logger } from '../../logger/index.js'; +import { ValidationError } from '../errors/validation.error.js'; +import { createErrorObject } from '../../../helpers/index.js'; +import { ApplicationError } from '../types/application-error.enum.js'; + +@injectable() +export class ValidationExceptionFilter implements ExceptionFilter { + constructor(@inject(Component.Logger) private readonly logger: Logger) { + this.logger.info('Register ValidationExceptionFilter'); + } + + public catch( + error: Error, + _req: Request, + res: Response, + next: NextFunction, + ): void { + if (!(error instanceof ValidationError)) { + return next(error); + } + + this.logger.error(`[ValidationException]: ${error.message}`, error); + + error.details.forEach((errorField) => + this.logger.warn(`[${errorField.property}] — ${errorField.messages}`), + ); + + res + .status(StatusCodes.BAD_REQUEST) + .json( + createErrorObject( + ApplicationError.ValidationError, + error.message, + error.details, + ), + ); + } +} diff --git a/src/shared/libs/rest/index.ts b/src/shared/libs/rest/index.ts index d8a47e9..729af2f 100644 --- a/src/shared/libs/rest/index.ts +++ b/src/shared/libs/rest/index.ts @@ -3,7 +3,7 @@ export * from './types/route.interface.js'; export * from './controller/controller.interface.js'; export * from './controller/base-controller.abstract.js'; export * from './exception-filter/exception-filter.interface.js'; -export * from './exception-filter/app-exception-filter.js'; +export * from './exception-filter/app.exception-filter.js'; export * from './types/request-params.type.js'; export * from './types/request-body.type.js'; export * from './types/request-query.type.js'; @@ -15,3 +15,9 @@ export * from './middleware/document-exists.middleware.js'; export * from './middleware/upload-file.middleware.js'; export * from './middleware/parse-token.middleware.js'; export * from './middleware/private-route.middleware.js'; +export * from './types/validation-error-field.type.js'; +export * from './types/application-error.enum.js'; +export * from './exception-filter/validation.exception-filter.js'; +export * from './exception-filter/http.exception-filter.js'; +export * from './middleware/upload-files.middleware.js'; +export * from './rest.constant.js'; diff --git a/src/shared/libs/rest/middleware/upload-file.middleware.ts b/src/shared/libs/rest/middleware/upload-file.middleware.ts index 8f8e7cb..3dff54d 100644 --- a/src/shared/libs/rest/middleware/upload-file.middleware.ts +++ b/src/shared/libs/rest/middleware/upload-file.middleware.ts @@ -3,11 +3,15 @@ import multer, { diskStorage } from 'multer'; import { extension } from 'mime-types'; import { nanoid } from 'nanoid'; import { Middleware } from './middleware.interface.js'; +import { StatusCodes } from 'http-status-codes'; +import { HttpError } from '../errors/index.js'; export class UploadFileMiddleware implements Middleware { constructor( private uploadDirectory: string, private fieldName: string, + private readonly allowedMimeTypes: string[], + private readonly maxFileSize: number, ) {} public async execute( @@ -24,10 +28,44 @@ export class UploadFileMiddleware implements Middleware { }, }); - const uploadSingleFileMiddleware = multer({ storage }).single( - this.fieldName, - ); + const uploadSingleFileMiddleware = multer({ + storage, + limits: { + fileSize: this.maxFileSize, + }, + fileFilter: (_req, file, callback) => { + if (!this.allowedMimeTypes.includes(file.mimetype)) { + return callback( + new HttpError( + StatusCodes.BAD_REQUEST, + 'Invalid file type', + 'UploadFileMiddleware', + ), + ); + } + + callback(null, true); + }, + }).single(this.fieldName); - uploadSingleFileMiddleware(req, res, next); + uploadSingleFileMiddleware(req, res, (error) => { + if (error instanceof multer.MulterError) { + if (error.code === 'LIMIT_FILE_SIZE') { + return next( + new HttpError( + StatusCodes.BAD_REQUEST, + 'File is too large', + 'UploadFileMiddleware', + ), + ); + } + } + + if (error) { + return next(error); + } + + return next(); + }); } } diff --git a/src/shared/libs/rest/middleware/upload-files.middleware.ts b/src/shared/libs/rest/middleware/upload-files.middleware.ts new file mode 100644 index 0000000..018cb78 --- /dev/null +++ b/src/shared/libs/rest/middleware/upload-files.middleware.ts @@ -0,0 +1,82 @@ +import { NextFunction, Request, Response } from 'express'; +import multer, { diskStorage } from 'multer'; +import { extension } from 'mime-types'; +import { nanoid } from 'nanoid'; +import { Middleware } from './middleware.interface.js'; +import { HttpError } from '../errors/index.js'; +import { StatusCodes } from 'http-status-codes'; + +export class UploadFilesMiddleware implements Middleware { + constructor( + private uploadDirectory: string, + private fieldName: string, + private readonly allowedMimeTypes: string[], + private readonly maxFileSize: number, + private readonly maxCount?: number, + ) {} + + public async execute( + req: Request, + res: Response, + next: NextFunction, + ): Promise { + const storage = diskStorage({ + destination: this.uploadDirectory, + filename: (_req, file, callback) => { + const fileExtention = extension(file.mimetype); + const filename = nanoid(); + callback(null, `${filename}.${fileExtention}`); + }, + }); + + const uploadFilesMiddleware = multer({ + storage, + limits: { + fileSize: this.maxFileSize, + }, + fileFilter: (_req, file, callback) => { + if (!this.allowedMimeTypes.includes(file.mimetype)) { + return callback( + new HttpError( + StatusCodes.BAD_REQUEST, + 'Invalid file type', + 'UploadFilesMiddleware', + ), + ); + } + + callback(null, true); + }, + }).array(this.fieldName, this.maxCount); + + uploadFilesMiddleware(req, res, (error) => { + if (error instanceof multer.MulterError) { + if (error.code === 'LIMIT_FILE_SIZE') { + return next( + new HttpError( + StatusCodes.BAD_REQUEST, + 'File is too large', + 'UploadFilesMiddleware', + ), + ); + } + + if (error.code === 'LIMIT_UNEXPECTED_FILE') { + return next( + new HttpError( + StatusCodes.BAD_REQUEST, + 'Too many files uploaded', + 'UploadFilesMiddleware', + ), + ); + } + } + + if (error) { + return next(error); + } + + return next(); + }); + } +} diff --git a/src/shared/libs/rest/middleware/validate-dto.middleware.ts b/src/shared/libs/rest/middleware/validate-dto.middleware.ts index 8915ebd..c629cbe 100644 --- a/src/shared/libs/rest/middleware/validate-dto.middleware.ts +++ b/src/shared/libs/rest/middleware/validate-dto.middleware.ts @@ -1,23 +1,26 @@ import { NextFunction, Request, Response } from 'express'; import { ClassConstructor, plainToInstance } from 'class-transformer'; import { validate } from 'class-validator'; -import { StatusCodes } from 'http-status-codes'; import { Middleware } from './middleware.interface.js'; +import { ValidationError } from '../errors/validation.error.js'; +import { reduceValidationErrors } from '../../../helpers/index.js'; export class ValidateDtoMiddleware implements Middleware { constructor(private dto: ClassConstructor) {} public async execute( - { body }: Request, - res: Response, + { body, path }: Request, + _res: Response, next: NextFunction, ): Promise { const dtoInstance = plainToInstance(this.dto, body); const errors = await validate(dtoInstance); if (errors.length > 0) { - res.status(StatusCodes.BAD_REQUEST).send(errors); - return; + throw new ValidationError( + `Validation error: ${path}`, + reduceValidationErrors(errors), + ); } next(); diff --git a/src/shared/libs/rest/rest.constant.ts b/src/shared/libs/rest/rest.constant.ts new file mode 100644 index 0000000..4dcffcf --- /dev/null +++ b/src/shared/libs/rest/rest.constant.ts @@ -0,0 +1,4 @@ +export const STATIC_UPLOAD_ROUTE = '/upload'; +export const STATIC_FILES_ROUTE = '/static'; +export const MAX_SINGLE_FILE_SIZE = 5 * 1024 * 1024; // 5 MB +export const ALLOWED_IMAGES = ['image/jpeg', 'image/png', 'image/webp']; diff --git a/src/shared/libs/rest/transform/path-transformer.constant.ts b/src/shared/libs/rest/transform/path-transformer.constant.ts new file mode 100644 index 0000000..fc17149 --- /dev/null +++ b/src/shared/libs/rest/transform/path-transformer.constant.ts @@ -0,0 +1,3 @@ +export const DEFAULT_STATIC_IMAGES = ['default-avatar.jpg']; + +export const STATIC_RESOURCE_FIELDS = ['avatarPath', 'image']; diff --git a/src/shared/libs/rest/transform/path-transformer.ts b/src/shared/libs/rest/transform/path-transformer.ts new file mode 100644 index 0000000..a9d483c --- /dev/null +++ b/src/shared/libs/rest/transform/path-transformer.ts @@ -0,0 +1,66 @@ +import { inject, injectable } from 'inversify'; +import { Component } from '../../../types/component.enum.js'; +import { Logger } from '../../logger/index.js'; +import { Config, RestSchema } from '../../config/index.js'; +import { + DEFAULT_STATIC_IMAGES, + STATIC_RESOURCE_FIELDS, +} from './path-transformer.constant.js'; +import { getFullServerPath } from '../../../helpers/index.js'; +import { STATIC_FILES_ROUTE, STATIC_UPLOAD_ROUTE } from '../rest.constant.js'; + +function isObject(value: unknown): value is Record { + return typeof value === 'object' && value !== null; +} + +@injectable() +export class PathTransformer { + constructor( + @inject(Component.Logger) private readonly logger: Logger, + @inject(Component.Config) private readonly config: Config, + ) { + this.logger.info('PathTranformer created!'); + } + + private hasDefaultImage(value: string) { + return DEFAULT_STATIC_IMAGES.includes(value); + } + + private isStaticProperty(property: string) { + return STATIC_RESOURCE_FIELDS.includes(property); + } + + public execute(data: Record): Record { + const stack = [data]; + + while (stack.length > 0) { + const current = stack.pop(); + + for (const key in current) { + if (Object.hasOwn(current, key)) { + const value = current[key]; + + if (isObject(value)) { + stack.push(value); + continue; + } + + if (this.isStaticProperty(key) && typeof value === 'string') { + const staticPath = STATIC_FILES_ROUTE; + const uploadPath = STATIC_UPLOAD_ROUTE; + const serverHost = this.config.get('HOST'); + const serverPort = this.config.get('PORT'); + + const rootPath = this.hasDefaultImage(value) + ? staticPath + : uploadPath; + current[key] = + `${getFullServerPath(serverHost, serverPort)}${rootPath}/${value}`; + } + } + } + } + + return data; + } +} diff --git a/src/shared/libs/rest/types/application-error.enum.ts b/src/shared/libs/rest/types/application-error.enum.ts new file mode 100644 index 0000000..1a2e3ee --- /dev/null +++ b/src/shared/libs/rest/types/application-error.enum.ts @@ -0,0 +1,5 @@ +export enum ApplicationError { + ValidationError = 'VALIDATION_ERROR', + CommonError = 'COMMON_ERROR', + ServiceError = 'SERVICE_ERROR', +} diff --git a/src/shared/libs/rest/types/validation-error-field.type.ts b/src/shared/libs/rest/types/validation-error-field.type.ts new file mode 100644 index 0000000..155246d --- /dev/null +++ b/src/shared/libs/rest/types/validation-error-field.type.ts @@ -0,0 +1,5 @@ +export type ValidationErrorField = { + property: string; + value: string; + messages: string[]; +}; diff --git a/src/shared/modules/offer/default-offer.service.ts b/src/shared/modules/offer/default-offer.service.ts index 16d9b82..d40dbf3 100644 --- a/src/shared/modules/offer/default-offer.service.ts +++ b/src/shared/modules/offer/default-offer.service.ts @@ -9,7 +9,7 @@ import { UpdateOfferDTO } from './dto/update-offer.dto.js'; import { DEFAULT_OFFER_COUNT, DEFAULT_PREMIUM_OFFER_COUNT, -} from './offer.constants.js'; +} from './offer.constant.js'; import { CommentEntity } from '../comment/index.js'; import { Types } from 'mongoose'; import { FavoriteEntity } from '../favorite/index.js'; diff --git a/src/shared/modules/offer/dto/create-offer.dto.ts b/src/shared/modules/offer/dto/create-offer.dto.ts index 011ed7e..0b2eaa1 100644 --- a/src/shared/modules/offer/dto/create-offer.dto.ts +++ b/src/shared/modules/offer/dto/create-offer.dto.ts @@ -1,6 +1,5 @@ /* eslint-disable indent */ import { - ArrayMaxSize, ArrayMinSize, IsArray, IsBoolean, @@ -8,7 +7,6 @@ import { IsEnum, IsIn, IsInt, - IsUrl, Max, MaxLength, Min, @@ -48,21 +46,6 @@ export class CreateOfferDTO { @IsEnum(City, { message: CreateOfferValidationMessage.city.invalid }) city: City; - @IsUrl( - {}, - { message: CreateOfferValidationMessage.previewImage.invalidFormat }, - ) - previewImage: string; - - @IsArray({ message: CreateOfferValidationMessage.images.invalidFormat }) - @ArrayMinSize(6, { - message: CreateOfferValidationMessage.images.invalidLength, - }) - @ArrayMaxSize(6, { - message: CreateOfferValidationMessage.images.invalidLength, - }) - images: string[]; - @IsBoolean({ message: CreateOfferValidationMessage.isPremium.invalidFormat }) isPremium: boolean; diff --git a/src/shared/modules/offer/dto/update-offer.dto.ts b/src/shared/modules/offer/dto/update-offer.dto.ts index 1fd63a3..7453e78 100644 --- a/src/shared/modules/offer/dto/update-offer.dto.ts +++ b/src/shared/modules/offer/dto/update-offer.dto.ts @@ -10,7 +10,6 @@ import { ArrayMinSize, ArrayMaxSize, IsBoolean, - IsNumber, Min, Max, IsIn, @@ -32,7 +31,7 @@ export class UpdateOfferDTO { @IsOptional() @MinLength(10, { message: CreateUpdateOfferMessage.title.minLength }) @MaxLength(100, { message: CreateUpdateOfferMessage.title.maxLength }) - title: string; + public title?: string; @IsOptional() @MinLength(20, { @@ -41,22 +40,22 @@ export class UpdateOfferDTO { @MaxLength(1024, { message: CreateUpdateOfferMessage.description.maxLength, }) - description: string; + public description?: string; @IsOptional() @IsDateString( {}, { message: CreateUpdateOfferMessage.publishDate.invalidFormat }, ) - publishDate: Date; + public publishDate?: Date; @IsOptional() @IsEnum(City, { message: CreateUpdateOfferMessage.city.invalid }) - city: City; + public city?: City; @IsOptional() @IsUrl({}, { message: CreateUpdateOfferMessage.previewImage.invalidFormat }) - previewImage: string; + public previewImage?: string; @IsOptional() @IsArray({ message: CreateUpdateOfferMessage.images.invalidFormat }) @@ -66,42 +65,33 @@ export class UpdateOfferDTO { @ArrayMaxSize(6, { message: CreateUpdateOfferMessage.images.invalidLength, }) - images: string[]; + public images?: string[]; @IsOptional() @IsBoolean({ message: CreateUpdateOfferMessage.isPremium.invalidFormat }) - isPremium: boolean; - - @IsOptional() - @IsNumber( - { maxDecimalPlaces: 1 }, - { message: CreateUpdateOfferMessage.rating.invalidFormat }, - ) - @Min(0, { message: CreateUpdateOfferMessage.rating.minValue }) - @Max(5, { message: CreateUpdateOfferMessage.rating.maxValue }) - rating: number; + public isPremium?: boolean; @IsOptional() @IsIn(HousingValues, { message: CreateUpdateOfferMessage.type.invalid }) - type: Housing; + public type?: Housing; @IsOptional() @IsInt({ message: CreateUpdateOfferMessage.rooms.invalidFormat }) @Min(1, { message: CreateUpdateOfferMessage.rooms.minValue }) @Max(8, { message: CreateUpdateOfferMessage.rooms.maxValue }) - rooms: number; + public rooms?: number; @IsOptional() @IsInt({ message: CreateUpdateOfferMessage.guests.invalidFormat }) @Min(1, { message: CreateUpdateOfferMessage.guests.minValue }) @Max(10, { message: CreateUpdateOfferMessage.guests.maxValue }) - guests: number; + public guests?: number; @IsOptional() @IsInt({ message: CreateUpdateOfferMessage.price.invalidFormat }) @Min(100, { message: CreateUpdateOfferMessage.price.minValue }) @Max(100000, { message: CreateUpdateOfferMessage.price.maxValue }) - price: number; + public price?: number; @IsOptional() @IsArray({ message: CreateUpdateOfferMessage.facilities.invalidFormat }) @@ -112,10 +102,10 @@ export class UpdateOfferDTO { each: true, message: CreateUpdateOfferMessage.facilities.invalidValue, }) - facilities: Facility[]; + public facilities?: Facility[]; @IsOptional() @ValidateNested({ message: CreateUpdateOfferMessage.location.invalid }) @Type(() => CoordinatesDto) - location: CoordinatesDto; + public location?: CoordinatesDto; } diff --git a/src/shared/modules/offer/index.ts b/src/shared/modules/offer/index.ts index 22f87bc..394962b 100644 --- a/src/shared/modules/offer/index.ts +++ b/src/shared/modules/offer/index.ts @@ -6,3 +6,5 @@ export * from './offer.container.js'; export * from './offer.controller.js'; export * from './rdo/offer.rdo.js'; export * from './dto/create-offer.messages.js'; +export * from './rdo/upload-images.rdo.js'; +export * from './rdo/upload-preview.rdo.js'; diff --git a/src/shared/modules/offer/offer.constants.ts b/src/shared/modules/offer/offer.constant.ts similarity index 69% rename from src/shared/modules/offer/offer.constants.ts rename to src/shared/modules/offer/offer.constant.ts index e02f6cb..ddbcd56 100644 --- a/src/shared/modules/offer/offer.constants.ts +++ b/src/shared/modules/offer/offer.constant.ts @@ -1,2 +1,3 @@ export const DEFAULT_OFFER_COUNT = 60; export const DEFAULT_PREMIUM_OFFER_COUNT = 3; +export const OFFER_IMAGES_LENGTH = 6; diff --git a/src/shared/modules/offer/offer.controller.ts b/src/shared/modules/offer/offer.controller.ts index 3011793..f7d52d0 100644 --- a/src/shared/modules/offer/offer.controller.ts +++ b/src/shared/modules/offer/offer.controller.ts @@ -2,12 +2,16 @@ import { StatusCodes } from 'http-status-codes'; import { inject, injectable } from 'inversify'; import { Request, Response } from 'express'; import { + ALLOWED_IMAGES, BaseController, DocumentExistsMiddleware, HttpError, HttpMethod, + MAX_SINGLE_FILE_SIZE, PrivateRouteMiddleware, RequestQuery, + UploadFileMiddleware, + UploadFilesMiddleware, ValidateDtoMiddleware, ValidateObjectIdMiddleware, } from '../../libs/rest/index.js'; @@ -23,6 +27,10 @@ import { ParamsDictionary } from 'express-serve-static-core'; import { CommentRdo, CommentService } from '../comment/index.js'; import { CreateOfferDTO } from './dto/create-offer.dto.js'; import { FavoriteService } from '../favorite/index.js'; +import { Config, RestSchema } from '../../libs/config/index.js'; +import { OFFER_IMAGES_LENGTH } from './offer.constant.js'; +import { UploadImagesRdo } from './rdo/upload-images.rdo.js'; +import { UploadPreviewRdo } from './rdo/upload-preview.rdo.js'; @injectable() export class OfferController extends BaseController { @@ -34,6 +42,8 @@ export class OfferController extends BaseController { private readonly commentService: CommentService, @inject(Component.FavoriteService) private readonly favoriteService: FavoriteService, + @inject(Component.Config) + private readonly configService: Config, ) { super(logger); @@ -122,6 +132,39 @@ export class OfferController extends BaseController { new DocumentExistsMiddleware(this.offerService, 'Offer', 'offerId'), ], }); + this.addRoute({ + path: '/:offerId/preview', + method: HttpMethod.Post, + handler: this.uploadPreview, + middlewares: [ + new PrivateRouteMiddleware(), + new ValidateObjectIdMiddleware('offerId'), + new DocumentExistsMiddleware(this.offerService, 'Offer', 'offerId'), + new UploadFileMiddleware( + this.configService.get('UPLOAD_DIRECTORY'), + 'preview', + ALLOWED_IMAGES, + MAX_SINGLE_FILE_SIZE, + ), + ], + }); + this.addRoute({ + path: '/:offerId/images', + method: HttpMethod.Post, + handler: this.uploadImages, + middlewares: [ + new PrivateRouteMiddleware(), + new ValidateObjectIdMiddleware('offerId'), + new DocumentExistsMiddleware(this.offerService, 'Offer', 'offerId'), + new UploadFilesMiddleware( + this.configService.get('UPLOAD_DIRECTORY'), + 'images', + ALLOWED_IMAGES, + MAX_SINGLE_FILE_SIZE, + OFFER_IMAGES_LENGTH, + ), + ], + }); } public async show( @@ -246,4 +289,48 @@ export class OfferController extends BaseController { this.ok(res, fillDTO(OfferRdo, offers)); } + + public async uploadPreview( + { params, file }: Request, + res: Response, + ): Promise { + const { offerId } = params; + + if (!file) { + throw new HttpError( + StatusCodes.UNPROCESSABLE_ENTITY, + 'Preview image is required', + 'OfferController', + ); + } + + const updatedOffer = await this.offerService.updateById(offerId, { + previewImage: file.filename, + }); + const previewRdo = fillDTO(UploadPreviewRdo, updatedOffer); + this.created(res, previewRdo); + } + + public async uploadImages( + { params, files }: Request, + res: Response, + ): Promise { + const { offerId } = params; + const filesToUpload = Array.isArray(files) ? files : undefined; + + if (!filesToUpload || filesToUpload.length !== OFFER_IMAGES_LENGTH) { + throw new HttpError( + StatusCodes.UNPROCESSABLE_ENTITY, + `Images must contain exactly ${OFFER_IMAGES_LENGTH} items`, + 'OfferController', + ); + } + + const images = filesToUpload.map((file) => file.filename); + const updatedOffer = await this.offerService.updateById(offerId, { + images, + }); + const imagesRdo = fillDTO(UploadImagesRdo, updatedOffer); + this.created(res, imagesRdo); + } } diff --git a/src/shared/modules/offer/rdo/upload-images.rdo.ts b/src/shared/modules/offer/rdo/upload-images.rdo.ts new file mode 100644 index 0000000..ba1b417 --- /dev/null +++ b/src/shared/modules/offer/rdo/upload-images.rdo.ts @@ -0,0 +1,6 @@ +import { Expose } from 'class-transformer'; + +export class UploadImagesRdo { + @Expose() + public images: string[]; +} diff --git a/src/shared/modules/offer/rdo/upload-preview.rdo.ts b/src/shared/modules/offer/rdo/upload-preview.rdo.ts new file mode 100644 index 0000000..c69ca34 --- /dev/null +++ b/src/shared/modules/offer/rdo/upload-preview.rdo.ts @@ -0,0 +1,6 @@ +import { Expose } from 'class-transformer'; + +export class UploadPreviewRdo { + @Expose() + public previewImage: string; +} diff --git a/src/shared/modules/offer/types/param-offerid.type.ts b/src/shared/modules/offer/types/param-offerid.type.ts index ad18197..a2bcc32 100644 --- a/src/shared/modules/offer/types/param-offerid.type.ts +++ b/src/shared/modules/offer/types/param-offerid.type.ts @@ -1,5 +1,3 @@ -// import { ParamsDictionary } from 'express-serve-static-core'; - export type ParamOfferId = { offerId: string; }; diff --git a/src/shared/modules/user/default-user.service.ts b/src/shared/modules/user/default-user.service.ts index e32447e..7348580 100644 --- a/src/shared/modules/user/default-user.service.ts +++ b/src/shared/modules/user/default-user.service.ts @@ -6,6 +6,7 @@ import { inject, injectable } from 'inversify'; import { Component } from '../../types/index.js'; import { Logger } from '../../libs/logger/index.js'; import { UpdateUserDTO } from './dto/update-user.dto.js'; +import { DEFAULT_AVATAR_FILE_NAME } from './user.constant.js'; @injectable() export class DefaultUserService implements UserService { @@ -19,7 +20,10 @@ export class DefaultUserService implements UserService { dto: CreateUserDto, salt: string, ): Promise> { - const user = new UserEntity(dto); + const user = new UserEntity({ + ...dto, + avatarPath: DEFAULT_AVATAR_FILE_NAME, + }); user.setPassword(dto.password, salt); const result = await this.userModel.create(user); diff --git a/src/shared/modules/user/dto/create-user.dto.ts b/src/shared/modules/user/dto/create-user.dto.ts index 4a44190..3064d59 100644 --- a/src/shared/modules/user/dto/create-user.dto.ts +++ b/src/shared/modules/user/dto/create-user.dto.ts @@ -6,9 +6,6 @@ export class CreateUserDto { @IsEmail({}, { message: CreateUserMessages.email.invalidFormat }) public email: string; - @IsString({ message: CreateUserMessages.avatarPath.invalidFormat }) - public avatarPath: string; - @IsString({ message: CreateUserMessages.firstname.invalidFormat }) @Length(1, 15, { message: CreateUserMessages.firstname.lengthField }) public firstname: string; diff --git a/src/shared/modules/user/dto/create-user.messages.ts b/src/shared/modules/user/dto/create-user.messages.ts index c7f2925..e28658f 100644 --- a/src/shared/modules/user/dto/create-user.messages.ts +++ b/src/shared/modules/user/dto/create-user.messages.ts @@ -2,9 +2,6 @@ export const CreateUserMessages = { email: { invalidFormat: 'email must be a valid address', }, - avatarPath: { - invalidFormat: 'avatarPath is required', - }, firstname: { invalidFormat: 'firstname is required', lengthField: 'min length is 1, max is 15', diff --git a/src/shared/modules/user/index.ts b/src/shared/modules/user/index.ts index 9fcf0b8..7484818 100644 --- a/src/shared/modules/user/index.ts +++ b/src/shared/modules/user/index.ts @@ -5,3 +5,4 @@ export * from './default-user.service.js'; export * from './user.container.js'; export * from './user-service.interface.js'; export * from './user.controller.js'; +export * from './user.constant.js'; diff --git a/src/shared/modules/user/rdo/logged-user.rdo.ts b/src/shared/modules/user/rdo/logged-user.rdo.ts index 543bd7d..fba093a 100644 --- a/src/shared/modules/user/rdo/logged-user.rdo.ts +++ b/src/shared/modules/user/rdo/logged-user.rdo.ts @@ -6,4 +6,13 @@ export class LoggedUserRdo { @Expose() public email: string; + + @Expose() + public avatarPath: string; + + @Expose() + public firstname: string; + + @Expose() + public lastname: string; } diff --git a/src/shared/modules/user/rdo/upload-user-avatar.rdo.ts b/src/shared/modules/user/rdo/upload-user-avatar.rdo.ts new file mode 100644 index 0000000..8514657 --- /dev/null +++ b/src/shared/modules/user/rdo/upload-user-avatar.rdo.ts @@ -0,0 +1,6 @@ +import { Expose } from 'class-transformer'; + +export class UploadUserAvatarRdo { + @Expose() + public filepath: string; +} diff --git a/src/shared/modules/user/rdo/user.rdo.ts b/src/shared/modules/user/rdo/user.rdo.ts index 7de404d..1f7aa85 100644 --- a/src/shared/modules/user/rdo/user.rdo.ts +++ b/src/shared/modules/user/rdo/user.rdo.ts @@ -1,6 +1,9 @@ import { Expose } from 'class-transformer'; export class UserRdo { + @Expose() + public id: string; + @Expose() public email: string; diff --git a/src/shared/modules/user/types/param-userid.type.ts b/src/shared/modules/user/types/param-userid.type.ts new file mode 100644 index 0000000..4c63ac7 --- /dev/null +++ b/src/shared/modules/user/types/param-userid.type.ts @@ -0,0 +1,3 @@ +export type ParamUserId = { + userId: string; +}; diff --git a/src/shared/modules/user/user.constant.ts b/src/shared/modules/user/user.constant.ts new file mode 100644 index 0000000..29633af --- /dev/null +++ b/src/shared/modules/user/user.constant.ts @@ -0,0 +1 @@ +export const DEFAULT_AVATAR_FILE_NAME = 'default-avatar.jpg'; diff --git a/src/shared/modules/user/user.controller.ts b/src/shared/modules/user/user.controller.ts index 21d0d89..4cd416c 100644 --- a/src/shared/modules/user/user.controller.ts +++ b/src/shared/modules/user/user.controller.ts @@ -2,9 +2,11 @@ import { Request, Response } from 'express'; import { StatusCodes } from 'http-status-codes'; import { inject, injectable } from 'inversify'; import { + ALLOWED_IMAGES, BaseController, HttpError, HttpMethod, + MAX_SINGLE_FILE_SIZE, UploadFileMiddleware, ValidateDtoMiddleware, ValidateObjectIdMiddleware, @@ -21,6 +23,8 @@ import { CreateUserDto } from './dto/create-user.dto.js'; import { LoginUserDto } from './dto/login-user.dto.js'; import { AuthService } from '../auth/index.js'; import { LoggedUserRdo } from './rdo/logged-user.rdo.js'; +import { ParamUserId } from './types/param-userid.type.js'; +import { UploadUserAvatarRdo } from './rdo/upload-user-avatar.rdo.js'; @injectable() export class UserController extends BaseController { @@ -59,6 +63,8 @@ export class UserController extends BaseController { new UploadFileMiddleware( this.configService.get('UPLOAD_DIRECTORY'), 'avatar', + ALLOWED_IMAGES, + MAX_SINGLE_FILE_SIZE, ), ], }); @@ -88,17 +94,21 @@ export class UserController extends BaseController { public async login({ body }: LoginUserRequest, res: Response): Promise { const user = await this.authService.verify(body); const token = await this.authService.authenticate(user); - const responseData = fillDTO(LoggedUserRdo, { - email: user.email, - token, - }); - this.ok(res, responseData); + const responseData = fillDTO(LoggedUserRdo, user); + this.ok(res, Object.assign(responseData, { token })); } - public async uploadAvatar(req: Request, res: Response) { - this.created(res, { - filepath: req.file?.path, - }); + public async uploadAvatar( + { params, file }: Request, + res: Response, + ) { + const { userId } = params; + const uploadFile = { avatarPath: file?.filename }; + await this.userService.updateById(userId, uploadFile); + this.created( + res, + fillDTO(UploadUserAvatarRdo, { filepath: uploadFile.avatarPath }), + ); } public async checkAuth({ tokenPayload: { email } }: Request, res: Response) { diff --git a/src/shared/types/component.enum.ts b/src/shared/types/component.enum.ts index 1e11b8b..3bf1049 100644 --- a/src/shared/types/component.enum.ts +++ b/src/shared/types/component.enum.ts @@ -17,4 +17,7 @@ export const Component = { AuthExceptionFilter: Symbol.for('AuthExceptionFilter'), FavoriteService: Symbol.for('FavoriteService'), FavoriteModel: Symbol.for('FavoriteModel'), + HttpExceptionFilter: Symbol.for('HttpExceptionFilter'), + ValidationExceptionFilter: Symbol.for('ValidationExceptionFilter'), + PathTransformer: Symbol.for('PathTransformer'), } as const; diff --git a/static/default-avatar.jpg b/static/default-avatar.jpg new file mode 100644 index 0000000..a5ae95a Binary files /dev/null and b/static/default-avatar.jpg differ