Compare commits

...

4 Commits

  1. 9
      .env.example
  2. 3
      Dockerfile
  3. 58
      docker-compose.local.yml
  4. 88
      package-lock.json
  5. 1
      package.json
  6. 6
      src/app.module.ts
  7. 13
      src/config/index.ts
  8. 12
      src/core/enums/events.enum.ts
  9. 4
      src/core/namespaces/ips.namespace.ts
  10. 6
      src/core/namespaces/notifications.namespace.ts
  11. 34
      src/core/namespaces/sessions.namespace.ts
  12. 15
      src/domain/ips/entities/ip.entity.ts
  13. 2
      src/domain/ips/ips.module.ts
  14. 1
      src/domain/ips/services/ips-lists.service.ts
  15. 4
      src/domain/ips/services/ips.service.ts
  16. 4
      src/domain/logs/entities/log.entity.ts
  17. 2
      src/domain/mailer/mailer.module.ts
  18. 30
      src/domain/notifications/services/notifications-events.service.ts
  19. 91
      src/domain/sessions/classes/blocked-ip-alert.ts
  20. 1
      src/domain/sessions/classes/index.ts
  21. 2
      src/domain/sessions/consts/index.ts
  22. 40
      src/domain/sessions/guards/ip.guard.ts
  23. 15
      src/domain/sessions/helpers/attemps-keys.helper.ts
  24. 1
      src/domain/sessions/helpers/index.ts
  25. 1
      src/domain/sessions/interfaces/index.ts
  26. 6
      src/domain/sessions/interfaces/sessions-module-options.interface.ts
  27. 79
      src/domain/sessions/services/auth-attemptions-by-fingeprint.service.ts
  28. 60
      src/domain/sessions/services/auth-attemptions-by-ips.service.ts
  29. 97
      src/domain/sessions/services/auth-attemptions-by-time.service.ts
  30. 99
      src/domain/sessions/services/auth-attemptions.service.ts
  31. 13
      src/domain/sessions/services/index.ts
  32. 4
      src/domain/sessions/services/sessions.service.ts
  33. 15
      src/domain/sessions/sessions.module.ts
  34. 1
      src/domain/tasks/crons/index.ts
  35. 88
      src/domain/tasks/crons/tasks-dedlines.cron.ts
  36. 2
      src/domain/tasks/tasks.module.ts
  37. 1
      src/domain/users/classes/index.ts
  38. 9
      src/domain/users/classes/user-full-name.ts
  39. 10
      src/rest/admin/auth/controllers/admin-auth.controller.ts
  40. 9
      src/rest/admin/auth/controllers/admin-password-recovery.controller.ts
  41. 33
      src/rest/admin/auth/services/admin-auth.service.ts
  42. 42
      src/rest/admin/auth/services/admin-password-recovery.service.ts
  43. 33
      src/rest/admin/notifications/admin-notifications.module.ts
  44. 27
      src/rest/admin/notifications/controllers/admin-notifications-send.controller.ts
  45. 3
      src/rest/admin/notifications/controllers/index.ts
  46. 3
      src/rest/admin/notifications/dto/index.ts
  47. 23
      src/rest/admin/notifications/dto/send-notifications.dto.ts
  48. 72
      src/rest/admin/notifications/services/admin-notifications-send.service.ts
  49. 3
      src/rest/admin/notifications/services/index.ts
  50. 10
      src/rest/app/auth/controllers/app-auth.controller.ts
  51. 19
      src/rest/app/auth/services/app-auth.service.ts
  52. 31
      src/shared/abstracts/cron.abstract.ts
  53. 1
      src/shared/abstracts/index.ts
  54. 17
      src/shared/decorators/index.ts
  55. 7
      src/shared/decorators/req-fingreprint.decorator.ts
  56. 1
      src/shared/filters/domain-exception.filter.ts
  57. 4
      tsconfig.build.json
  58. 2
      tsconfig.json

9
.env.example

@ -78,4 +78,11 @@ ALLOWED_TASK_FILES_TYPES='jpeg,jpg,png,svg,webp,tiff,pdf,txt,doc,docx,xls,xlsx' @@ -78,4 +78,11 @@ ALLOWED_TASK_FILES_TYPES='jpeg,jpg,png,svg,webp,tiff,pdf,txt,doc,docx,xls,xlsx'
# IN KB
ALLOWED_CHAT_FILES_SIZE=50000
# IN KB
ALLOWED_CHAT_VIDEOS_SIZE=100000
ALLOWED_CHAT_VIDEOS_SIZE=100000
# BLOCK IPS OPTIONS
MAX_ATTEMPS_BY_IP=50
MAX_ATTEMPS_BY_FINGERPRINT=5
MAX_ATTEMPS_BY_TIME=10
MAX_GAP_FOR_TIME_ATTEMPS=3000 # in miliseconds

3
Dockerfile

@ -1,5 +1,4 @@ @@ -1,5 +1,4 @@
FROM node:14.0.0
FROM node:16.20.2
RUN apt-get update
RUN apt-get install -y build-essential

58
docker-compose.local.yml

@ -0,0 +1,58 @@ @@ -0,0 +1,58 @@
version: '3'
services:
taskme-postgres:
image: postgres:11
restart: always
ports:
- 3303:5432
environment:
POSTGRES_PASSWORD: ${DATABASE_PASS}
POSTGRES_USER: ${DATABASE_USER}
POSTGRES_DB: ${DATABASE_DB}
taskme-redis:
image: 'redis:4-alpine'
command: redis-server --requirepass ${REDIS_PASS}
ports:
- '6379:6379'
taskme-minio:
hostname: taskme-minio
image: minio/minio:RELEASE.2021-09-18T18-09-59Z
container_name: taskme-minio
volumes:
- './taskme/data/:/data'
- './taskme/config:/root/.minio'
ports:
- 5003:9000
- 5004:9001
environment:
MINIO_ROOT_USER: ${MINIO_ACCESS_KEY}
MINIO_ROOT_PASSWORD: ${MINIO_SECRET_KEY}
command: server --console-address ":9001" /data
taskme-createbuckets:
image: minio/mc
depends_on:
- taskme-minio
entrypoint: >
/bin/sh -c "
sleep 10;
/usr/bin/mc config host add data http://${MINIO_HOST}:${MINIO_PORT} ${MINIO_ACCESS_KEY} ${MINIO_SECRET_KEY};
/usr/bin/mc mb data/${MINIO_BUCKET};
/usr/bin/mc policy set public data/${MINIO_BUCKET};
exit 0;
"
taskme-imgproxy:
image: 'darthsim/imgproxy:latest'
ports:
- '5005:8080'
environment:
IMGPROXY_KEY: ${IMGPROXY_KEY}
IMGPROXY_SALT: ${IMGPROXY_SALT}
IMGPROXY_MAX_SRC_FILE_SIZE: 10485760

88
package-lock.json generated

@ -26,6 +26,7 @@ @@ -26,6 +26,7 @@
"axios": "^0.27.2",
"class-transformer": "^0.4.0",
"class-validator": "^0.13.1",
"cron": "^3.1.6",
"dotenv": "^10.0.0",
"express-basic-auth": "^1.2.0",
"http": "0.0.1-security",
@ -3499,6 +3500,14 @@ @@ -3499,6 +3500,14 @@
"reflect-metadata": "^0.1.12"
}
},
"node_modules/@nestjs/schedule/node_modules/cron": {
"version": "1.8.2",
"resolved": "https://registry.npmjs.org/cron/-/cron-1.8.2.tgz",
"integrity": "sha512-Gk2c4y6xKEO8FSAUTklqtfSr7oTq0CiPQeLBG5Fl0qoXpZyMcj1SG59YL+hqq04bu6/IuEA7lMkYDAplQNKkyg==",
"dependencies": {
"moment-timezone": "^0.5.x"
}
},
"node_modules/@nestjs/schematics": {
"version": "7.3.1",
"resolved": "https://registry.npmjs.org/@nestjs/schematics/-/schematics-7.3.1.tgz",
@ -4796,6 +4805,11 @@ @@ -4796,6 +4805,11 @@
"integrity": "sha512-/THyiqyQAP9AfARo4pF+aCGcyiQ94tX/Is2I7HofNRqoYLgN1PBoOWu2/zTA5zMxzP5EFutMtWtGAFRKUe961Q==",
"dev": true
},
"node_modules/@types/luxon": {
"version": "3.3.8",
"resolved": "https://registry.npmjs.org/@types/luxon/-/luxon-3.3.8.tgz",
"integrity": "sha512-jYvz8UMLDgy3a5SkGJne8H7VA7zPV2Lwohjx0V8V31+SqAjNmurWMkk9cQhfvlcnXWudBpK9xPM1n4rljOcHYQ=="
},
"node_modules/@types/mime": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.2.tgz",
@ -7306,11 +7320,12 @@ @@ -7306,11 +7320,12 @@
}
},
"node_modules/cron": {
"version": "1.8.2",
"resolved": "https://registry.npmjs.org/cron/-/cron-1.8.2.tgz",
"integrity": "sha512-Gk2c4y6xKEO8FSAUTklqtfSr7oTq0CiPQeLBG5Fl0qoXpZyMcj1SG59YL+hqq04bu6/IuEA7lMkYDAplQNKkyg==",
"version": "3.1.6",
"resolved": "https://registry.npmjs.org/cron/-/cron-3.1.6.tgz",
"integrity": "sha512-cvFiQCeVzsA+QPM6fhjBtlKGij7tLLISnTSvFxVdnFGLdz+ZdXN37kNe0i2gefmdD17XuZA6n2uPVwzl4FxW/w==",
"dependencies": {
"moment-timezone": "^0.5.x"
"@types/luxon": "~3.3.0",
"luxon": "~3.4.0"
}
},
"node_modules/cross-spawn": {
@ -13102,6 +13117,14 @@ @@ -13102,6 +13117,14 @@
"integrity": "sha512-zTU3DaZaF3Rt9rhN3uBMGQD3dD2/vFQqnvZCDv4dl5iOzq2IZQqTxu90r4E5J+nP70J3ilqVCrbho2eWaeW8Ow==",
"dev": true
},
"node_modules/luxon": {
"version": "3.4.4",
"resolved": "https://registry.npmjs.org/luxon/-/luxon-3.4.4.tgz",
"integrity": "sha512-zobTr7akeGHnv7eBOXcRgMeCP6+uyYsczwmeRCauvpvaAltgNyTbLH/+VaEAPUeWBT+1GuNmz4wC/6jtQzbbVA==",
"engines": {
"node": ">=12"
}
},
"node_modules/macos-release": {
"version": "2.5.0",
"resolved": "https://registry.npmjs.org/macos-release/-/macos-release-2.5.0.tgz",
@ -14256,19 +14279,19 @@ @@ -14256,19 +14279,19 @@
}
},
"node_modules/moment": {
"version": "2.29.3",
"resolved": "https://registry.npmjs.org/moment/-/moment-2.29.3.tgz",
"integrity": "sha512-c6YRvhEo//6T2Jz/vVtYzqBzwvPT95JBQ+smCytzf7c50oMZRsR/a4w88aD34I+/QVSfnoAnSBFPJHItlOMJVw==",
"version": "2.30.1",
"resolved": "https://registry.npmjs.org/moment/-/moment-2.30.1.tgz",
"integrity": "sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how==",
"engines": {
"node": "*"
}
},
"node_modules/moment-timezone": {
"version": "0.5.34",
"resolved": "https://registry.npmjs.org/moment-timezone/-/moment-timezone-0.5.34.tgz",
"integrity": "sha512-3zAEHh2hKUs3EXLESx/wsgw6IQdusOT8Bxm3D9UrHPQR7zlMmzwybC8zHEM1tQ4LJwP7fcxrWr8tuBg05fFCbg==",
"version": "0.5.45",
"resolved": "https://registry.npmjs.org/moment-timezone/-/moment-timezone-0.5.45.tgz",
"integrity": "sha512-HIWmqA86KcmCAhnMAN0wuDOARV/525R2+lOLotuGFzn4HO+FH+/645z2wx0Dt3iDv6/p61SIvKnDstISainhLQ==",
"dependencies": {
"moment": ">= 2.9.0"
"moment": "^2.29.4"
},
"engines": {
"node": "*"
@ -22770,6 +22793,16 @@ @@ -22770,6 +22793,16 @@
"requires": {
"cron": "1.8.2",
"uuid": "8.3.2"
},
"dependencies": {
"cron": {
"version": "1.8.2",
"resolved": "https://registry.npmjs.org/cron/-/cron-1.8.2.tgz",
"integrity": "sha512-Gk2c4y6xKEO8FSAUTklqtfSr7oTq0CiPQeLBG5Fl0qoXpZyMcj1SG59YL+hqq04bu6/IuEA7lMkYDAplQNKkyg==",
"requires": {
"moment-timezone": "^0.5.x"
}
}
}
},
"@nestjs/schematics": {
@ -23823,6 +23856,11 @@ @@ -23823,6 +23856,11 @@
"integrity": "sha512-/THyiqyQAP9AfARo4pF+aCGcyiQ94tX/Is2I7HofNRqoYLgN1PBoOWu2/zTA5zMxzP5EFutMtWtGAFRKUe961Q==",
"dev": true
},
"@types/luxon": {
"version": "3.3.8",
"resolved": "https://registry.npmjs.org/@types/luxon/-/luxon-3.3.8.tgz",
"integrity": "sha512-jYvz8UMLDgy3a5SkGJne8H7VA7zPV2Lwohjx0V8V31+SqAjNmurWMkk9cQhfvlcnXWudBpK9xPM1n4rljOcHYQ=="
},
"@types/mime": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.2.tgz",
@ -25832,11 +25870,12 @@ @@ -25832,11 +25870,12 @@
}
},
"cron": {
"version": "1.8.2",
"resolved": "https://registry.npmjs.org/cron/-/cron-1.8.2.tgz",
"integrity": "sha512-Gk2c4y6xKEO8FSAUTklqtfSr7oTq0CiPQeLBG5Fl0qoXpZyMcj1SG59YL+hqq04bu6/IuEA7lMkYDAplQNKkyg==",
"version": "3.1.6",
"resolved": "https://registry.npmjs.org/cron/-/cron-3.1.6.tgz",
"integrity": "sha512-cvFiQCeVzsA+QPM6fhjBtlKGij7tLLISnTSvFxVdnFGLdz+ZdXN37kNe0i2gefmdD17XuZA6n2uPVwzl4FxW/w==",
"requires": {
"moment-timezone": "^0.5.x"
"@types/luxon": "~3.3.0",
"luxon": "~3.4.0"
}
},
"cross-spawn": {
@ -30399,6 +30438,11 @@ @@ -30399,6 +30438,11 @@
"integrity": "sha512-zTU3DaZaF3Rt9rhN3uBMGQD3dD2/vFQqnvZCDv4dl5iOzq2IZQqTxu90r4E5J+nP70J3ilqVCrbho2eWaeW8Ow==",
"dev": true
},
"luxon": {
"version": "3.4.4",
"resolved": "https://registry.npmjs.org/luxon/-/luxon-3.4.4.tgz",
"integrity": "sha512-zobTr7akeGHnv7eBOXcRgMeCP6+uyYsczwmeRCauvpvaAltgNyTbLH/+VaEAPUeWBT+1GuNmz4wC/6jtQzbbVA=="
},
"macos-release": {
"version": "2.5.0",
"resolved": "https://registry.npmjs.org/macos-release/-/macos-release-2.5.0.tgz",
@ -31354,16 +31398,16 @@ @@ -31354,16 +31398,16 @@
}
},
"moment": {
"version": "2.29.3",
"resolved": "https://registry.npmjs.org/moment/-/moment-2.29.3.tgz",
"integrity": "sha512-c6YRvhEo//6T2Jz/vVtYzqBzwvPT95JBQ+smCytzf7c50oMZRsR/a4w88aD34I+/QVSfnoAnSBFPJHItlOMJVw=="
"version": "2.30.1",
"resolved": "https://registry.npmjs.org/moment/-/moment-2.30.1.tgz",
"integrity": "sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how=="
},
"moment-timezone": {
"version": "0.5.34",
"resolved": "https://registry.npmjs.org/moment-timezone/-/moment-timezone-0.5.34.tgz",
"integrity": "sha512-3zAEHh2hKUs3EXLESx/wsgw6IQdusOT8Bxm3D9UrHPQR7zlMmzwybC8zHEM1tQ4LJwP7fcxrWr8tuBg05fFCbg==",
"version": "0.5.45",
"resolved": "https://registry.npmjs.org/moment-timezone/-/moment-timezone-0.5.45.tgz",
"integrity": "sha512-HIWmqA86KcmCAhnMAN0wuDOARV/525R2+lOLotuGFzn4HO+FH+/645z2wx0Dt3iDv6/p61SIvKnDstISainhLQ==",
"requires": {
"moment": ">= 2.9.0"
"moment": "^2.29.4"
}
},
"moo": {

1
package.json

@ -42,6 +42,7 @@ @@ -42,6 +42,7 @@
"axios": "^0.27.2",
"class-transformer": "^0.4.0",
"class-validator": "^0.13.1",
"cron": "^3.1.6",
"dotenv": "^10.0.0",
"express-basic-auth": "^1.2.0",
"http": "0.0.1-security",

6
src/app.module.ts

@ -26,7 +26,7 @@ import { @@ -26,7 +26,7 @@ import {
PushNotificationsModule,
RedisModule,
} from './libs'
import { getEnv, stringToBoolean } from './shared'
import { Cron, getEnv, stringToBoolean } from './shared'
import { getRestModules } from './rest'
import { ScheduleModule } from '@nestjs/schedule'
import { TypeOrmModule } from '@nestjs/typeorm'
@ -35,6 +35,8 @@ import { ThrottlerModule } from '@nestjs/throttler' @@ -35,6 +35,8 @@ import { ThrottlerModule } from '@nestjs/throttler'
const oldDbName = getEnv('OLD_DATABASE_DB')
Cron.setup(getEnv('CRON_ENABLED') === 'true')
const imports = [
ThrottlerModule.forRoot({
ttl: 60,
@ -55,7 +57,7 @@ const imports = [ @@ -55,7 +57,7 @@ const imports = [
}),
MailerModule.forRoot($config.getMailerConfig()),
UsersModule.forRoot({ passwordHashSalt: getEnv('LOCAL_HASH_SALT') }),
SessionsModule.forRoot(),
SessionsModule.forRoot($config.getSessionsConfig()),
IPsModule.forRoot(),
LogsModule.forRoot(),
TaxonomiesModule.forRoot(),

13
src/config/index.ts

@ -9,8 +9,9 @@ import { IRedisModuleOptions } from 'src/libs/redis/interfaces' @@ -9,8 +9,9 @@ import { IRedisModuleOptions } from 'src/libs/redis/interfaces'
import { getEnv, stringToBoolean } from 'src/shared'
import { ConnectionOptions } from 'typeorm'
import { ENTITIES } from './entities.config'
import { ISessionsModuleOptions } from 'src/domain/sessions/interfaces'
const getDatabaseConfig = (): Parameters<(typeof DatabaseModule)['forRoot']> => {
const getDatabaseConfig = (): Parameters<typeof DatabaseModule['forRoot']> => {
return [
{
type: 'postgres',
@ -83,6 +84,15 @@ const getPushNotificationsConfig = (): IPushNotifcationsModuleParams => { @@ -83,6 +84,15 @@ const getPushNotificationsConfig = (): IPushNotifcationsModuleParams => {
}
}
const getSessionsConfig = (): ISessionsModuleOptions => {
return {
maxAttempsByIp: Number(getEnv('MAX_ATTEMPS_BY_IP')),
maxAttempsByFingerprint: Number(getEnv('MAX_ATTEMPS_BY_FINGERPRINT')),
maxAttempsByTime: Number(getEnv('MAX_ATTEMPS_BY_TIME')),
maxGapForTimeAttemps: Number(getEnv('MAX_GAP_FOR_TIME_ATTEMPS')),
}
}
const getLinkToWeb = () => {
return getEnv('WEB_URL')
}
@ -138,5 +148,6 @@ export const $config = { @@ -138,5 +148,6 @@ export const $config = {
getOldDatabaseConfig,
getEmail,
getFilesLimitsConfig,
getSessionsConfig,
getAppVersion,
}

12
src/core/enums/events.enum.ts

@ -1,4 +1,4 @@ @@ -1,4 +1,4 @@
import { Logs, Sessions, Versions } from '../namespaces'
import { Logs, Sessions, Tasks, Versions } from '../namespaces'
import { Socket } from 'socket.io'
export enum Events {
@ -26,6 +26,8 @@ export enum Events { @@ -26,6 +26,8 @@ export enum Events {
OnChangeTaxonomies = 'OnChangeTaxonomies',
OnErrorJoinUser = 'OnErrorJoinUser',
OnUpdateEntity = 'OnUpdateEntity',
OnTaskDeadlineSoon = 'OnTaskDeadlineSoon',
OnTaskDeadlineExpired = 'OnTaskDeadlineExpired',
}
export interface IEventsPayloads {
@ -134,4 +136,12 @@ export interface IEventsPayloads { @@ -134,4 +136,12 @@ export interface IEventsPayloads {
type: Versions.EntityType
entityId: number
}
[Events.OnTaskDeadlineSoon]: {
task: Tasks.TaskModel
}
[Events.OnTaskDeadlineExpired]: {
task: Tasks.TaskModel
}
}

4
src/core/namespaces/ips.namespace.ts

@ -1,4 +1,5 @@ @@ -1,4 +1,5 @@
import { IPagination, IPaginationResult } from 'src/core/interfaces'
import { Users } from './users.namespace'
export namespace IPs {
export enum IPListType {
@ -23,6 +24,9 @@ export namespace IPs { @@ -23,6 +24,9 @@ export namespace IPs {
/** Тип списку, у якому знаходиться IP. Чорний або білий */
listType: IPListType
/** Користувач */
user?: Users.UserModel
}
export interface StoreIPPayload {

6
src/core/namespaces/notifications.namespace.ts

@ -21,6 +21,12 @@ export namespace Notifications { @@ -21,6 +21,12 @@ export namespace Notifications {
/** День народження */
TodayBirthday = 'todayBirthday',
/** Термін виконання задачі підходить до кінця **/
TaskDeadlineSoon = 'taskDeadlineSoon',
/** Термін виконання задачі пройшов **/
TaskDeadlineExpired = 'taskDeadlineExpired',
}
export enum NotificationsGroup {

34
src/core/namespaces/sessions.namespace.ts

@ -111,11 +111,10 @@ export namespace Sessions { @@ -111,11 +111,10 @@ export namespace Sessions {
closeAllUserSessions(userId: number, execludeIds?: number[]): Promise<void>
/**
* Метод для блокування IP адрес після 5 невдалих спроб
* @param {string} attempterIp - Айпі того, хто намагався авторизуватись
* @param data - додаткові дані
* Метод для блокування IP адрес після n-кількості невдалих спроб
* @param {AddAuthAttempPayload} payload - Данні того, хто намагався авторизуватись
*/
addAuthAttemption(attempterIp: string, data?: Record<string, any>): Promise<void>
addAuthAttemption(payload: Sessions.AddAuthAttempPayload): Promise<void>
/**
* Видаляє кількість спроб авторизації (потрібно в разі вдалої авторизації)
@ -162,4 +161,31 @@ export namespace Sessions { @@ -162,4 +161,31 @@ export namespace Sessions {
*/
compareCode(receiver: string, code: string): Promise<boolean>
}
export interface AddAuthAttempPayload {
ip: string
fingerprint?: string
targetUserId?: number
data?: Record<any, any>
}
export interface IAuthAttemptionsService {
/**
* Зберегти спробуй увійи в аккаунт
* @param {AddAuthAttempPayload} payload - данні про спробу
*/
add(payload: AddAuthAttempPayload): Promise<void>
/**
* Перевірка чи користувач може спробувати увійти
* @param targetUserId - аккаунт користувача у який намагаються увійти
* @param ip - ip адреса з якої відбуваться спроба входу
*/
checkAuthAttempIsAviable(targetUserId: number, ip: string)
/**
* Скидання спроб увійти
* @param ip
*/
dropCount(ip: string): Promise<void>
}
}

15
src/domain/ips/entities/ip.entity.ts

@ -1,5 +1,14 @@ @@ -1,5 +1,14 @@
import { IPs } from 'src/core'
import { Column, CreateDateColumn, Entity, PrimaryGeneratedColumn, UpdateDateColumn } from 'typeorm'
import { User } from 'src/domain/users/entities'
import {
Column,
CreateDateColumn,
Entity,
JoinColumn,
ManyToOne,
PrimaryGeneratedColumn,
UpdateDateColumn,
} from 'typeorm'
@Entity('ips')
export class Ip implements IPs.IIP {
@ -20,4 +29,8 @@ export class Ip implements IPs.IIP { @@ -20,4 +29,8 @@ export class Ip implements IPs.IIP {
@Column({ nullable: true, type: 'char', default: IPs.IPListType.White })
listType: IPs.IPListType
@ManyToOne(() => User)
@JoinColumn({ name: 'userId' })
user?: User
}

2
src/domain/ips/ips.module.ts

@ -25,7 +25,7 @@ export class IPsModule { @@ -25,7 +25,7 @@ export class IPsModule {
}
static getExports() {
return [IPS_SERVICE]
return [IPS_SERVICE, IPS_REPOSITORY]
}
static forRoot(): DynamicModule {

1
src/domain/ips/services/ips-lists.service.ts

@ -1,3 +1,4 @@ @@ -1,3 +1,4 @@
/* eslint-disable @typescript-eslint/ban-ts-comment */
import { Inject, Injectable } from '@nestjs/common'
import { IPs } from 'src/core'
import { RedisService } from 'src/libs'

4
src/domain/ips/services/ips.service.ts

@ -32,7 +32,9 @@ export class IPsService implements IPs.IIPsService { @@ -32,7 +32,9 @@ export class IPsService implements IPs.IIPsService {
}
public async getList(pagination: IPagination, params: IPs.IFetchIpsListParams) {
const query = this.ipsRepository.createQueryBuilder('it')
const query = this.ipsRepository
.createQueryBuilder('it')
.leftJoinAndSelect('it.user', 'user')
if (params.type) query.andWhere('it.listType = :type', { type: params?.type })
if (params.ip) query.andWhere('it.ip ILIKE :ip', { ip: `%${params?.ip}%` })

4
src/domain/logs/entities/log.entity.ts

@ -25,10 +25,10 @@ export class Log implements Logs.ILog { @@ -25,10 +25,10 @@ export class Log implements Logs.ILog {
@Column({ nullable: true })
ip?: string
@CreateDateColumn({ type: 'timestamp', default: () => 'LOCALTIMESTAMP' })
@CreateDateColumn({ type: 'timestamptz', default: () => 'LOCALTIMESTAMP' })
createdAt: string
@UpdateDateColumn({ type: 'timestamp', default: () => 'LOCALTIMESTAMP' })
@UpdateDateColumn({ type: 'timestamptz', default: () => 'LOCALTIMESTAMP' })
updatedAt: string
@Column({ type: 'char', nullable: true })

2
src/domain/mailer/mailer.module.ts

@ -1,4 +1,4 @@ @@ -1,4 +1,4 @@
import { DynamicModule, Global, Module } from '@nestjs/common'
import { DynamicModule, Module } from '@nestjs/common'
import { MailerModule as Mailer } from '@nestjs-modules/mailer'
import { MAILER_SERVICE } from 'src/core/consts'
import { provideClass } from 'src/shared'

30
src/domain/notifications/services/notifications-events.service.ts

@ -129,4 +129,34 @@ export class NotificationsEventsHandlerService { @@ -129,4 +129,34 @@ export class NotificationsEventsHandlerService {
})
}
}
@OnEvent(Events.OnTaskDeadlineSoon)
public async onTaskDeadlineSoon(payload: IEventsPayloads['OnTaskDeadlineSoon']) {
const { task } = payload
await this.notificationsService.addNotification({
userId: task.executorId,
title: 'Термін виконання задачі завтра',
content: `Термін виконання задачі "${task.name}" закінчується завтра (${task.endDate}).`,
group: Notifications.NotificationsGroup.Tasks,
data: {
taskId: task.id,
type: Notifications.NotificationType.TaskDeadlineSoon,
},
})
}
@OnEvent(Events.OnTaskDeadlineExpired)
public async onTaskDeadlineExpired(payload: IEventsPayloads['OnTaskDeadlineExpired']) {
const { task } = payload
await this.notificationsService.addNotification({
userId: task.executorId,
title: 'Термін виконання задачі минув',
content: `Термін виконання задачі "${task.name}" минув. Запланований час закінчення - ${task.endDate}.`,
group: Notifications.NotificationsGroup.Tasks,
data: {
taskId: task.id,
type: Notifications.NotificationType.TaskDeadlineExpired,
},
})
}
}

91
src/domain/sessions/classes/blocked-ip-alert.ts

@ -0,0 +1,91 @@ @@ -0,0 +1,91 @@
import { IUsersRepository } from 'src/domain/users/interfaces'
import { attempsKeys } from '../helpers'
import { IPs, Mailer, Users } from 'src/core'
import { IIPsRepository } from 'src/domain/ips/interfaces'
export class BlockedIpAlert {
private blockedIp: string
private clearIp: string
private fingerprint: string
private blocked?: IPs.IIP
private get targetUser() {
if (this.blocked) return this.blocked.user
return null
}
constructor(
private readonly usersRepository: IUsersRepository,
private readonly mailerService: Mailer.IMailerService,
private readonly ipsRepository: IIPsRepository,
) {}
public setIp(blockedIp: string) {
const decoded = attempsKeys.parseIp(blockedIp)
this.blockedIp = blockedIp
this.clearIp = decoded.ip
this.fingerprint = decoded.finreprint
return this
}
public async sendAlerts() {
await this.preloadData()
await this.sendToAdmins()
await this.sendToTargetUser()
}
private async preloadData() {
this.blocked = await this.ipsRepository.findOne({
where: { ip: this.blockedIp },
relations: ['user'],
})
}
private async sendToAdmins() {
const admins = await this.usersRepository.find({ where: { role: Users.Role.Admin } })
const message = await this.generateTextForAdminAlert()
admins.map(it => {
this.mailerService.send({
subject: 'Ip було заблоковано',
text: message,
to: it.email,
})
})
}
private generateTextForAdminAlert() {
let text = `
Системне повідомлення про блокування IP \n
Ip: ${this.clearIp} \n
`
if (this.fingerprint) {
text = `${text} Відбиток: ${this.fingerprint} \n`
}
if (this.targetUser) {
text = `${text} Аккаунт користувача в якого намагались зайти: ${this.targetUser.email}, ${this.targetUser.phoneNumber}`
}
return text
}
private sendToTargetUser() {
if (!this.targetUser) return null
this.mailerService.send({
subject: 'Ip було заблоковано',
text: this.generateTextForTargetUserAlert(),
to: this.targetUser.email,
})
}
private generateTextForTargetUserAlert() {
const text = `
Системне повідомлення про блокування IP \n
До вашого аккаунта намагались увійти. \n
Ip з якого відбувалась спроба: ${this.clearIp} \n
Ip було заблоковане. Якщо це ваше IP то вам потрібно звернутись до адміністратора додатку.
`
return text
}
}

1
src/domain/sessions/classes/index.ts

@ -0,0 +1 @@ @@ -0,0 +1 @@
export * from './blocked-ip-alert'

2
src/domain/sessions/consts/index.ts

@ -1 +1,3 @@ @@ -1 +1,3 @@
export const SESSIONS_REPOSITORY = Symbol('SESSIONS_REPOSITORY')
export const SESSIONS_MODULE_OPTIONS = Symbol('SESSIONS_MODULE_OPTIONS')

40
src/domain/sessions/guards/ip.guard.ts

@ -1,14 +1,18 @@ @@ -1,14 +1,18 @@
/* eslint-disable @typescript-eslint/ban-ts-comment */
import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common'
import { getIPFromRequest } from 'src/shared'
import { attempsKeys } from '../helpers'
const whiteListUrls = ['/admin/auth/password-recovery']
@Injectable()
export class IpGuard implements CanActivate {
public async canActivate(context: ExecutionContext): Promise<boolean> {
const req = context.switchToHttp().getRequest()
const ip = getIPFromRequest(req)
const fingerprint = req.headers['x-allow-lang']
req['fingerprint'] = fingerprint
console.log('ip', ip)
const ip = getIPFromRequest(req)
// @ts-ignore
const existsInWhiteList = await global.checkIfIpInWhiteList(ip)
@ -16,8 +20,36 @@ export class IpGuard implements CanActivate { @@ -16,8 +20,36 @@ export class IpGuard implements CanActivate {
// @ts-ignore
const existsInBlackList = await global.checkIfIpBlocked(ip)
if (existsInBlackList) return false
const urlInWhiteList = this.checkUrlIsInWhiteList(req.url)
if (urlInWhiteList) return true
const isBlockByFingreprint = await this.checkFingerprint(req, ip)
if (isBlockByFingreprint) return false
return true
}
private async checkFingerprint(req: any, ip: string) {
const fingerprint = req.fingerprint
if (!fingerprint) return false
// @ts-ignore
const existsInBlackList = await global.checkIfIpBlocked(
attempsKeys.createByFingreprintIp(ip, fingerprint),
)
return existsInBlackList
}
private checkUrlIsInWhiteList(url: string) {
let result = false
whiteListUrls.map(it => {
if (url.includes(it)) result = true
})
// console.log('Ip')
return !existsInBlackList
return result
}
}

15
src/domain/sessions/helpers/attemps-keys.helper.ts

@ -0,0 +1,15 @@ @@ -0,0 +1,15 @@
export const attempsKeys = {
createByFingerprint: (ip: string, fingerprint: string) => {
return `attempt-${ip}:-:${fingerprint}`
},
createByFingreprintIp: (ip: string, fingerprint: string) => {
return `${ip}///${fingerprint}`
},
parseIp: (ip: string) => {
const paths = ip.split('///')
return {
ip: paths[0],
finreprint: paths[1] ? atob(paths[1]) : null,
}
},
}

1
src/domain/sessions/helpers/index.ts

@ -0,0 +1 @@ @@ -0,0 +1 @@
export * from './attemps-keys.helper'

1
src/domain/sessions/interfaces/index.ts

@ -1 +1,2 @@ @@ -1 +1,2 @@
export * from './sessions-module-options.interface'
export * from './sessions-repository.inteface'

6
src/domain/sessions/interfaces/sessions-module-options.interface.ts

@ -0,0 +1,6 @@ @@ -0,0 +1,6 @@
export interface ISessionsModuleOptions {
maxAttempsByIp: number
maxAttempsByFingerprint: number
maxAttempsByTime: number
maxGapForTimeAttemps: number
}

79
src/domain/sessions/services/auth-attemptions-by-fingeprint.service.ts

@ -0,0 +1,79 @@ @@ -0,0 +1,79 @@
import { Inject, Injectable } from '@nestjs/common'
import { defaultTo } from 'lodash'
import { IPs, Sessions } from 'src/core'
import { RedisService } from 'src/libs'
import { attempsKeys } from '../helpers'
import { IPS_SERVICE } from 'src/core/consts'
import { EventEmitter2 } from '@nestjs/event-emitter'
import { Events } from 'src/core/enums'
import { SESSIONS_MODULE_OPTIONS } from '../consts'
import { ISessionsModuleOptions } from '../interfaces'
@Injectable()
export class AuthAttemtionsByFingerprintService {
private readonly maxAtempts: number = 10
constructor(
@Inject(IPS_SERVICE)
private readonly ipsService: IPs.IIPsService,
private readonly redisService: RedisService,
private readonly eventEmitter: EventEmitter2,
@Inject(SESSIONS_MODULE_OPTIONS)
options: ISessionsModuleOptions,
) {
this.maxAtempts = options.maxAttempsByFingerprint
}
public async add(payload: Sessions.AddAuthAttempPayload) {
const key = this.createStoreKey(payload.ip, payload.fingerprint)
const attempsCount = await this.getAttemps(key)
await this.increase(key, attempsCount)
const isNeedBlock = await this.checkNeedToBlock(key)
if (isNeedBlock) {
await this.block(payload)
return true
}
}
public async clear(key: string) {
await this.redisService.del(key)
}
public async clearByPayload(ip: string, fingerprint: string) {
await this.clear(attempsKeys.createByFingerprint(ip, fingerprint))
}
private async increase(key: string, existCount = 0) {
await this.redisService.set(key, Number(existCount) + 1)
}
private createStoreKey(ip: string, fingerpint: string) {
return attempsKeys.createByFingerprint(ip, fingerpint)
}
private async getAttemps(key: string) {
const res = await this.redisService.get(key)
return defaultTo(Number(res), 0)
}
private async checkNeedToBlock(key: string) {
const attempsCount = await this.getAttemps(key)
return Number(attempsCount) > this.maxAtempts
}
private async block(payload: Sessions.AddAuthAttempPayload) {
await this.ipsService.store({
ip: attempsKeys.createByFingreprintIp(payload.ip, payload.fingerprint),
listType: IPs.IPListType.Black,
userId: payload.targetUserId,
})
this.eventEmitter.emit(Events.OnTooManyAuthAttempts, {
ip: attempsKeys.createByFingreprintIp(payload.ip, payload.fingerprint),
userId: payload.targetUserId,
})
await this.clearByPayload(payload.ip, payload.fingerprint)
}
}

60
src/domain/sessions/services/auth-attemptions-by-ips.service.ts

@ -0,0 +1,60 @@ @@ -0,0 +1,60 @@
import { Inject, Injectable } from '@nestjs/common'
import { EventEmitter2 } from '@nestjs/event-emitter'
import { IPs } from 'src/core'
import { IPS_SERVICE } from 'src/core/consts'
import { Events } from 'src/core/enums'
import { RedisService } from 'src/libs'
import { ISessionsModuleOptions } from '../interfaces'
import { SESSIONS_MODULE_OPTIONS } from '../consts'
@Injectable()
export class AuthAttemptionsByIpsService {
private readonly maxAtempts: number = 40
constructor(
@Inject(IPS_SERVICE)
private readonly ipsService: IPs.IIPsService,
private readonly redisService: RedisService,
private readonly eventEmitter: EventEmitter2,
@Inject(SESSIONS_MODULE_OPTIONS)
options: ISessionsModuleOptions,
) {
this.maxAtempts = options.maxAttempsByIp
}
public async add(ip: string, targetUserId?: number) {
const attempts = await this.increment(ip)
if (attempts < this.maxAtempts) return
await this.ipsService.store({ ip, listType: IPs.IPListType.Black, userId: targetUserId })
this.eventEmitter.emit(Events.OnTooManyAuthAttempts, { ip })
return true
}
private async increment(ip: string) {
const storeKey = this.createStoreKey(ip)
const res = await this.redisService.get(storeKey)
if (!res) {
await this.redisService.set(storeKey, 1)
return 1
}
const attemps = Number(res) + 1
await this.redisService.set(storeKey, attemps)
return attemps
}
public async dropCount(ip: string) {
await this.redisService.set(this.createStoreKey(ip), 0)
}
private createStoreKey(ip: string) {
return `attempt-${ip}`
}
}

97
src/domain/sessions/services/auth-attemptions-by-time.service.ts

@ -0,0 +1,97 @@ @@ -0,0 +1,97 @@
import { Inject, Injectable } from '@nestjs/common'
import { EventEmitter2 } from '@nestjs/event-emitter'
import { IPs, Sessions } from 'src/core'
import { IPS_SERVICE } from 'src/core/consts'
import { Events } from 'src/core/enums'
import { ISessionsModuleOptions } from '../interfaces'
import { SESSIONS_MODULE_OPTIONS } from '../consts'
type UserId = number
type AttempData = {
timestamp: number
count: number
}
@Injectable()
export class AuthAttemptionsByTimeService {
private timestampMaxGap = 1000 * 10 // 10 minutes
private maxAttemps = 3
private db: Record<UserId, AttempData> = {}
constructor(
@Inject(IPS_SERVICE)
private readonly ipsService: IPs.IIPsService,
private readonly eventEmitter: EventEmitter2,
@Inject(SESSIONS_MODULE_OPTIONS)
options: ISessionsModuleOptions,
) {
this.timestampMaxGap = options.maxGapForTimeAttemps
this.maxAttemps = options.maxAttempsByTime
}
public async add(payload: Sessions.AddAuthAttempPayload) {
const exist = this.db[payload.targetUserId]
if (!exist) {
this.saveFirstAttemp(payload.targetUserId)
return
}
if (this.isAttempExpired(exist)) {
this.resetAttemps(payload.targetUserId)
return
}
this.increaseCount(payload.targetUserId, Number(exist.count) + 1)
if (this.isRechMaxAttemps(payload.targetUserId)) {
await this.ipsService.store({
ip: payload.ip,
listType: IPs.IPListType.Black,
userId: payload.targetUserId,
})
this.eventEmitter.emit(Events.OnTooManyAuthAttempts, { ip: payload.ip })
return true
}
return false
}
private saveFirstAttemp(userId: number) {
this.resetAttemps(userId)
}
private resetAttemps(userId: number) {
this.db[userId] = {
timestamp: new Date().getTime(),
count: 1,
}
}
private isAttempExpired(attemp: AttempData) {
const now = new Date().getTime()
const gap = Number(now) - Number(attemp.timestamp)
return gap > this.timestampMaxGap
}
private increaseCount(userId: number, newCount: number) {
this.db[userId] = {
timestamp: new Date().getTime(),
count: newCount,
}
}
private isRechMaxAttemps(userId: number) {
const data = this.db[userId]
if (!data) return false
if (data.count >= this.maxAttemps) return true
}
public isUserBlocked(userId: number) {
const exist = this.db[userId]
if (exist) return false
if (this.isAttempExpired(exist)) return false
return this.isRechMaxAttemps(userId)
}
}

99
src/domain/sessions/services/auth-attemptions.service.ts

@ -1,51 +1,86 @@ @@ -1,51 +1,86 @@
import { Inject, Injectable } from '@nestjs/common'
import { EventEmitter2 } from '@nestjs/event-emitter'
import { IPs } from 'src/core'
import { IPS_SERVICE } from 'src/core/consts'
import { Events } from 'src/core/enums'
import { RedisService } from 'src/libs'
import { IPs, Mailer, Sessions } from 'src/core'
import { IPS_SERVICE, MAILER_SERVICE } from 'src/core/consts'
import { AuthAttemptionsByIpsService } from './auth-attemptions-by-ips.service'
import { AuthAttemtionsByFingerprintService } from './auth-attemptions-by-fingeprint.service'
import { USERS_REPOSITORY } from 'src/domain/users/consts'
import { IUsersRepository } from 'src/domain/users/interfaces'
import { attempsKeys } from '../helpers'
import { IPS_REPOSITORY } from 'src/domain/ips/consts'
import { IIPsRepository } from 'src/domain/ips/interfaces'
import { BlockedIpAlert } from '../classes'
import { AuthAttemptionsByTimeService } from './auth-attemptions-by-time.service'
@Injectable()
export class AuthAttemptionsService {
private readonly maxAtempts = 15
export class AuthAttemptionsService implements Sessions.IAuthAttemptionsService {
constructor(
private readonly redisService: RedisService,
@Inject(IPS_SERVICE) private readonly ipsService: IPs.IIPsService,
private eventEmitter: EventEmitter2,
) {}
@Inject(IPS_SERVICE)
private readonly ipsService: IPs.IIPsService,
public async add(ip: string, data: Record<string, any> = {}) {
const listType = await this.ipsService.getIpListType(ip)
if (listType === IPs.IPListType.White) return
@Inject(USERS_REPOSITORY)
private readonly usersRepository: IUsersRepository,
const attempts = await this.increment(ip)
@Inject(MAILER_SERVICE)
private readonly mailerService: Mailer.IMailerService,
if (attempts < this.maxAtempts) return
@Inject(IPS_REPOSITORY)
private readonly ipsRepository: IIPsRepository,
await this.ipsService.store({ ip, listType: IPs.IPListType.Black })
this.eventEmitter.emit(Events.OnTooManyAuthAttempts, { ip, ...data })
}
private readonly authAttemptionsByIpsService: AuthAttemptionsByIpsService,
private readonly authAttemtionsByFingerprintService: AuthAttemtionsByFingerprintService,
private readonly authAttemptionsByTimeService: AuthAttemptionsByTimeService,
) {}
public async add(payload: Sessions.AddAuthAttempPayload) {
const listType = await this.ipsService.getIpListType(payload.ip)
if (listType === IPs.IPListType.White) return
private async increment(ip: string) {
const storeKey = this.createStoreKey(ip)
const res = await this.redisService.get(storeKey)
let blockedIp = null
if (!res) {
await this.redisService.set(storeKey, 1)
return 1
if (payload.targetUserId) {
const wasBlockedByTime = await this.authAttemptionsByTimeService.add(payload)
if (wasBlockedByTime) blockedIp = payload.ip
}
const attemps = Number(res) + 1
if (!blockedIp) {
const wasBlocked = await this.authAttemptionsByIpsService.add(
payload.ip,
payload.targetUserId,
)
if (wasBlocked) {
blockedIp = payload.ip
}
}
if (!blockedIp && payload.fingerprint) {
const wasBlockedByFingreprirnt = await this.authAttemtionsByFingerprintService.add(
payload,
)
if (wasBlockedByFingreprirnt) {
blockedIp = attempsKeys.createByFingreprintIp(payload.ip, payload.fingerprint)
}
}
await this.redisService.set(storeKey, attemps)
return attemps
if (blockedIp) {
new BlockedIpAlert(this.usersRepository, this.mailerService, this.ipsRepository)
.setIp(blockedIp)
.sendAlerts()
}
}
public async dropCount(ip: string) {
await this.redisService.set(this.createStoreKey(ip), 0)
await this.authAttemptionsByIpsService.dropCount(ip)
await this.ipsService.removeIps([ip])
}
private createStoreKey(ip: string) {
return `attempt-${ip}`
public async checkAuthAttempIsAviable(targetUserId: number, ip: string) {
const isAble = this.authAttemptionsByTimeService.isUserBlocked(targetUserId)
if (isAble) return true
await this.authAttemptionsByTimeService.add({
targetUserId,
ip,
})
return false
}
}

13
src/domain/sessions/services/index.ts

@ -1,7 +1,16 @@ @@ -1,7 +1,16 @@
import { SessionsService } from './sessions.service'
import { AuthAttemptionsService } from './auth-attemptions.service'
import { ConfirmationCodesService } from './confirmation-codes.service'
import { AuthAttemptionsByIpsService } from './auth-attemptions-by-ips.service'
import { AuthAttemtionsByFingerprintService } from './auth-attemptions-by-fingeprint.service'
import { AuthAttemptionsByTimeService } from './auth-attemptions-by-time.service'
export const SESSIONS_SERVICES = [AuthAttemptionsService, ConfirmationCodesService]
export const SESSIONS_SERVICES = [
AuthAttemptionsService,
ConfirmationCodesService,
AuthAttemptionsByIpsService,
AuthAttemtionsByFingerprintService,
AuthAttemptionsByTimeService,
]
export { SessionsService, ConfirmationCodesService }
export { SessionsService, ConfirmationCodesService, AuthAttemptionsByIpsService }

4
src/domain/sessions/services/sessions.service.ts

@ -161,8 +161,8 @@ export class SessionsService implements Sessions.ISessionsService { @@ -161,8 +161,8 @@ export class SessionsService implements Sessions.ISessionsService {
await this.redisService.set(token, 'true', 360)
}
public async addAuthAttemption(attempterIp: string, data?: Record<string, any>) {
return await this.authAtteptionsService.add(attempterIp, data)
public async addAuthAttemption(payload: Sessions.AddAuthAttempPayload) {
return await this.authAtteptionsService.add(payload)
}
public async dropAuthAttemptions(attempterIp: string) {

15
src/domain/sessions/sessions.module.ts

@ -3,22 +3,29 @@ import { CONFIRMATION_CODES_SERVICE, SESSIONS_SERVICE } from 'src/core/consts' @@ -3,22 +3,29 @@ import { CONFIRMATION_CODES_SERVICE, SESSIONS_SERVICE } from 'src/core/consts'
import { JwtModule, provideEntity, RedisModule } from 'src/libs'
import { provideClass } from 'src/shared'
import { SmsModule, UsersModule } from '..'
import { MailerModule, SmsModule, UsersModule } from '..'
import { IPsModule } from '../ips/ips.module'
import { SESSIONS_REPOSITORY } from './consts'
import { SESSIONS_MODULE_OPTIONS, SESSIONS_REPOSITORY } from './consts'
import { Session } from './entities'
import { AuthRoleGuard, AuthGuard, AuthOptionalGuard } from './guards'
import { ConfirmationCodesService, SessionsService, SESSIONS_SERVICES } from './services'
import { ISessionsModuleOptions } from './interfaces'
@Global()
@Module({})
export class SessionsModule {
private static options: ISessionsModuleOptions
static getProviders() {
return [
provideClass(SESSIONS_SERVICE, SessionsService),
provideClass(CONFIRMATION_CODES_SERVICE, ConfirmationCodesService),
provideEntity(SESSIONS_REPOSITORY, Session),
{
provide: SESSIONS_MODULE_OPTIONS,
useValue: this.options,
},
AuthGuard,
AuthRoleGuard,
AuthOptionalGuard,
@ -32,6 +39,7 @@ export class SessionsModule { @@ -32,6 +39,7 @@ export class SessionsModule {
RedisModule.forFeature(),
IPsModule.forFeature(),
SmsModule.forFeature(),
MailerModule.forFeature(),
UsersModule.forFeature(),
]
}
@ -40,7 +48,8 @@ export class SessionsModule { @@ -40,7 +48,8 @@ export class SessionsModule {
return [SESSIONS_SERVICE, CONFIRMATION_CODES_SERVICE]
}
static forRoot(): DynamicModule {
static forRoot(options: ISessionsModuleOptions): DynamicModule {
this.options = options
return {
module: SessionsModule,
imports: SessionsModule.getImports(),

1
src/domain/tasks/crons/index.ts

@ -0,0 +1 @@ @@ -0,0 +1 @@
export * from './tasks-dedlines.cron'

88
src/domain/tasks/crons/tasks-dedlines.cron.ts

@ -0,0 +1,88 @@ @@ -0,0 +1,88 @@
import { Inject, Injectable } from '@nestjs/common'
import { TASKS_REPOSITORY } from '../consts'
import { ITasksRepository } from '../interfaces'
import { Cron } from 'src/shared'
import * as moment from 'moment'
import { Tasks } from 'src/core'
import { EventEmitter2 } from '@nestjs/event-emitter'
import { Events } from 'src/core/enums'
@Injectable()
export class TasksDedlinesCron extends Cron {
constructor(
@Inject(TASKS_REPOSITORY)
private readonly tasksRepository: ITasksRepository,
private eventEmitter: EventEmitter2,
) {
super()
}
protected register(): void {
this.start('0 10 * * *', this.everyDayBody.bind(this))
this.start('0 10 * * 0', this.everyWeekBody.bind(this))
}
protected async everyDayBody() {
try {
await this.findPreDeadlineTasks()
} catch (e) {}
try {
await this.findExpiredTasks()
} catch (e) {}
}
protected async everyWeekBody() {
const now = moment(new Date()).format('YYYY-MM-DD')
const tasks = await this.tasksRepository
.createQueryBuilder('it')
.where('it.endDate < :now', { now })
.andWhere('it.doneDate IS NULL')
.andWhere('it.status = :status', { status: Tasks.Status.Active })
.getMany()
console.log('everyWeekBody', tasks)
for await (const task of tasks) {
this.eventEmitter.emit(Events.OnTaskDeadlineExpired, {
task,
})
}
}
protected async findPreDeadlineTasks() {
const tomorrowDate = moment(new Date()).add(1, 'days').format('YYYY-MM-DD')
const tasks = await this.tasksRepository
.createQueryBuilder('it')
.where('it.endDate = :endDate', { endDate: tomorrowDate })
.andWhere('it.doneDate IS NULL')
.andWhere('it.status = :status', { status: Tasks.Status.Active })
.getMany()
console.log('findPreDeadlineTasks', tasks)
for await (const task of tasks) {
this.eventEmitter.emit(Events.OnTaskDeadlineSoon, {
task,
})
}
}
protected async findExpiredTasks() {
const date = moment(new Date()).subtract(1, 'days').format('YYYY-MM-DD')
const tasks = await this.tasksRepository
.createQueryBuilder('it')
.where('it.endDate = :endDate', { endDate: date })
.andWhere('it.doneDate IS NULL')
.andWhere('it.status = :status', { status: Tasks.Status.Active })
.getMany()
console.log('findExpiredTasks', tasks)
for await (const task of tasks) {
this.eventEmitter.emit(Events.OnTaskDeadlineExpired, {
task,
})
}
}
}

2
src/domain/tasks/tasks.module.ts

@ -33,6 +33,7 @@ import { @@ -33,6 +33,7 @@ import {
TasksService,
TASKS_SERVICES,
} from './services'
import { TasksDedlinesCron } from './crons'
@Module({})
export class TasksModule {
@ -82,6 +83,7 @@ export class TasksModule { @@ -82,6 +83,7 @@ export class TasksModule {
return {
module: TasksModule,
imports: TasksModule.getImports(),
providers: [provideEntity(TASKS_REPOSITORY, Task), TasksDedlinesCron],
}
}

1
src/domain/users/classes/index.ts

@ -1 +1,2 @@ @@ -1 +1,2 @@
export * from './user-creator'
export * from './user-full-name'

9
src/domain/users/classes/user-full-name.ts

@ -0,0 +1,9 @@ @@ -0,0 +1,9 @@
import { Users } from 'src/core'
export class UserFullName {
static getFromUser(user: Users.UserModel) {
if (!user?.info) return 'Uknow User'
return `${user.info.firstName} ${user.info.lastName}`
}
}

10
src/rest/admin/auth/controllers/admin-auth.controller.ts

@ -9,7 +9,7 @@ import { @@ -9,7 +9,7 @@ import {
} from '../dto'
import { AdminAuthService } from '../services'
import { AuthGuard } from 'src/domain/sessions/decorators'
import { ReqUser } from 'src/shared'
import { ReqFingreprint, ReqUser } from 'src/shared'
import { RealIP } from 'nestjs-real-ip'
@ApiTags('Admin | Auth')
@ -25,8 +25,12 @@ export class AdminAuthController { @@ -25,8 +25,12 @@ export class AdminAuthController {
type: TokenPairDto,
})
@Post()
public async login(@RealIP() ip: string, @Body() dto: AdminLoginDto) {
return await this.adminAuthService.signIn(ip, dto)
public async login(
@RealIP() ip: string,
@ReqFingreprint() fingreprint: string,
@Body() dto: AdminLoginDto,
) {
return await this.adminAuthService.signIn(ip, dto, fingreprint)
}
@ApiOperation({ summary: 'Вихід користувача' })

9
src/rest/admin/auth/controllers/admin-password-recovery.controller.ts

@ -2,6 +2,7 @@ import { Body, Controller, Ip, Post } from '@nestjs/common' @@ -2,6 +2,7 @@ import { Body, Controller, Ip, Post } from '@nestjs/common'
import { ApiBody, ApiOkResponse, ApiOperation, ApiTags } from '@nestjs/swagger'
import { ConfirmPasswordRecoveryDto, RequestCodeDto, TokenPairDto } from '../dto'
import { AdminPasswordRecoveryService } from '../services'
import { ReqFingreprint } from 'src/shared'
@ApiTags('Admin | Auth | Password recovery')
@Controller('admin/auth/password-recovery')
@ -23,7 +24,11 @@ export class AdminPasswordRecoveryController { @@ -23,7 +24,11 @@ export class AdminPasswordRecoveryController {
type: TokenPairDto,
})
@Post()
public async confirmRecovery(@Ip() ip: string, @Body() dto: ConfirmPasswordRecoveryDto) {
return await this.adminPasswordRecoveryService.confirmRecovery(ip, dto)
public async confirmRecovery(
@ReqFingreprint() fingreprint: string,
@Ip() ip: string,
@Body() dto: ConfirmPasswordRecoveryDto,
) {
return await this.adminPasswordRecoveryService.confirmRecovery(ip, dto, fingreprint)
}
}

33
src/rest/admin/auth/services/admin-auth.service.ts

@ -10,23 +10,38 @@ import { AdminLoginDto, CheckCodePayloadDto, LogoutPayloadDto, RefreshTokenDto } @@ -10,23 +10,38 @@ import { AdminLoginDto, CheckCodePayloadDto, LogoutPayloadDto, RefreshTokenDto }
@Injectable()
export class AdminAuthService {
constructor(
@Inject(USERS_SERVICE) private readonly usersService: Users.IUsersService,
@Inject(SESSIONS_SERVICE) private readonly sessionsService: Sessions.ISessionsService,
@Inject(USERS_SERVICE)
private readonly usersService: Users.IUsersService,
@Inject(SESSIONS_SERVICE)
private readonly sessionsService: Sessions.ISessionsService,
@Inject(CONFIRMATION_CODES_SERVICE)
private readonly confirmationCodesService: Sessions.IConfirmationCodesService,
private readonly eventEmitter: EventEmitter2,
) {}
public async signIn(ip: string, dto: AdminLoginDto) {
public async signIn(ip: string, dto: AdminLoginDto, fingerprint: string) {
const user = await this.usersService.getOneByEmailOrLogin(dto.login)
if (!user || user.status === Users.Status.Deleted || user.status === Users.Status.Blocked)
if (!user || user.status === Users.Status.Deleted || user.status === Users.Status.Blocked) {
await this.sessionsService.addAuthAttemption({
ip,
fingerprint,
})
throw new InvalidCredentialsException()
}
const isCorrect = await this.usersService.compareUserPassword(user.id, dto.password)
if (!isCorrect) {
await this.sessionsService.addAuthAttemption(ip, { userId: user.id })
await this.sessionsService.addAuthAttemption({
ip,
targetUserId: user.id,
fingerprint,
})
throw new InvalidCredentialsException()
}
@ -63,8 +78,14 @@ export class AdminAuthService { @@ -63,8 +78,14 @@ export class AdminAuthService {
public async checkCode(ip: string, dto: CheckCodePayloadDto) {
const isCorrect = await this.confirmationCodesService.compareCode(dto.phoneNumber, dto.code)
const user = await this.usersService.getOneByPhoneNumber(dto.phoneNumber)
if (!isCorrect) await this.sessionsService.addAuthAttemption(ip)
if (!isCorrect) {
await this.sessionsService.addAuthAttemption({
ip,
targetUserId: user ? user.id : null,
})
}
return isCorrect
}

42
src/rest/admin/auth/services/admin-password-recovery.service.ts

@ -8,6 +8,7 @@ import { @@ -8,6 +8,7 @@ import {
} from 'src/core/consts'
import { InvalidCredentialsException, WrongCodeException } from 'src/shared'
import { ConfirmPasswordRecoveryDto } from '../dto'
import { attempsKeys } from 'src/domain/sessions/helpers'
@Injectable()
export class AdminPasswordRecoveryService {
@ -20,26 +21,42 @@ export class AdminPasswordRecoveryService { @@ -20,26 +21,42 @@ export class AdminPasswordRecoveryService {
) {}
public async requestCode(ip: string, phoneNumber: string) {
const user = await this.usersService.getOneByPhoneNumber(phoneNumber)
try {
const user = await this.usersService.getOneByPhoneNumber(phoneNumber)
if (!user || user.status === Users.Status.Deleted || user.status === Users.Status.Blocked) {
await this.sessionsService.addAuthAttemption(ip, { userId: user ? user.id : null })
throw new InvalidCredentialsException()
}
if (
!user ||
user.status === Users.Status.Deleted ||
user.status === Users.Status.Blocked
) {
await this.sessionsService.addAuthAttemption({
ip,
targetUserId: user?.id,
})
throw new InvalidCredentialsException()
}
await this.confirmationCodesService.sendConfirmationCode(
phoneNumber,
async (code: string) => await this.smsService.send({ to: phoneNumber, text: code }),
)
await this.confirmationCodesService.sendConfirmationCode(
phoneNumber,
// async (code: string) => await this.smsService.send({ to: phoneNumber, text: code }),
(code: string) => console.log('code', code),
)
} catch (e) {
console.log('e', e)
}
}
public async confirmRecovery(ip: string, dto: ConfirmPasswordRecoveryDto) {
public async confirmRecovery(ip: string, dto: ConfirmPasswordRecoveryDto, fingerprint: string) {
const isCorrect = await this.confirmationCodesService.confirmCode(dto.phoneNumber, dto.code)
const user = await this.usersService.getOneByPhoneNumber(dto.phoneNumber)
if (!isCorrect) {
this.sessionsService.addAuthAttemption(ip, { userId: user ? user.id : null })
this.sessionsService.addAuthAttemption({
ip,
targetUserId: user.id,
fingerprint,
})
throw new WrongCodeException()
}
@ -53,6 +70,9 @@ export class AdminPasswordRecoveryService { @@ -53,6 +70,9 @@ export class AdminPasswordRecoveryService {
})
await this.sessionsService.dropAuthAttemptions(ip)
await this.sessionsService.dropAuthAttemptions(
attempsKeys.createByFingreprintIp(ip, fingerprint),
)
return { accessToken: session.accessToken, refreshToken: session.refreshToken }
}

33
src/rest/admin/notifications/admin-notifications.module.ts

@ -1,18 +1,39 @@ @@ -1,18 +1,39 @@
import { DynamicModule, Module } from '@nestjs/common'
import { NotificationsModule } from 'src/domain'
import { MailerModule, NotificationsModule, UsersModule } from 'src/domain'
import { JwtModule } from 'src/libs'
import { AdminNotificationsController, AdminNotificationsSettingsController } from './controllers'
import { AdminNotificationsService, AdminNotificationsSettingsService } from './services'
import {
AdminNotificationsController,
AdminNotificationsSendController,
AdminNotificationsSettingsController,
} from './controllers'
import {
AdminNotificationsSendService,
AdminNotificationsService,
AdminNotificationsSettingsService,
} from './services'
@Module({})
export class AdminNotificationsModule {
static forRoot(): DynamicModule {
return {
module: AdminNotificationsModule,
imports: [NotificationsModule.forFeature(), JwtModule.forFeature()],
providers: [AdminNotificationsService, AdminNotificationsSettingsService],
controllers: [AdminNotificationsController, AdminNotificationsSettingsController],
imports: [
NotificationsModule.forFeature(),
JwtModule.forFeature(),
UsersModule.forFeature(),
MailerModule.forFeature(),
],
providers: [
AdminNotificationsService,
AdminNotificationsSettingsService,
AdminNotificationsSendService,
],
controllers: [
AdminNotificationsController,
AdminNotificationsSettingsController,
AdminNotificationsSendController,
],
}
}
}

27
src/rest/admin/notifications/controllers/admin-notifications-send.controller.ts

@ -0,0 +1,27 @@ @@ -0,0 +1,27 @@
import { Body, Controller, Post } from '@nestjs/common'
import { ApiOkResponse, ApiOperation, ApiTags } from '@nestjs/swagger'
import { AdminNotificationsSendService } from '../services'
import { SendNotificationsPayloadDto, SendNotificationsResultDto } from '../dto'
import { RoleGuard } from 'src/domain/sessions/decorators'
import { Users } from 'src/core'
import { ReqUser } from 'src/shared'
@ApiTags('Admin | Notifications')
@Controller('admin/notifications-send')
export class AdminNotificationsSendController {
constructor(private readonly adminNotificationsSendService: AdminNotificationsSendService) {}
@ApiOperation({
summary: 'Відправка нотифікацій групі користувачів',
})
@ApiOkResponse({
description:
'Повертає імена користувачів яким були успішно відправлені нотифікацій або не успішно',
type: SendNotificationsResultDto,
})
@RoleGuard(Users.Role.Admin)
@Post()
send(@ReqUser() userId: number, @Body() dto: SendNotificationsPayloadDto) {
return this.adminNotificationsSendService.send(userId, dto)
}
}

3
src/rest/admin/notifications/controllers/index.ts

@ -1,2 +1,3 @@ @@ -1,2 +1,3 @@
export * from './admin-notifications.controller'
export * from './admin-notifications-send.controller'
export * from './admin-notifications-settings.controller'
export * from './admin-notifications.controller'

3
src/rest/admin/notifications/dto/index.ts

@ -1,3 +1,4 @@ @@ -1,3 +1,4 @@
export * from './save-admin-notifications-settings.dto'
export * from './add-user-device-payload.dto'
export * from './read-notifications-by-ids.dto'
export * from './save-admin-notifications-settings.dto'
export * from './send-notifications.dto'

23
src/rest/admin/notifications/dto/send-notifications.dto.ts

@ -0,0 +1,23 @@ @@ -0,0 +1,23 @@
import { DtoProperty, DtoPropertyOptional } from 'src/shared'
export class SendNotificationsPayloadDto {
@DtoProperty()
usersIds: number[]
@DtoProperty()
title: string
@DtoProperty()
content: string
@DtoPropertyOptional()
haveToSendEmail?: boolean
}
export class SendNotificationsResultDto {
@DtoProperty()
successSentUsersNames: string[]
@DtoProperty()
failedSentUsersNames: string[]
}

72
src/rest/admin/notifications/services/admin-notifications-send.service.ts

@ -0,0 +1,72 @@ @@ -0,0 +1,72 @@
import { Inject, Injectable } from '@nestjs/common'
import { Mailer, Notifications, Users } from 'src/core'
import { MAILER_SERVICE, NOTIFICATIONS_SERVICE, USERS_SERVICE } from 'src/core/consts'
import { SendNotificationsPayloadDto, SendNotificationsResultDto } from '../dto'
import { UserFullName } from 'src/domain/users/classes'
@Injectable()
export class AdminNotificationsSendService {
constructor(
@Inject(NOTIFICATIONS_SERVICE)
private readonly notificationsService: Notifications.INotificationsService,
@Inject(USERS_SERVICE)
private readonly usersService: Users.IUsersService,
@Inject(MAILER_SERVICE)
private readonly mailerService: Mailer.IMailerService,
) {}
public async send(userId: number, dto: SendNotificationsPayloadDto) {
const result: SendNotificationsResultDto = {
successSentUsersNames: [],
failedSentUsersNames: [],
}
for await (const targetUserId of dto.usersIds) {
let user: Users.UserModel = null
console.log('dto', dto)
try {
user = await this.usersService.getOne(targetUserId, ['info'])
await this.sendNotificationToOne(targetUserId, dto, userId)
if (dto.haveToSendEmail) await this.sendEmailToOne(user.email, dto)
result.successSentUsersNames.push(UserFullName.getFromUser(user))
} catch (e) {
console.log('error', e)
result.failedSentUsersNames.push(UserFullName.getFromUser(user))
}
}
}
private async sendNotificationToOne(
userId: number,
payload: Omit<SendNotificationsPayloadDto, 'usersIds'>,
authorId: number,
) {
await this.notificationsService.addNotification({
userId,
title: payload.title,
content: payload.content,
group: Notifications.NotificationsGroup.Other,
data: {
sentByUserId: authorId,
},
})
}
private async sendEmailToOne(
email: string,
payload: Omit<SendNotificationsPayloadDto, 'usersIds'>,
) {
console.log('send email', email)
await this.mailerService.send({
to: email,
subject: payload.title,
text: payload.content,
})
}
}

3
src/rest/admin/notifications/services/index.ts

@ -1,2 +1,3 @@ @@ -1,2 +1,3 @@
export * from './admin-notifications.service'
export * from './admin-notifications-send.service'
export * from './admin-notifications-settings.service'
export * from './admin-notifications.service'

10
src/rest/app/auth/controllers/app-auth.controller.ts

@ -1,7 +1,7 @@ @@ -1,7 +1,7 @@
import { Body, Controller, Ip, Post } from '@nestjs/common'
import { ApiBody, ApiOkResponse, ApiOperation, ApiTags } from '@nestjs/swagger'
import { AuthGuard } from 'src/domain/sessions/decorators'
import { ReqUser } from 'src/shared'
import { ReqFingreprint, ReqUser } from 'src/shared'
import {
ConfirmLoginCodePayloadDto,
LoginPayloadDto,
@ -44,8 +44,12 @@ export class AppAuthController { @@ -44,8 +44,12 @@ export class AppAuthController {
type: TokenPairDto,
})
@Post('confirm-code')
public async confirmCode(@Ip() ip: string, @Body() dto: ConfirmLoginCodePayloadDto) {
return await this.appAuthService.confirmCode(ip, dto)
public async confirmCode(
@Ip() ip: string,
@Body() dto: ConfirmLoginCodePayloadDto,
@ReqFingreprint() fingerprint: string,
) {
return await this.appAuthService.confirmCode(ip, dto, fingerprint)
}
@ApiOperation({ summary: 'Перевірка коду' })

19
src/rest/app/auth/services/app-auth.service.ts

@ -84,7 +84,7 @@ export class AppAuthService implements OnModuleInit { @@ -84,7 +84,7 @@ export class AppAuthService implements OnModuleInit {
)
}
public async confirmCode(ip: string, dto: ConfirmLoginCodePayloadDto) {
public async confirmCode(ip: string, dto: ConfirmLoginCodePayloadDto, fingerprint: string) {
const isCorrect = await this.confirmationCodesService.confirmCode(
dto.phoneNumber,
dto.code,
@ -94,7 +94,14 @@ export class AppAuthService implements OnModuleInit { @@ -94,7 +94,14 @@ export class AppAuthService implements OnModuleInit {
const user = await this.usersService.getOneByPhoneNumber(dto.phoneNumber)
if (!isCorrect) {
this.sessionsService.addAuthAttemption(ip, { userId: user ? user.id : null })
this.sessionsService.addAuthAttemption({
ip,
targetUserId: user.id,
fingerprint: fingerprint,
data: {
login: dto.phoneNumber,
},
})
throw new WrongCodeException()
}
@ -118,7 +125,13 @@ export class AppAuthService implements OnModuleInit { @@ -118,7 +125,13 @@ export class AppAuthService implements OnModuleInit {
public async checkCode(ip: string, dto: CheckCodePayloadDto) {
const isCorrect = await this.confirmationCodesService.compareCode(dto.phoneNumber, dto.code)
if (!isCorrect) await this.sessionsService.addAuthAttemption(ip)
if (!isCorrect)
await this.sessionsService.addAuthAttemption({
ip,
data: {
login: dto.phoneNumber,
},
})
return isCorrect
}

31
src/shared/abstracts/cron.abstract.ts

@ -0,0 +1,31 @@ @@ -0,0 +1,31 @@
import { OnModuleDestroy } from '@nestjs/common'
import { CronJob } from 'cron'
export abstract class Cron implements OnModuleDestroy {
static isEnabled = null
static setup(isEnabled: boolean) {
this.isEnabled = isEnabled
}
protected abstract register(): void
protected job: CronJob
onModuleInit() {
if (Cron.isEnabled === null) throw new Error('Should setup Cron by Cron.setup')
if (Cron.isEnabled) this.register()
}
onModuleDestroy() {
try {
if (this.job) this.job.stop()
} catch (e) {}
}
protected start(time: string, fn: () => void) {
this.job = new CronJob(time, fn, null, true, 'UTC+2')
this.job.start()
}
}

1
src/shared/abstracts/index.ts

@ -1 +1,2 @@ @@ -1 +1,2 @@
export * from './cron.abstract'
export * from './seeder.abstract'

17
src/shared/decorators/index.ts

@ -1,12 +1,13 @@ @@ -1,12 +1,13 @@
export * from './api-description.decorator'
export * from './api-implict-decorator'
export * from './dto-property.decorator'
export * from './req-user.decorator'
export * from './req-user-role.decorator'
export * from './permissions-tabs-guard.decorator'
export * from './req-fingreprint.decorator'
export * from './req-pagination.decorator'
export * from './api-implict-decorator'
export * from './transform-string-to-boolean.decorator'
export * from './transform-string-to-number.decorator'
export * from './req-session-id.decorator'
export * from './req-user-role.decorator'
export * from './req-user.decorator'
export * from './transform-phone-number.decorator'
export * from './transform-remove-null-fields.decorator'
export * from './permissions-tabs-guard.decorator'
export * from './req-session-id.decorator'
export * from './api-description.decorator'
export * from './transform-string-to-boolean.decorator'
export * from './transform-string-to-number.decorator'

7
src/shared/decorators/req-fingreprint.decorator.ts

@ -0,0 +1,7 @@ @@ -0,0 +1,7 @@
import { createParamDecorator, ExecutionContext } from '@nestjs/common'
export const ReqFingreprint = createParamDecorator((data: unknown, ctx: ExecutionContext) => {
const request = ctx.switchToHttp().getRequest()
return request.fingerprint || null
})

1
src/shared/filters/domain-exception.filter.ts

@ -32,6 +32,7 @@ export class DomainExceptionsFilter implements ExceptionFilter { @@ -32,6 +32,7 @@ export class DomainExceptionsFilter implements ExceptionFilter {
}
this.logger.error(`${ctx.getRequest().url} Catch exeption, status: ${result.status}`)
console.log(result)
// httpAdapter.reply(ctx.getResponse(), result.json, result.status)
return response.status(result.status).json(result.json)

4
tsconfig.build.json

@ -1,4 +1,4 @@ @@ -1,4 +1,4 @@
{
"extends": "./tsconfig.json",
"exclude": ["node_modules", "test", "dist", "**/*spec.ts"]
"extends": "./tsconfig.json",
"exclude": ["node_modules", "test", "dist", "**/*spec.ts", "documentation", "dist"]
}

2
tsconfig.json

@ -12,5 +12,5 @@ @@ -12,5 +12,5 @@
"baseUrl": "./",
"incremental": true
},
"exclude": ["./documentation", "./taskme"]
"exclude": ["./documentation", "./taskme", "./dist"]
}

Loading…
Cancel
Save