diff --git a/.eslintrc.js b/.eslintrc.js new file mode 100644 index 0000000..259de13 --- /dev/null +++ b/.eslintrc.js @@ -0,0 +1,25 @@ +module.exports = { + parser: '@typescript-eslint/parser', + parserOptions: { + project: 'tsconfig.json', + tsconfigRootDir: __dirname, + sourceType: 'module', + }, + plugins: ['@typescript-eslint/eslint-plugin'], + extends: [ + 'plugin:@typescript-eslint/recommended', + 'plugin:prettier/recommended', + ], + root: true, + env: { + node: true, + jest: true, + }, + ignorePatterns: ['.eslintrc.js'], + rules: { + '@typescript-eslint/interface-name-prefix': 'off', + '@typescript-eslint/explicit-function-return-type': 'off', + '@typescript-eslint/explicit-module-boundary-types': 'off', + '@typescript-eslint/no-explicit-any': 'off', + }, +}; diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml new file mode 100644 index 0000000..f3385c7 --- /dev/null +++ b/.github/workflows/build.yaml @@ -0,0 +1,42 @@ +name: Regular lint, build and deploy to private registry (if commit message contains ~deploy~) + +on: + push: + branches: + - main + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Install dependencies and lint, then build + uses: actions/setup-node@v3 + with: + node-version: '18.x' + - run: npm i -f + - run: npm lint + - run: npm run build + docker: + name: Create docker image and push to private registry + runs-on: ubuntu-latest + if: "contains(github.event.head_commit.message, '~deploy~')" + steps: + - name: Checkout + uses: actions/checkout@v2 + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v1 + - name: Login to registry + uses: docker/login-action@v2 + with: + context: . + registry: ${{ secrets.REGISTRY_URL }} + username: ${{ secrets.REGISTRY_LOGIN }} + password: ${{ secrets.REGISTRY_PASSWORD }} + - name: Build and push + uses: docker/build-push-action@v4 + with: + push: true + tags: ${{ secrets.REGISTRY_URL }}/${{ github.event.repository.name }}:latest + env: + DOCKER_BUILDKIT: 1 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e973c5d --- /dev/null +++ b/.gitignore @@ -0,0 +1,38 @@ +# compiled output +/dist +/node_modules + +# Logs +logs +*.log +npm-debug.log* +pnpm-debug.log* +yarn-debug.log* +yarn-error.log* +lerna-debug.log* +.env +browser-cache +# OS +yarn.lock +params.txt +.DS_Store + +# Tests +/coverage +/.nyc_output + +# IDEs and editors +/.idea +.project +.classpath +.c9/ +*.launch +.settings/ +*.sublime-workspace + +# IDE - VSCode +.vscode/* +!.vscode/settings.json +!.vscode/tasks.json +!.vscode/launch.json +!.vscode/extensions.json diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..dcb7279 --- /dev/null +++ b/.prettierrc @@ -0,0 +1,4 @@ +{ + "singleQuote": true, + "trailingComma": "all" +} \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..46f6623 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,18 @@ +FROM node:18 + +# Setup workdir and copy package.json +WORKDIR /usr/src/app +COPY package*.json ./ + +# Install dependencies with ignore flag +RUN npm i --force + +COPY . . + +# Build the app +RUN npm run build + +# Expose port 3000 +EXPOSE 3000 + +CMD ["npm", "start"] diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..8aa2645 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) [year] [fullname] + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..b922ac7 --- /dev/null +++ b/README.md @@ -0,0 +1,203 @@ +# 🥝 QIWI Reverse API + +## Что это и зачем? + +С [недавних пор](https://developer.qiwi.com/ru/qiwi-wallet-personal/#auth_param) QIWI закрыли возможность получения OAuth-токенов: + +> **Мы остановили выпуск OAuth-токенов. Приносим извинения за доставленные неудобства.** +> +> _Источник: [developer.qiwi.com/ru/qiwi-wallet-personal](https://developer.qiwi.com/ru/qiwi-wallet-personal/#auth_param)_ + +Благодаря этому API, осуществляющему получение **куки** и **access_token** из вашего QIWI-кошелька через аутентификацию с помощью телефона и пароля, а также обновляющему их каждые два часа, продолжение использования API становится возможным, хотя и менее удобным. + +## Как происходит получение токена? + +Под капотом [puppeteer](https://github.com/puppeteer/puppeteer), [puppeteer-extra-plugin-stealth](https://www.npmjs.com/package/puppeteer-extra-plugin-stealth) и [puppeteer-extra](https://www.npmjs.com/package/puppeteer-extra). + +Запускаем браузер, входим, достаем параметр из _куки_, а тажке токен из _localstorage_. + +## Начало работы + +Склонируйте репозиторий в нужную вам директорию: + +```bash +git clone https://github.com/LukasAndreano/qiwi-reverse-api.git +``` + +Установите зависимости: + +```bash +cd +yarn +``` + +Пропишите правильные доступы к БД, создав файл **.env** (он не идет в GIT, игнорируется), используя **env.example**. После чего запустите сервинг: + +```bash +yarn dev +``` + +## Todo + +- [ ] Авто-деплой на локальный Docker Registry +- [ ] Написать автотесты + +## Методы API + +Все методы для работы описаны в документации, расположенной по адресу: + +> [http://localhost:3000/docs](http://localhost:3000/docs) +> +> Логин и пароль для входа в Swagger: **devs** (если не указан иной) + +Однако, дополнительно дублирую: + +### /auth POST + +```json +{ + "phone": "79999999999", + "password": "YourPassword" +} +``` + +Вернет: + +```json +{ + "status": true, + "statusCode": 200, + "data": { + "access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6MywidHlwZSI6ImFjY2Vzc190b2tlbiIsImlhdCI6MTY5OTg4Mjg5OSwiZXhwIjoxNjk5OTY5Mjk5fQ.R0njmshiqkzZWQNObJtRw6RzCfC7DHNsoJiOnoZVHM0", + "refresh_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6ImE3NTA5YjMxLWVjMDAtNDlhZi1hZDhmLWExMTExNmE2NGE0MCIsInVzZXJfaWQiOjMsInR5cGUiOiJyZWZyZXNoX3Rva2VuIiwiaWF0IjoxNjk5ODgyODk5LCJleHAiOjE3MDI0NzQ4OTl9.rkWCHDWlR-m_-Pqh4F0Grw3HNpTlazTBVimu-sKwdpY" + } +} +``` + +> Время жизни **access_token** - 1 день, **refresh_token** - 30 дней. + +### /auth/refresh POST + +body: + +```json +{ + "refresh_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6ImE3NTA5YjMxLWVjMDAtNDlhZi1hZDhmLWExMTExNmE2NGE0MCIsInVzZXJfaWQiOjMsInR5cGUiOiJyZWZyZXNoX3Rva2VuIiwiaWF0IjoxNjk5ODgyODk5LCJleHAiOjE3MDI0NzQ4OTl9.rkWCHDWlR-m_-Pqh4F0Grw3HNpTlazTBVimu-sKwdpY" +} +``` + +Вернет: + +```json +{ + "status": true, + "statusCode": 200, + "data": { + "access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6MywidHlwZSI6ImFjY2Vzc190b2tlbiIsImlhdCI6MTY5OTg4Mjg5OSwiZXhwIjoxNjk5OTY5Mjk5fQ.R0njmshiqkzZWQNObJtRw6RzCfC7DHNsoJiOnoZVHM0", + "refresh_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6ImE3NTA5YjMxLWVjMDAtNDlhZi1hZDhmLWExMTExNmE2NGE0MCIsInVzZXJfaWQiOjMsInR5cGUiOiJyZWZyZXNoX3Rva2VuIiwiaWF0IjoxNjk5ODgyODk5LCJleHAiOjE3MDI0NzQ4OTl9.rkWCHDWlR-m_-Pqh4F0Grw3HNpTlazTBVimu-sKwdpY" + } +} +``` + +### /request POST + +body: + +```json +{ + "method": "GET", + "endpoint": "/payment-history/v2/persons/79999999999/payments", + "params": { + "rows": 10, + "operation": "IN" + } +} +``` + +Ответ (изменен в целях безопасности): + +```json +{ + "status": true, + "statusCode": 201, + "data": { + "data": [ + { + "txnId": 0, + "personId": 0, + "date": "2022-10-13T14:45:27+03:00", + "errorCode": 0, + "error": null, + "status": "SUCCESS", + "type": "IN", + "statusText": "Success", + "trmTxnId": "0", + "account": "0", + "sum": { + "amount": 1000, + "currency": 398 + }, + "commission": { + "amount": 0, + "currency": 398 + }, + "total": { + "amount": 190000, + "currency": 398 + }, + "provider": { + "id": 4, + "shortName": "Платеж с терминала", + "longName": "Платеж с терминала", + "logoUrl": null, + "description": null, + "keys": null, + "siteUrl": null, + "extras": [] + }, + "source": { + "id": 99, + "shortName": "Перевод на QIWI Кошелек", + "longName": null, + "logoUrl": "https://static.qiwi.com/img/providers/logoBig/99_l.png", + "description": null, + "keys": "пополнить, перевести, qiwi, кошелек, оплатить, онлайн, оплата, счет, способ, услуга, перевод", + "siteUrl": "https://www.qiwi.com", + "extras": [ + { + "key": "seo_description", + "value": "Пополнение QIWI Кошелька банковской картой без комиссии от 2000 руб., со счета мобильного телефона или наличными через QIWI Терминалы. Оплачивать услуги стало проще." + }, + { + "key": "seo_title", + "value": "Пополнить QIWI Кошелек: с банковской карты, с баланса телефона, через QIWI Кошелек" + } + ] + }, + "comment": null, + "currencyRate": 1, + "paymentExtras": [], + "features": { + "chequeReady": false, + "bankDocumentReady": false, + "regularPaymentEnabled": false, + "bankDocumentAvailable": false, + "repeatPaymentEnabled": false, + "favoritePaymentEnabled": false, + "chatAvailable": false, + "greetingCardAttached": false + }, + "serviceExtras": {}, + "view": { + "title": "Платеж с терминала", + "account": "0" + } + } + ], + "nextTxnId": 0, + "nextTxnDate": "2022-10-13T14:45:27+03:00" + } +} +``` + +> Если метод **GET** и имеет **query** параметры, то необходимо их передавать в **params**, как и в случае с **body** diff --git a/env.example b/env.example new file mode 100644 index 0000000..a2028ad --- /dev/null +++ b/env.example @@ -0,0 +1,10 @@ +PORT=3000 + +DB_HOST=localhost +DB_PORT=8889 +DB_USER=qiwi +DB_NAME=qiwi +DB_PASSWORD=qiwi + +DOCS_PASSWORD=devs +JWT_SECRET=supersecretjwtkey diff --git a/nest-cli.json b/nest-cli.json new file mode 100644 index 0000000..f9aa683 --- /dev/null +++ b/nest-cli.json @@ -0,0 +1,8 @@ +{ + "$schema": "https://json.schemastore.org/nest-cli", + "collection": "@nestjs/schematics", + "sourceRoot": "src", + "compilerOptions": { + "deleteOutDir": true + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..f68e4e7 --- /dev/null +++ b/package.json @@ -0,0 +1,75 @@ +{ + "name": "qiwi-reverse-api", + "version": "1.0.0", + "description": "QIWI Reverse API", + "author": "Nikita Balin ", + "private": false, + "license": "MIT", + "scripts": { + "build": "nest build", + "start": "nest start", + "dev": "nest start --watch", + "lint": "eslint \"src/**/*.ts\" --fix", + "prettify": "prettier --write \"{src,}/**/*.ts\"" + }, + "dependencies": { + "@nestjs/common": "^9.0.0", + "@nestjs/config": "^2.2.0", + "@nestjs/core": "^9.0.0", + "@nestjs/platform-express": "^9.2.1", + "@nestjs/schedule": "^3.0.3", + "@nestjs/swagger": "^6.1.4", + "@nestjs/typeorm": "^9.0.1", + "axios": "^1.3.0", + "class-transformer": "^0.5.1", + "class-validator": "^0.14.0", + "express-basic-auth": "^1.2.1", + "jsonwebtoken": "^9.0.1", + "mysql2": "^3.1.0", + "puppeteer": "^21.1.0", + "puppeteer-extra": "^3.3.6", + "puppeteer-extra-plugin-stealth": "^2.11.2", + "reflect-metadata": "^0.1.13", + "rimraf": "^4.1.2", + "rxjs": "^7.8.0", + "swagger-ui-express": "^4.6.0", + "typeorm": "^0.3.11", + "user-agents": "^1.0.1444", + "uuid": "^9.0.0" + }, + "devDependencies": { + "@nestjs/cli": "^9.0.0", + "@nestjs/schematics": "^9.0.0", + "@types/express": "^4.17.13", + "@types/jsonwebtoken": "^9.0.5", + "@types/node": "18.11.18", + "@typescript-eslint/eslint-plugin": "^5.0.0", + "@typescript-eslint/parser": "^5.0.0", + "eslint": "^8.0.1", + "eslint-config-prettier": "^8.3.0", + "eslint-plugin-prettier": "^4.0.0", + "prettier": "^2.3.2", + "source-map-support": "^0.5.20", + "ts-loader": "^9.2.3", + "ts-node": "^10.0.0", + "tsconfig-paths": "4.1.1", + "typescript": "^4.7.4" + }, + "jest": { + "moduleFileExtensions": [ + "js", + "json", + "ts" + ], + "rootDir": "src", + "testRegex": ".*\\.spec\\.ts$", + "transform": { + "^.+\\.(t|j)s$": "ts-jest" + }, + "collectCoverageFrom": [ + "**/*.(t|j)s" + ], + "coverageDirectory": "../coverage", + "testEnvironment": "node" + } +} diff --git a/src/app.module.ts b/src/app.module.ts new file mode 100644 index 0000000..73461c4 --- /dev/null +++ b/src/app.module.ts @@ -0,0 +1,51 @@ +import { MiddlewareConsumer, Module, NestModule } from '@nestjs/common'; +import { ConfigModule } from '@nestjs/config'; +import { APP_INTERCEPTOR } from '@nestjs/core'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { ResInterceptor } from './interceptors/res.interceptor'; +import { ParamsMiddleware } from './middleware/params/params.middleware'; +import { StartParamsModule } from './middleware/params/params.module'; +import { AuthModule } from './controllers/auth/auth.module'; +import { TasksModule } from './tasks/tasks.module'; +import { ScheduleModule } from '@nestjs/schedule'; +import { RequestModule } from './controllers/request/request.module'; + +@Module({ + imports: [ + ScheduleModule.forRoot(), + ConfigModule.forRoot({ + envFilePath: `.env`, + }), + TypeOrmModule.forRoot({ + type: 'mysql', + extra: { + connectionLimit: +process.env.DB_CONNECTION_LIMIT || 10, + }, + host: process.env.DB_HOST, + port: Number(process.env.DB_PORT) || 3306, + username: process.env.DB_USER, + charset: process.env.DB_CHARSET || 'utf8mb4_general_ci', + password: process.env.DB_PASSWORD, + database: process.env.DB_NAME, + entities: [__dirname + '/entities/*.entity.{js,ts}'], + synchronize: process.env.DB_SYNC === 'true', + cache: false, + }), + StartParamsModule, + AuthModule, + TasksModule, + RequestModule, + ], + controllers: [], + providers: [ + { + provide: APP_INTERCEPTOR, + useClass: ResInterceptor, + }, + ], +}) +export class AppModule implements NestModule { + configure(consumer: MiddlewareConsumer) { + consumer.apply(ParamsMiddleware).exclude('auth/(.*)?').forRoutes('*'); + } +} diff --git a/src/controllers/auth/auth.controller.ts b/src/controllers/auth/auth.controller.ts new file mode 100644 index 0000000..5b12d8c --- /dev/null +++ b/src/controllers/auth/auth.controller.ts @@ -0,0 +1,41 @@ +import { Body, Controller, HttpCode, Post } from '@nestjs/common'; +import { ApiResponse, ApiOperation, ApiTags } from '@nestjs/swagger'; +import { AuthService } from './auth.service'; +import { AuthData } from './dto/auth-data.dto'; +import { AuthBody, RefreshBody } from './dto/auth-body.dto'; + +@ApiTags('Модуль авторизации') +@Controller('auth') +export class AuthController { + constructor(private readonly authService: AuthService) {} + + @Post() + @ApiOperation({ + summary: 'Авторизация по номеру телефона + паролю', + }) + @ApiResponse({ + status: 200, + type: AuthData, + }) + @ApiResponse({ + status: 201, + type: AuthData, + }) + @HttpCode(200) + async signin(@Body() body: AuthBody): Promise { + return await this.authService.signin(body); + } + + @Post('/refresh') + @ApiOperation({ + summary: 'Обновление токена', + }) + @ApiResponse({ + status: 200, + type: AuthData, + }) + @HttpCode(200) + async refresh(@Body() body: RefreshBody): Promise { + return await this.authService.refresh(body); + } +} diff --git a/src/controllers/auth/auth.module.ts b/src/controllers/auth/auth.module.ts new file mode 100644 index 0000000..0ef310d --- /dev/null +++ b/src/controllers/auth/auth.module.ts @@ -0,0 +1,14 @@ +import { Module } from '@nestjs/common'; +import { AuthController } from './auth.controller'; +import { AuthService } from './auth.service'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { AuthTokens } from 'src/entities/auth_tokens.entity'; +import { Users } from 'src/entities/users.entity'; +import { Tokens } from 'src/entities/tokens.entity'; + +@Module({ + controllers: [AuthController], + providers: [AuthService], + imports: [TypeOrmModule.forFeature([Users, Tokens, AuthTokens])], +}) +export class AuthModule {} diff --git a/src/controllers/auth/auth.service.ts b/src/controllers/auth/auth.service.ts new file mode 100644 index 0000000..5b005e7 --- /dev/null +++ b/src/controllers/auth/auth.service.ts @@ -0,0 +1,177 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Users } from 'src/entities/users.entity'; +import { updateTokens } from 'src/utils/authHelper.utils'; +import { Repository } from 'typeorm'; +import { AuthBody, RefreshBody } from './dto/auth-body.dto'; +import { AuthData } from './dto/auth-data.dto'; +import Errors from 'src/errors.enum'; +import errorGenerator from 'src/utils/errorGenerator.utils'; +import getCurrentTimestamp from 'src/utils/getCurrentTimestamp.utils'; +import * as jwt from 'jsonwebtoken'; +import { AuthTokens } from 'src/entities/auth_tokens.entity'; +import { Tokens } from 'src/entities/tokens.entity'; +import getQiwiToken from 'src/utils/getQiwiToken.utils'; + +@Injectable() +export class AuthService { + constructor( + @InjectRepository(Users) + private readonly usersRepository: Repository, + + @InjectRepository(AuthTokens) + private readonly authTokensRepository: Repository, + + @InjectRepository(Tokens) + private readonly tokensRepository: Repository, + ) {} + + async refresh(body: RefreshBody): Promise { + try { + const refresh: any = jwt.verify( + body.refresh_token, + process.env.JWT_SECRET, + ); + + if (refresh.type !== 'refresh_token') + errorGenerator(Errors.AUTH_PARAMS_NOT_VALID); + + const findInDB = await this.authTokensRepository + .createQueryBuilder('auth_tokens') + .select(['auth_tokens.id as id']) + .where('auth_tokens.refresh_token = :refresh_token', { + refresh_token: body.refresh_token, + }) + .andWhere('auth_tokens.created_by = :created_by', { + created_by: refresh.user_id, + }) + .getRawOne(); + + if (!findInDB) errorGenerator(Errors.AUTH_PARAMS_NOT_VALID); + + const tokens = await updateTokens(refresh.user_id); + + await this.authTokensRepository + .createQueryBuilder('auth_tokens') + .delete() + .where('auth_tokens.id = :id', { id: findInDB.id }) + .andWhere('auth_tokens.created_by = :created_by', { + created_by: refresh.user_id, + }) + .execute(); + + await this.authTokensRepository + .createQueryBuilder('auth_tokens') + .insert() + .into(AuthTokens) + .values({ + created_at: getCurrentTimestamp(), + created_by: refresh.user_id, + refresh_token: tokens.refresh_token, + }) + .execute(); + + return { + access_token: tokens.access_token, + refresh_token: tokens.refresh_token, + }; + } catch (e) { + console.log(e); + errorGenerator(Errors.AUTH_PARAMS_NOT_VALID); + } + } + + async signin(body: AuthBody): Promise { + let access_token = await getQiwiToken( + body.phone.replace('+', ''), + body.password, + ); + + if (!access_token) errorGenerator(Errors.CANT_GET_TOKEN); + + access_token = String(access_token); + + const findUser = await this.usersRepository + .createQueryBuilder('users') + .select(['users.id as id']) + .where('users.phone = :phone', { + phone: body.phone, + }) + .andWhere('users.password = :password', { + password: body.password, + }) + .getRawOne(); + + if (!findUser) { + const insertUser = await this.usersRepository + .createQueryBuilder('users') + .insert() + .into(Users) + .values({ + phone: body.phone, + password: body.password, + updated_at: getCurrentTimestamp(), + joined_at: getCurrentTimestamp(), + }) + .execute(); + + const tokens = await updateTokens(insertUser.raw.insertId); + + await this.tokensRepository + .createQueryBuilder('tokens') + .insert() + .into(Tokens) + .values({ + created_at: getCurrentTimestamp(), + created_by: insertUser.raw.insertId, + access_token, + }) + .execute(); + + await this.authTokensRepository + .createQueryBuilder('auth_tokens') + .insert() + .into(AuthTokens) + .values({ + created_at: getCurrentTimestamp(), + created_by: insertUser.raw.insertId, + refresh_token: tokens.refresh_token, + }) + .execute(); + + return { + access_token: tokens.access_token, + refresh_token: tokens.refresh_token, + }; + } else { + const tokens = await updateTokens(findUser.id); + + await this.tokensRepository + .createQueryBuilder('tokens') + .insert() + .into(Tokens) + .values({ + created_at: getCurrentTimestamp(), + created_by: findUser.id, + access_token, + }) + .execute(); + + await this.authTokensRepository + .createQueryBuilder('auth_tokens') + .insert() + .into(AuthTokens) + .values({ + created_at: getCurrentTimestamp(), + created_by: findUser.id, + refresh_token: tokens.refresh_token, + }) + .execute(); + + return { + access_token: tokens.access_token, + refresh_token: tokens.refresh_token, + }; + } + } +} diff --git a/src/controllers/auth/dto/auth-body.dto.ts b/src/controllers/auth/dto/auth-body.dto.ts new file mode 100644 index 0000000..0f620e8 --- /dev/null +++ b/src/controllers/auth/dto/auth-body.dto.ts @@ -0,0 +1,30 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsPhoneNumber, IsString, Length } from 'class-validator'; + +export class AuthBody { + @ApiProperty({ + description: 'Телефон пользователя', + example: '79123456789', + }) + @Length(1, 11) + @IsPhoneNumber('RU') + phone: string; + + @ApiProperty({ + description: 'Пароль от QIWI', + example: 'PASSWORD_FROM_QIWI', + }) + @Length(6, 64) + @IsString() + password: string; +} + +export class RefreshBody { + @ApiProperty({ + description: 'refresh_token пользователя', + example: 'refresh_token', + }) + @IsString() + @Length(100, 200) + refresh_token: string; +} diff --git a/src/controllers/auth/dto/auth-data.dto.ts b/src/controllers/auth/dto/auth-data.dto.ts new file mode 100644 index 0000000..655833c --- /dev/null +++ b/src/controllers/auth/dto/auth-data.dto.ts @@ -0,0 +1,15 @@ +import { ApiProperty } from '@nestjs/swagger'; + +export class AuthData { + @ApiProperty({ + description: 'Токен доступа', + example: 'token', + }) + access_token: string; + + @ApiProperty({ + description: 'Токен обновления', + example: 'token', + }) + refresh_token: string; +} diff --git a/src/controllers/request/dto/request-body.dto.ts b/src/controllers/request/dto/request-body.dto.ts new file mode 100644 index 0000000..97a851b --- /dev/null +++ b/src/controllers/request/dto/request-body.dto.ts @@ -0,0 +1,30 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsIn, IsObject, IsString, Length } from 'class-validator'; + +export class RequestBody { + @ApiProperty({ + description: 'Метод запроса', + example: 'GET', + }) + @IsString() + @Length(3, 4) + @IsIn(['GET', 'POST']) + method: string; + + @ApiProperty({ + description: 'Endpoint', + example: 'sinap/api/v2/terms/31212/payments', + }) + @IsString() + @Length(1, 256) + endpoint: string; + + @ApiProperty({ + description: 'Параметры запроса', + example: { + fields: 'account', + }, + }) + @IsObject() + params: any; +} diff --git a/src/controllers/request/request.controller.ts b/src/controllers/request/request.controller.ts new file mode 100644 index 0000000..9d9969f --- /dev/null +++ b/src/controllers/request/request.controller.ts @@ -0,0 +1,19 @@ +import { Body, Controller, Headers, Post } from '@nestjs/common'; +import { ApiOperation, ApiTags } from '@nestjs/swagger'; +import { RequestService } from './request.service'; +import { RequestBody } from './dto/request-body.dto'; +import { UserDataDto } from 'src/dto/user-data.dto'; + +@ApiTags('Модуль запросов') +@Controller('request') +export class RequestController { + constructor(private readonly requestService: RequestService) {} + + @Post() + @ApiOperation({ + summary: 'Запрос к QIWI API', + }) + async request(@Body() body: RequestBody, @Headers('user') user: UserDataDto) { + return await this.requestService.request(user, body); + } +} diff --git a/src/controllers/request/request.module.ts b/src/controllers/request/request.module.ts new file mode 100644 index 0000000..27b4c41 --- /dev/null +++ b/src/controllers/request/request.module.ts @@ -0,0 +1,12 @@ +import { Module } from '@nestjs/common'; +import { RequestController } from './request.controller'; +import { RequestService } from './request.service'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { Tokens } from 'src/entities/tokens.entity'; + +@Module({ + controllers: [RequestController], + providers: [RequestService], + imports: [TypeOrmModule.forFeature([Tokens])], +}) +export class RequestModule {} diff --git a/src/controllers/request/request.service.ts b/src/controllers/request/request.service.ts new file mode 100644 index 0000000..06650d1 --- /dev/null +++ b/src/controllers/request/request.service.ts @@ -0,0 +1,50 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { UserDataDto } from 'src/dto/user-data.dto'; +import { Tokens } from 'src/entities/tokens.entity'; +import { Repository } from 'typeorm'; +import { RequestBody } from './dto/request-body.dto'; +import errorGenerator from 'src/utils/errorGenerator.utils'; +import Errors from 'src/errors.enum'; +import axios from 'axios'; + +@Injectable() +export class RequestService { + constructor( + @InjectRepository(Tokens) + private readonly tokensRepository: Repository, + ) {} + + async request(user: UserDataDto, body: RequestBody) { + const token = await this.tokensRepository + .createQueryBuilder('tokens') + .select(['tokens.access_token as access_token']) + .where('tokens.created_by = :created_by', { created_by: user.id }) + .orderBy('tokens.id', 'DESC') + .getRawOne(); + + if (!token) errorGenerator(Errors.AUTH_PARAMS_NOT_VALID); + + try { + const req = await axios({ + method: body.method, + url: `https://edge.qiwi.com/${body.endpoint}${ + body.method === 'GET' + ? `?${Object.keys(body.params) + .map((el) => `${el}=${body.params[el]}`) + .join('&')}` + : '' + }`, + headers: { + Authorization: `Bearer ${token.access_token}`, + 'Content-Type': 'application/json', + }, + data: body?.method === 'POST' ? body.params : undefined, + }); + + return req.data; + } catch (e) { + return e.response.data; + } + } +} diff --git a/src/dto/user-data.dto.ts b/src/dto/user-data.dto.ts new file mode 100644 index 0000000..38c7412 --- /dev/null +++ b/src/dto/user-data.dto.ts @@ -0,0 +1,27 @@ +import { ApiProperty } from '@nestjs/swagger'; + +export class UserDataDto { + @ApiProperty({ + description: 'Идентификатор пользователя', + example: 1, + }) + id: number; + + @ApiProperty({ + description: 'Телефон от QIWI', + example: '79951275110', + }) + phone: string; + + @ApiProperty({ + description: 'Пароль от QIWI', + example: 'PASSWORD', + }) + password: string; + + @ApiProperty({ + description: 'Дата регистрации пользователя (timestamp)', + example: 1610000000, + }) + joined_at: number; +} diff --git a/src/entities/auth_tokens.entity.ts b/src/entities/auth_tokens.entity.ts new file mode 100644 index 0000000..dcf3f46 --- /dev/null +++ b/src/entities/auth_tokens.entity.ts @@ -0,0 +1,25 @@ +import { + Entity, + Column, + PrimaryGeneratedColumn, + ManyToOne, + JoinColumn, +} from 'typeorm'; +import { Users } from './users.entity'; + +@Entity('auth_tokens') +export class AuthTokens { + @PrimaryGeneratedColumn() + id: number; + + @Column() + @ManyToOne(() => Users, (user) => user.id) + @JoinColumn({ name: 'created_by' }) + created_by: number; + + @Column({ length: 200 }) + refresh_token: string; + + @Column() + created_at: number; +} diff --git a/src/entities/tokens.entity.ts b/src/entities/tokens.entity.ts new file mode 100644 index 0000000..5eb8edc --- /dev/null +++ b/src/entities/tokens.entity.ts @@ -0,0 +1,25 @@ +import { + Entity, + Column, + PrimaryGeneratedColumn, + ManyToOne, + JoinColumn, +} from 'typeorm'; +import { Users } from './users.entity'; + +@Entity('tokens') +export class Tokens { + @PrimaryGeneratedColumn() + id: number; + + @Column() + @ManyToOne(() => Users, (user) => user.id) + @JoinColumn({ name: 'created_by' }) + created_by: number; + + @Column({ length: 64 }) + access_token: string; + + @Column() + created_at: number; +} diff --git a/src/entities/users.entity.ts b/src/entities/users.entity.ts new file mode 100644 index 0000000..571d5de --- /dev/null +++ b/src/entities/users.entity.ts @@ -0,0 +1,19 @@ +import { Entity, Column, PrimaryGeneratedColumn } from 'typeorm'; + +@Entity('users') +export class Users { + @PrimaryGeneratedColumn() + id: number; + + @Column({ length: 12 }) + phone: string; + + @Column({ length: 128 }) + password: string; + + @Column() + updated_at: number; + + @Column() + joined_at: number; +} diff --git a/src/errors.enum.ts b/src/errors.enum.ts new file mode 100644 index 0000000..d4d5fc3 --- /dev/null +++ b/src/errors.enum.ts @@ -0,0 +1,23 @@ +class Errors { + static readonly ACCESS_DENIED = { + errorCode: 0, + message: 'access denied', + }; + + static readonly NOT_FOUND = { + errorCode: 1, + message: 'not found', + }; + + static readonly AUTH_PARAMS_NOT_VALID = { + errorCode: 2, + message: 'auth params not valid', + }; + + static readonly CANT_GET_TOKEN = { + errorCode: 3, + message: 'cant get token', + }; +} + +export default Errors; diff --git a/src/interceptors/res.interceptor.ts b/src/interceptors/res.interceptor.ts new file mode 100644 index 0000000..941ee5f --- /dev/null +++ b/src/interceptors/res.interceptor.ts @@ -0,0 +1,29 @@ +import { + Injectable, + NestInterceptor, + ExecutionContext, + CallHandler, +} from '@nestjs/common'; +import { Observable } from 'rxjs'; +import { map } from 'rxjs/operators'; + +export interface Response { + statusCode: number; + data: T; +} + +@Injectable() +export class ResInterceptor implements NestInterceptor> { + intercept( + context: ExecutionContext, + next: CallHandler, + ): Observable> { + return next.handle().pipe( + map((data) => ({ + status: true, + statusCode: context.switchToHttp().getResponse().statusCode, + data, + })), + ); + } +} diff --git a/src/main.ts b/src/main.ts new file mode 100644 index 0000000..04ce81f --- /dev/null +++ b/src/main.ts @@ -0,0 +1,44 @@ +import { ValidationPipe } from '@nestjs/common'; +import { NestFactory } from '@nestjs/core'; +import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger'; +import { AppModule } from './app.module'; +import { NestExpressApplication } from '@nestjs/platform-express'; +import basicAuth from 'express-basic-auth'; + +async function bootstrap() { + const app = await NestFactory.create(AppModule, { + cors: true, + }); + + app.set('trust proxy', 1); + + const config = new DocumentBuilder() + .setTitle('Документация по API') + .setVersion('1.0') + .build(); + + app.use( + ['/docs', '/docs-json'], + basicAuth({ + challenge: true, + users: { + devs: process.env.DOCS_PASSWORD || 'devs', + }, + }), + ); + + const document = SwaggerModule.createDocument(app, config); + SwaggerModule.setup('docs', app, document); + + app.useGlobalPipes( + new ValidationPipe({ + transform: true, + whitelist: true, + forbidNonWhitelisted: true, + transformOptions: { enableImplicitConversion: true }, + }), + ); + + await app.listen(process.env.PORT || 3000); +} +bootstrap(); diff --git a/src/middleware/params/params.middleware.ts b/src/middleware/params/params.middleware.ts new file mode 100644 index 0000000..d169b88 --- /dev/null +++ b/src/middleware/params/params.middleware.ts @@ -0,0 +1,43 @@ +import { Injectable, NestMiddleware } from '@nestjs/common'; +import { ParamsService } from './params.service'; +import { UserDataDto } from 'src/dto/user-data.dto'; +import { verify } from 'jsonwebtoken'; + +interface JwtPayload { + id: number; +} + +@Injectable() +export class ParamsMiddleware implements NestMiddleware { + constructor(private readonly paramsService: ParamsService) {} + + async use(req: any, res: any, next: () => void) { + const authError = () => + res.status(401).json({ + response: false, + statusCode: 401, + errorCode: 0, + }); + + if (!req?.headers?.authorization && !req?._parsedUrl?.query) + return authError(); + + const params = + req?.headers?.authorization?.slice(7) || req?._parsedUrl?.query || ''; + + try { + const { id } = verify(params, process.env.JWT_SECRET) as JwtPayload; + + const user: UserDataDto = await this.paramsService.getUser(id); + + req.headers = { + ...req.headers, + user, + }; + + next(); + } catch (e) { + return authError(); + } + } +} diff --git a/src/middleware/params/params.module.ts b/src/middleware/params/params.module.ts new file mode 100644 index 0000000..adcb48e --- /dev/null +++ b/src/middleware/params/params.module.ts @@ -0,0 +1,11 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { Users } from 'src/entities/users.entity'; +import { ParamsService } from './params.service'; + +@Module({ + providers: [ParamsService], + exports: [ParamsService], + imports: [TypeOrmModule.forFeature([Users])], +}) +export class StartParamsModule {} diff --git a/src/middleware/params/params.service.ts b/src/middleware/params/params.service.ts new file mode 100644 index 0000000..260c30e --- /dev/null +++ b/src/middleware/params/params.service.ts @@ -0,0 +1,23 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { UserDataDto } from 'src/dto/user-data.dto'; +import { Users } from 'src/entities/users.entity'; +import { Repository } from 'typeorm'; + +@Injectable() +export class ParamsService { + constructor( + @InjectRepository(Users) + private readonly usersRepository: Repository, + ) {} + + async getUser(user_id: number): Promise { + const userData = await this.usersRepository + .createQueryBuilder('users') + .select(['*']) + .where('users.id = :id', { id: user_id }) + .getRawOne(); + + return userData; + } +} diff --git a/src/tasks/tasks.module.ts b/src/tasks/tasks.module.ts new file mode 100644 index 0000000..f7221d9 --- /dev/null +++ b/src/tasks/tasks.module.ts @@ -0,0 +1,7 @@ +import { Module } from '@nestjs/common'; +import { TokenUpdaterModule } from './token-updater/token-updater.module'; + +@Module({ + imports: [TokenUpdaterModule], +}) +export class TasksModule {} diff --git a/src/tasks/token-updater/token-updater.module.ts b/src/tasks/token-updater/token-updater.module.ts new file mode 100644 index 0000000..631861a --- /dev/null +++ b/src/tasks/token-updater/token-updater.module.ts @@ -0,0 +1,12 @@ +import { Module } from '@nestjs/common'; +import { TokenUpdaterService } from './token-updater.service'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { Tokens } from 'src/entities/tokens.entity'; +import { Users } from 'src/entities/users.entity'; +import { AuthTokens } from 'src/entities/auth_tokens.entity'; + +@Module({ + providers: [TokenUpdaterService], + imports: [TypeOrmModule.forFeature([Tokens, Users, AuthTokens])], +}) +export class TokenUpdaterModule {} diff --git a/src/tasks/token-updater/token-updater.service.ts b/src/tasks/token-updater/token-updater.service.ts new file mode 100644 index 0000000..8013039 --- /dev/null +++ b/src/tasks/token-updater/token-updater.service.ts @@ -0,0 +1,103 @@ +import { Injectable } from '@nestjs/common'; +import { Cron } from '@nestjs/schedule'; +import { InjectRepository } from '@nestjs/typeorm'; +import { AuthTokens } from 'src/entities/auth_tokens.entity'; +import { Tokens } from 'src/entities/tokens.entity'; +import { Users } from 'src/entities/users.entity'; +import getCurrentTimestamp from 'src/utils/getCurrentTimestamp.utils'; +import getQiwiToken from 'src/utils/getQiwiToken.utils'; +import { Repository } from 'typeorm'; + +@Injectable() +export class TokenUpdaterService { + constructor( + @InjectRepository(Tokens) + private readonly tokensRepository: Repository, + + @InjectRepository(Users) + private readonly usersRepository: Repository, + + @InjectRepository(AuthTokens) + private readonly authTokensRepository: Repository, + ) {} + + @Cron('* * * * *') + async handleCron() { + const tokens = await this.tokensRepository + .createQueryBuilder('tokens') + .select([ + 'tokens.id as id', + 'tokens.created_by as created_by', + 'users.phone as phone', + 'users.password as password', + ]) + .where('tokens.created_at < :created_at', { + created_at: getCurrentTimestamp() - 3600 * 2, + }) + .innerJoin('tokens.created_by', 'users') + .getRawMany(); + + for await (const el of tokens) { + await this.tokensRepository + .createQueryBuilder('tokens') + .delete() + .where('tokens.id = :id', { id: el.id }) + .execute(); + + let access_token; + let attempts = 0; + + while (true) { + if (attempts > 20) break; + + try { + access_token = await getQiwiToken(el.phone, el.password); + + if (access_token) break; + } catch (e) { + console.log(e); + } + + attempts++; + + await new Promise((resolve) => setTimeout(resolve, 10000 * 6)); + } + + if (!access_token) { + await this.authTokensRepository + .createQueryBuilder('auth_tokens') + .delete() + .where('auth_tokens.created_by = :created_by', { + created_by: el.created_by, + }) + .execute(); + + await this.tokensRepository + .createQueryBuilder('tokens') + .delete() + .where('tokens.created_by = :created_by', { + created_by: el.created_by, + }) + .execute(); + + await this.usersRepository + .createQueryBuilder('users') + .delete() + .where('users.id = :id', { id: el.created_by }) + .execute(); + + continue; + } + + await this.tokensRepository + .createQueryBuilder('tokens') + .insert() + .values({ + created_by: el.created_by, + access_token, + created_at: getCurrentTimestamp(), + }) + .execute(); + } + } +} diff --git a/src/utils/authHelper.utils.ts b/src/utils/authHelper.utils.ts new file mode 100644 index 0000000..0a4b95d --- /dev/null +++ b/src/utils/authHelper.utils.ts @@ -0,0 +1,50 @@ +import { sign } from 'jsonwebtoken'; +import { v4 as uuidv4 } from 'uuid'; + +const generateAccessToken = (user_id: number): string => { + const payload = { + id: user_id, + type: 'access_token', + }; + const options = { + expiresIn: '1d', + }; + + return sign(payload, process.env.JWT_SECRET, options); +}; + +const generateRefreshToken = ( + user_id: number, +): { + id: string; + token: string; +} => { + const payload = { + id: uuidv4(), + user_id, + type: 'refresh_token', + }; + const options = { + expiresIn: '30d', + }; + + return { + id: payload.id, + token: sign(payload, process.env.JWT_SECRET, options), + }; +}; + +const updateTokens = async ( + user_id: number, +): Promise<{ id: string; access_token: string; refresh_token: string }> => { + const access_token = generateAccessToken(user_id); + const refresh_token = generateRefreshToken(user_id); + + return { + id: refresh_token.id, + access_token, + refresh_token: refresh_token.token, + }; +}; + +export { generateAccessToken, generateRefreshToken, updateTokens }; diff --git a/src/utils/errorGenerator.utils.ts b/src/utils/errorGenerator.utils.ts new file mode 100644 index 0000000..528ba4d --- /dev/null +++ b/src/utils/errorGenerator.utils.ts @@ -0,0 +1,26 @@ +import { HttpException } from '@nestjs/common'; + +interface IErrorGenerator { + errorCode: number; + message: string; + data?: any; +} + +interface IErrorGeneratorReturn { + status: boolean; + errorCode: number; + message: string; + data?: any; +} + +const errorGenerator = (errorCode: IErrorGenerator): IErrorGeneratorReturn => { + throw new HttpException( + { + status: false, + ...errorCode, + }, + 200, + ); +}; + +export default errorGenerator; diff --git a/src/utils/getCurrentTimestamp.utils.ts b/src/utils/getCurrentTimestamp.utils.ts new file mode 100644 index 0000000..79620bc --- /dev/null +++ b/src/utils/getCurrentTimestamp.utils.ts @@ -0,0 +1,3 @@ +const getCurrentTimestamp = (): number => Math.floor(Date.now() / 1000); + +export default getCurrentTimestamp; diff --git a/src/utils/getQiwiToken.utils.ts b/src/utils/getQiwiToken.utils.ts new file mode 100644 index 0000000..8465777 --- /dev/null +++ b/src/utils/getQiwiToken.utils.ts @@ -0,0 +1,171 @@ +import { random } from 'user-agents'; +import puppeteer from 'puppeteer-extra'; +import { executablePath } from 'puppeteer'; +import stealthPlugin from 'puppeteer-extra-plugin-stealth'; + +const getQiwiToken = async ( + userPhone: string, + userPassword: string, +): Promise => { + puppeteer.use(stealthPlugin()); + + const browser = await puppeteer.launch({ + args: [ + '--no-sandbox', + '--enable-automation', + '--disable-dev-shm-usage', + '--lang=ru', + '--no-first-run', + '--window-size=1366,768', + ], + headless: true, + ignoreHTTPSErrors: true, + executablePath: executablePath(), + slowMo: 15, + defaultViewport: { + width: 1366, + height: 768, + }, + }); + + try { + const page = (await browser.pages())[0]; + + await page.evaluateOnNewDocument(() => { + Object.defineProperty(navigator, 'webdriver', { + get: () => false, + }); + }); + + const pagesUserAgent = random().toString(); + + await page.setUserAgent(pagesUserAgent); + + await page.goto(`https://qiwi.com/`, { + waitUntil: ['domcontentloaded', 'load', 'networkidle2', 'networkidle0'], + timeout: 60000, + }); + + await page.waitForSelector('button', { + timeout: 30000, + visible: true, + }); + + await page.evaluate(() => { + document.querySelectorAll('button')[1].click(); + }); + + await page.waitForSelector('input[name="username"]', { + timeout: 30000, + visible: true, + }); + + await page.waitForTimeout(2000); + + await page.type('input[name="username"]', userPhone.slice(1), { + delay: 20, + }); + + await page.type('input[name="password"]', userPassword, { + delay: 20, + }); + + await page.keyboard.press('Enter'); + + await page.waitForTimeout(4000); + + let attempts = 0; + let phone = null; + + while (true) { + if (attempts > 30) break; + + const url = page.url(); + + if (url.includes('main')) { + try { + phone = await page.evaluate(() => { + return document + .querySelectorAll('.account-info-number-43')[0] + .innerHTML.replaceAll(' ', '') + .replaceAll('‑', '') + .replace('+', ''); + }); + } catch { + try { + phone = await page.evaluate(() => { + return document + .querySelectorAll('.account-info-number-41')[0] + .innerHTML.replaceAll(' ', '') + .replaceAll('‑', '') + .replace('+', ''); + }); + } catch {} + } + + if (phone) break; + } + + attempts += 1; + + await page.waitForTimeout(1000); + } + + if (!phone) { + try { + await browser.close(); + } catch {} + + return null; + } + + const token = await page.evaluate( + () => JSON.parse(window.localStorage['oauth-token-head'])['access_token'], + ); + + attempts = 0; + + let tokenTailWebQw = null; + + while (true) { + if (attempts > 20) break; + + try { + const cookies = await page.cookies(); + + tokenTailWebQw = cookies.find( + (cookie) => cookie.name === 'token-tail-web-qw', + )?.value; + } catch {} + + if (tokenTailWebQw) break; + + attempts += 1; + + await page.waitForTimeout(1000); + } + + if (!tokenTailWebQw) { + try { + await browser.close(); + } catch {} + + return null; + } + + try { + await browser.close(); + } catch {} + + return `${token}${tokenTailWebQw}`; + } catch (e) { + console.log(e); + try { + await browser.close(); + } catch {} + + throw e; + } +}; + +export default getQiwiToken; diff --git a/tsconfig.build.json b/tsconfig.build.json new file mode 100644 index 0000000..64f86c6 --- /dev/null +++ b/tsconfig.build.json @@ -0,0 +1,4 @@ +{ + "extends": "./tsconfig.json", + "exclude": ["node_modules", "test", "dist", "**/*spec.ts"] +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..4285353 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,22 @@ +{ + "compilerOptions": { + "module": "commonjs", + "declaration": true, + "removeComments": true, + "emitDecoratorMetadata": true, + "experimentalDecorators": true, + "allowSyntheticDefaultImports": true, + "target": "es2021", + "sourceMap": true, + "outDir": "./dist", + "baseUrl": "./", + "incremental": true, + "skipLibCheck": true, + "esModuleInterop": true, + "strictNullChecks": false, + "noImplicitAny": false, + "strictBindCallApply": false, + "forceConsistentCasingInFileNames": false, + "noFallthroughCasesInSwitch": false + } +}