├── .dockerignore
├── .gitignore
├── .husky
└── pre-commit
├── .tool-versions
├── .vscode
└── launch.json
├── Dockerfile
├── LICENSE.md
├── README.md
├── config
├── .env
└── .env.development
├── docker-compose.yml
├── eslint.config.cjs
├── html
├── socket.html
├── upload-graphql.html
└── upload-rest.html
├── nginx.conf
├── nodemon.json
├── package-lock.json
├── package.json
├── prettier.config.cjs
├── src
├── app.env.ts
├── app.module.ts
├── cors
│ └── cors.middleware.ts
├── graphql
│ ├── graphql.module.ts
│ ├── graphql.options.ts
│ ├── reloader
│ │ ├── reloader.controller.ts
│ │ ├── reloader.module.ts
│ │ └── reloader.service.ts
│ └── stitching
│ │ ├── stitching.module.ts
│ │ └── stitching.service.ts
├── healthz
│ ├── healthz.controller.ts
│ └── healthz.module.ts
├── jwt
│ ├── jwt.module.ts
│ └── jwt.service.ts
├── logger
│ ├── logger.decorator.ts
│ ├── logger.filter.ts
│ ├── logger.middleware.ts
│ ├── logger.module.ts
│ ├── logger.service.ts
│ └── logger.store.ts
├── main.ts
├── models
│ ├── book
│ │ ├── book.entity.ts
│ │ ├── book.module.ts
│ │ ├── book.resolver.ts
│ │ ├── book.service.ts
│ │ └── mutation-input
│ │ │ └── create.dto.ts
│ ├── models.module.ts
│ └── section
│ │ ├── mutation-input
│ │ └── create.dto.ts
│ │ ├── section.entity.ts
│ │ ├── section.module.ts
│ │ ├── section.resolver.ts
│ │ └── section.service.ts
├── oauth
│ ├── dto
│ │ ├── change-password.dto.ts
│ │ ├── registration-response.dto.ts
│ │ ├── registration.dto.ts
│ │ ├── sign-in-by-password.dto.ts
│ │ ├── sign-in-by-refresh-token.dto.ts
│ │ └── sign-in-response.dto.ts
│ ├── oauth.controller.ts
│ ├── oauth.decorator.ts
│ ├── oauth.middleware.ts
│ ├── oauth.module.ts
│ ├── oauth.service.ts
│ ├── recovery-key
│ │ ├── recovery-key.entity.ts
│ │ └── recovery-key.module.ts
│ ├── refresh-token
│ │ ├── refresh-token.entity.ts
│ │ └── refresh-token.module.ts
│ └── user
│ │ ├── user.entity.ts
│ │ └── user.module.ts
├── rabbitmq
│ ├── rabbitmq.decorator.ts
│ ├── rabbitmq.discovery.ts
│ ├── rabbitmq.module.ts
│ └── rabbitmq.service.ts
├── redis
│ ├── redis.client.ts
│ ├── redis.module.ts
│ └── redis.service.ts
├── repl.module.ts
├── repl.ts
├── typeorm
│ ├── migrations
│ │ ├── .gitkeep
│ │ ├── 1728545269541-generated.ts
│ │ └── 1729581873271-generated.ts
│ ├── typeorm.cli.ts
│ └── typeorm.module.ts
├── upload
│ ├── graphql
│ │ ├── dto
│ │ │ └── upload.object.ts
│ │ ├── upload-graphql.module.ts
│ │ ├── upload-graphql.resolver.ts
│ │ └── upload-graphql.service.ts
│ ├── rest
│ │ ├── upload-rest.controller.ts
│ │ ├── upload-rest.module.ts
│ │ └── upload-rest.service.ts
│ ├── upload.constants.ts
│ └── upload.module.ts
├── utils
│ ├── bcrypt.ts
│ ├── errors.ts
│ ├── promise.ts
│ ├── random.ts
│ ├── request.ts
│ └── validate.ts
└── ws
│ ├── ws.adapter.ts
│ ├── ws.gateway.ts
│ └── ws.module.ts
├── tsconfig.build.json
├── tsconfig.json
└── uploads
└── .gitkeep
/.dockerignore:
--------------------------------------------------------------------------------
1 | # compiled output
2 | dist
3 | node_modules
4 | .env.*.local
5 |
6 | .git
7 |
8 | .yarn/cache
9 | .yarn/install-state.gz
10 |
11 | # Logs
12 | logs
13 | *.log
14 | npm-debug.log*
15 | pnpm-debug.log*
16 | yarn-debug.log*
17 | yarn-error.log*
18 | lerna-debug.log*
19 |
20 | # OS
21 | .DS_Store
22 |
23 | # Tests
24 | /coverage
25 | /.nyc_output
26 |
27 | # IDEs and editors
28 | /.idea
29 | .project
30 | .classpath
31 | .c9/
32 | *.launch
33 | .settings/
34 | *.sublime-workspace
35 |
36 | # IDE - VSCode
37 | .vscode/*
38 | !.vscode/settings.json
39 | !.vscode/tasks.json
40 | !.vscode/launch.json
41 | !.vscode/extensions.json
42 |
43 | schema.graphql
44 | graph.png
45 |
46 | uploads/*
47 | !uploads/.gitkeep
48 | pgdata
49 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # compiled output
2 | dist
3 | node_modules
4 | .env.*.local
5 |
6 | .yarn/cache
7 | .yarn/install-state.gz
8 |
9 | # Logs
10 | logs
11 | *.log
12 | npm-debug.log*
13 | pnpm-debug.log*
14 | yarn-debug.log*
15 | yarn-error.log*
16 | lerna-debug.log*
17 |
18 | # OS
19 | .DS_Store
20 |
21 | # Tests
22 | /coverage
23 | /.nyc_output
24 |
25 | # IDEs and editors
26 | /.idea
27 | .project
28 | .classpath
29 | .c9/
30 | *.launch
31 | .settings/
32 | *.sublime-workspace
33 |
34 | # IDE - VSCode
35 | .vscode/*
36 | !.vscode/settings.json
37 | !.vscode/tasks.json
38 | !.vscode/launch.json
39 | !.vscode/extensions.json
40 |
41 | schema.graphql
42 | graph.png
43 |
44 | uploads/*
45 | !uploads/.gitkeep
46 | pgdata
47 |
--------------------------------------------------------------------------------
/.husky/pre-commit:
--------------------------------------------------------------------------------
1 | npm run lint:fix
--------------------------------------------------------------------------------
/.tool-versions:
--------------------------------------------------------------------------------
1 | nodejs 22.9.0
2 |
--------------------------------------------------------------------------------
/.vscode/launch.json:
--------------------------------------------------------------------------------
1 | {
2 | // ! https://go.microsoft.com/fwlink/?linkid=830387
3 | "version": "0.2.0",
4 | "configurations": [
5 | {
6 | "type": "node",
7 | "request": "launch",
8 | "name": "debug",
9 | "cwd": "${workspaceFolder}",
10 | "runtimeExecutable": "npm",
11 | "args": [
12 | "run",
13 | "start:dev",
14 | ],
15 | "runtimeArgs": [],
16 | "env": {
17 | "NODE_ENV": "development",
18 | "TS_NODE_PROJECT": "${workspaceFolder}/tsconfig.json"
19 | },
20 | },
21 | ]
22 | }
23 |
--------------------------------------------------------------------------------
/Dockerfile:
--------------------------------------------------------------------------------
1 | FROM node:22-alpine AS base
2 |
3 | ENV DOCKER_DEPLOY=true
4 | ENV ALLOW_CONFIG_MUTATIONS=true
5 |
6 | RUN apk add --update --no-cache tzdata \
7 | nano \
8 | bash \
9 | htop \
10 | nginx \
11 | nginx-mod-http-lua \
12 | && cp /usr/share/zoneinfo/UTC /etc/localtime \
13 | && echo "UTC" > /etc/timezone \
14 | && mkdir -p /run/nginx
15 |
16 | COPY ./nginx.conf /etc/nginx/http.d/default.conf
17 |
18 | RUN nginx -t
19 |
20 | FROM base AS build
21 |
22 | WORKDIR /build
23 |
24 | COPY package.json package-lock.json ./
25 |
26 | RUN npm ci
27 |
28 | COPY tsconfig.json tsconfig.build.json ./
29 | COPY config ./config
30 | COPY src ./src
31 |
32 | RUN npm run build
33 |
34 | FROM base
35 |
36 | WORKDIR /server
37 |
38 | ARG env
39 | ENV NODE_ENV=${env}
40 |
41 | COPY --from=build /build/package.json /build/package-lock.json ./
42 |
43 | RUN npm ci --omit=dev
44 |
45 | COPY --from=build /build/config ./config
46 | COPY --from=build /build/dist ./dist
47 |
48 | CMD nginx; npm run start:build
49 |
--------------------------------------------------------------------------------
/LICENSE.md:
--------------------------------------------------------------------------------
1 | The MIT License (MIT)
2 |
3 | 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:
4 |
5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
6 |
7 | 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.
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # NestJS-Example
2 |
3 | NestJS example with using GraphQL (schema stitching, schema reload, dataloader, upload files, subscriptions, response cache), RabbitMQ, Redis, Websocket, JWT authentication, ESLint 9
4 |
5 | ## Dependencies
6 |
7 | * [NodeJS 22](https://nodejs.org/download/release/latest-v22.x/)
8 | * [Redis 7](https://redis.io/download/)
9 | * [PostgreSQL 13+](https://www.postgresql.org/download/)
10 | * [RabbitMQ](https://www.rabbitmq.com/download.html)
11 |
12 | ## Run locally
13 |
14 | ### Installation
15 |
16 | ```bash
17 | npm ci
18 | ```
19 |
20 | ### Run
21 |
22 | ```bash
23 | npm run start:dev
24 | ```
25 |
26 | ### REPL
27 |
28 | ```bash
29 | npm run repl:dev
30 | ```
31 |
32 | ## Run with Docker on host
33 |
34 | ### Build
35 |
36 | ```bash
37 | sudo docker build -t nestjs-example . --build-arg env=development
38 | ```
39 |
40 | ### Run
41 |
42 | ```bash
43 | sudo docker run -d --network host nestjs-example:latest
44 | ```
45 |
46 | ### Get CONTAINER_ID
47 |
48 | ```bash
49 | sudo docker ps -a | grep nestjs-example
50 | ```
51 |
52 | ### Open container
53 |
54 | ```bash
55 | sudo docker exec -it $CONTAINER_ID sh
56 | ```
57 |
58 | ### Show logs
59 |
60 | ```bash
61 | sudo docker logs $CONTAINER_ID
62 | ```
63 |
64 | ### Stop container
65 |
66 | ```bash
67 | sudo docker stop $CONTAINER_ID
68 | ```
69 |
70 | ### Remove container
71 |
72 | ```bash
73 | sudo docker rm $CONTAINER_ID
74 | ```
75 |
76 | ## Run with Docker Compose
77 |
78 | ### Create network
79 |
80 | ```bash
81 | sudo docker network create nestjs-example-network
82 | ```
83 |
84 | ### Run
85 |
86 | ```bash
87 | sudo docker compose up --build
88 | ```
89 |
90 | ## Dependencies graph
91 |
92 | ### Full
93 |
94 | ```bash
95 | npm run madge:full
96 | ```
97 |
98 | ### Circular
99 |
100 | ```bash
101 | npm run madge:circular
102 | ```
103 |
104 | ## Typeorm
105 |
106 | ### Creating a migration file from modified entities
107 |
108 | ```bash
109 | npm run typeorm:generate
110 | ```
111 |
112 | ### Create an empty migration file
113 |
114 | ```bash
115 | npm run typeorm:create
116 | ```
117 |
118 | ### Run migrations
119 |
120 | #### Development
121 |
122 | ```bash
123 | npm run typeorm:run:dev
124 | ```
125 |
126 | #### Build
127 |
128 | ```bash
129 | npm run typeorm:run:build
130 | ```
131 |
--------------------------------------------------------------------------------
/config/.env:
--------------------------------------------------------------------------------
1 | APP_NAME=nestjs_example
2 | APP_PORT=8080
3 | APP_BODY_LIMIT=50mb
4 | APP_BODY_PARAMETER_LIMIT=50000
5 |
6 | WS_PORT=8081
7 | WS_PING_INTERVAL=3000
8 | WS_PING_TIMEOUT=10000
9 | WS_PATH=/socket.io
10 |
11 | DB_DATABASE=nestjs_example
12 | DB_HOST=
13 | DB_PORT=
14 | DB_USERNAME=
15 | DB_PASSWORD=
16 | DB_LOGGING=["error", "schema", "migration"]
17 |
18 | REDIS_HOST=
19 | REDIS_PORT=
20 | REDIS_PASSWORD=
21 | REDIS_KEY=nestjs_example_
22 |
23 | RABBITMQ_HOST=
24 | RABBITMQ_PORT=
25 | RABBITMQ_USERNAME=
26 | RABBITMQ_PASSWORD=
27 | RABBITMQ_VHOST=vhost
28 | RABBITMQ_EXCHANGE=nestjs_example
29 | RABBITMQ_PREFIX=
30 |
31 | LOGGER_LEVEL=info
32 | LOGGER_SILENCE=["healthz"]
33 | LOGGER_KEYWORDS=[]
34 |
35 | CORS_ALLOWED_ORIGINS=[]
36 | CORS_ALLOWED_METHODS=[]
37 | CORS_ALLOWED_PATHS=[]
38 | CORS_CREDENTIALS=false
39 |
40 | JWT_SECRET_KEY=
41 | JWT_ALGORITHM=HS512
42 | JWT_ACCESS_TOKEN_LIFETIME_IN_MINUTES=20
43 | JWT_REFRESH_TOKEN_LIFETIME_IN_MINUTES=10080
44 |
45 | GRAPHQL_API_URLS=[]
46 | GRAPHQL_CACHE_TTL=5000
47 |
--------------------------------------------------------------------------------
/config/.env.development:
--------------------------------------------------------------------------------
1 | DB_HOST=localhost
2 | DB_PORT=5432
3 | DB_USERNAME=postgres
4 | DB_PASSWORD=postgres
5 | DB_LOGGING=true
6 |
7 | REDIS_HOST=localhost
8 | REDIS_PORT=6379
9 | REDIS_KEY=nestjs_example_
10 |
11 | RABBITMQ_HOST=localhost
12 | RABBITMQ_PORT=5672
13 | RABBITMQ_USERNAME=admin
14 | RABBITMQ_PASSWORD=password
15 | RABBITMQ_PREFIX=development_
16 |
17 | JWT_SECRET_KEY=JWT_SECRET_KEY
18 |
--------------------------------------------------------------------------------
/docker-compose.yml:
--------------------------------------------------------------------------------
1 | version: '3'
2 | services:
3 | nestjs-example:
4 | build:
5 | context: .
6 | args:
7 | - env=development
8 | ports:
9 | - '8080:80'
10 | environment:
11 | DB_HOST: nestjs-example-db
12 | DB_PORT: 5432
13 | DB_USERNAME: postgres
14 | DB_PASSWORD: postgres
15 | REDIS_HOST: nestjs-example-redis
16 | REDIS_PORT: 6379
17 | RABBITMQ_HOST: nestjs-example-rabbitmq
18 | RABBITMQ_PORT: 5672
19 | RABBITMQ_USERNAME: admin
20 | RABBITMQ_PASSWORD: password
21 | # volumes:
22 | # - ./appdata:/server/uploads
23 | links:
24 | - nestjs-example-db
25 | - nestjs-example-redis
26 | - nestjs-example-rabbitmq
27 | depends_on:
28 | - nestjs-example-db
29 | - nestjs-example-redis
30 | - nestjs-example-rabbitmq
31 | networks:
32 | - nestjs-example-network
33 | container_name: nestjs-example
34 | restart: on-failure
35 |
36 | nestjs-example-db:
37 | image: postgres:13-alpine
38 | environment:
39 | POSTGRES_PASSWORD: postgres
40 | expose:
41 | - "5432"
42 | # volumes:
43 | # - ./pgdata:/var/lib/postgresql/data
44 | networks:
45 | - nestjs-example-network
46 | container_name: nestjs-example-db
47 | restart: on-failure
48 |
49 | nestjs-example-redis:
50 | image: redis:7-alpine
51 | expose:
52 | - "6379"
53 | # volumes:
54 | # - ./redisdata:/var/lib/redis
55 | networks:
56 | - nestjs-example-network
57 | container_name: nestjs-example-redis
58 | restart: on-failure
59 |
60 | nestjs-example-rabbitmq:
61 | image: rabbitmq:4.0.3-alpine
62 | environment:
63 | RABBITMQ_DEFAULT_USER: admin
64 | RABBITMQ_DEFAULT_PASS: password
65 | RABBITMQ_DEFAULT_VHOST: vhost
66 | expose:
67 | - "5672"
68 | networks:
69 | - nestjs-example-network
70 | container_name: nestjs-example-rabbitmq
71 | restart: on-failure
72 |
73 | networks:
74 | nestjs-example-network:
75 | external: true
76 |
--------------------------------------------------------------------------------
/eslint.config.cjs:
--------------------------------------------------------------------------------
1 | const prettier_plugin = require('eslint-plugin-prettier/recommended');
2 | const path = require('node:path');
3 | const typescript_eslint = require('typescript-eslint');
4 |
5 | const default_ignores_pool = ['**/.git/**/*', '**/dist/**/*', '**/node_modules/**/*', '**/uploads/**/*'];
6 |
7 | const root_path = path.dirname(__filename);
8 | const tsconfig_path = path.resolve(root_path, 'tsconfig.json');
9 |
10 | /** @type {import("eslint").Linter.Config[]} */
11 | module.exports = [
12 | ...[typescript_eslint.configs.recommendedTypeChecked[1], typescript_eslint.configs.recommendedTypeChecked[2]].map((config) => ({
13 | ...config,
14 | languageOptions: {
15 | parser: typescript_eslint.parser,
16 | parserOptions: {
17 | sourceType: 'module',
18 | tsconfigRootDir: root_path,
19 | project: tsconfig_path,
20 | },
21 | },
22 | plugins: {
23 | '@typescript-eslint': typescript_eslint.plugin,
24 | },
25 | rules: {
26 | 'no-unused-vars': 'off',
27 | 'no-restricted-imports': [
28 | 'error',
29 | {
30 | patterns: [
31 | {
32 | group: ['src/*'],
33 | message: 'No import from src!',
34 | },
35 | ],
36 | },
37 | ],
38 | },
39 | files: ['**/*.{ts,mts}'],
40 | ignores: [...default_ignores_pool],
41 | })),
42 | {
43 | ...typescript_eslint.configs.stylisticTypeChecked[2],
44 | languageOptions: {
45 | parser: typescript_eslint.parser,
46 | parserOptions: {
47 | sourceType: 'module',
48 | tsconfigRootDir: root_path,
49 | project: tsconfig_path,
50 | projectService: true,
51 | },
52 | },
53 | plugins: {
54 | '@typescript-eslint': typescript_eslint.plugin,
55 | },
56 | files: ['**/*.{ts,mts}'],
57 | ignores: [...default_ignores_pool],
58 | },
59 | {
60 | ...prettier_plugin,
61 | name: 'prettier/recommended',
62 | ignores: [...default_ignores_pool],
63 | },
64 | ];
65 |
--------------------------------------------------------------------------------
/html/socket.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | Socket Test
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
20 |
21 |
22 |
23 |
24 |
25 |
60 |
61 |
62 |
--------------------------------------------------------------------------------
/html/upload-graphql.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | Upload Test
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
32 |
33 |
79 |
80 |
81 |
--------------------------------------------------------------------------------
/html/upload-rest.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | Upload Test
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
32 |
33 |
60 |
61 |
62 |
63 |
--------------------------------------------------------------------------------
/nginx.conf:
--------------------------------------------------------------------------------
1 | upstream backend {
2 | server localhost:8080;
3 | }
4 |
5 | upstream websocket {
6 | server localhost:8081;
7 | }
8 |
9 | server {
10 | listen 80;
11 |
12 | location /robots.txt {
13 | return 200 'User-agent: *\nDisallow: /';
14 | }
15 |
16 | location ~* ^/(uploads/|uploads)$ {
17 | return 403;
18 | }
19 |
20 | location ~* ^/uploads/(.*) {
21 | root /server;
22 |
23 | add_header 'Cache-Control' 'max-age=31536000, public';
24 | add_header X-Content-Type-Options nosniff;
25 | add_header Referrer-Policy 'strict-origin-when-cross-origin';
26 | add_header Content-Security-Policy "default-src 'self'; connect-src * 'self'; frame-src * 'self'; font-src * blob: data:; img-src * blob: data:; media-src * blob: data:; script-src * 'unsafe-inline' 'unsafe-eval'; worker-src * data: blob:; style-src * 'unsafe-inline'; base-uri 'self'; form-action 'self';";
27 | add_header 'Access-Control-Allow-Origin' '*';
28 | }
29 |
30 | location /socket.io {
31 | add_header X-Content-Type-Options nosniff;
32 | add_header Referrer-Policy 'strict-origin-when-cross-origin';
33 | add_header Content-Security-Policy "default-src 'self'; connect-src * 'self'; frame-src * 'self'; font-src * blob: data:; img-src * blob: data:; media-src * blob: data:; script-src * 'unsafe-inline' 'unsafe-eval'; worker-src * data: blob:; style-src * 'unsafe-inline'; base-uri 'self'; form-action 'self';";
34 | add_header Cache-Control 'no-store, no-cache, must-revalidate, proxy-revalidate, max-age=0';
35 |
36 | if_modified_since off;
37 | expires off;
38 | etag off;
39 | proxy_no_cache 1;
40 | proxy_cache_bypass 1;
41 | proxy_http_version 1.1;
42 |
43 | proxy_pass http://websocket;
44 |
45 | proxy_set_header Upgrade $http_upgrade;
46 | proxy_set_header Connection 'upgrade';
47 | proxy_set_header Host $host;
48 | proxy_cache_bypass $http_upgrade;
49 | proxy_set_header X-Forwarded-Proto $scheme;
50 | proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
51 |
52 | client_max_body_size 10g;
53 | }
54 |
55 | location / {
56 | add_header X-Content-Type-Options nosniff;
57 | add_header Referrer-Policy 'strict-origin-when-cross-origin';
58 | add_header Content-Security-Policy "default-src 'self'; connect-src * 'self'; frame-src * 'self'; font-src * blob: data:; img-src * blob: data:; media-src * blob: data:; script-src * 'unsafe-inline' 'unsafe-eval'; worker-src * data: blob:; style-src * 'unsafe-inline'; base-uri 'self'; form-action 'self';";
59 | add_header Cache-Control 'no-store, no-cache, must-revalidate, proxy-revalidate, max-age=0';
60 |
61 | if_modified_since off;
62 | expires off;
63 | etag off;
64 | proxy_no_cache 1;
65 | proxy_cache_bypass 1;
66 | proxy_http_version 1.1;
67 |
68 | proxy_pass http://backend;
69 |
70 | proxy_set_header Upgrade $http_upgrade;
71 | proxy_set_header Connection 'upgrade';
72 | proxy_set_header Host $host;
73 | proxy_set_header X-Forwarded-Proto $scheme;
74 | proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
75 |
76 | client_max_body_size 10g;
77 | }
78 | }
79 |
--------------------------------------------------------------------------------
/nodemon.json:
--------------------------------------------------------------------------------
1 | {
2 | "watch": [
3 | "src",
4 | "config"
5 | ],
6 | "env": {
7 | "NODE_ENV": "development"
8 | },
9 | "ext": "ts",
10 | "ignore": [
11 | "src/**/*.spec.ts"
12 | ],
13 | "exec": "ts-node --files -r tsconfig-paths/register src/main.ts --unhandled-rejections=warn"
14 | }
15 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "nestjs-example",
3 | "version": "0.0.2",
4 | "description": "",
5 | "author": "t.kosminov",
6 | "private": false,
7 | "license": "MIT",
8 | "scripts": {
9 | "build": "rimraf dist && tsc -p tsconfig.build.json",
10 | "lint": "eslint .",
11 | "lint:fix": "npm run lint -- --fix",
12 | "start:dev": "nodemon",
13 | "start:build": "node --enable-source-maps dist/main.js --unhandled-rejections=warn",
14 | "repl:dev": "NODE_ENV=development ts-node --files -r tsconfig-paths/register src/repl.ts --unhandled-rejections=warn",
15 | "repl:build": "node --enable-source-maps dist/repl.js --unhandled-rejections=warn",
16 | "typeorm:cli:ts": "ts-node -r tsconfig-paths/register ./node_modules/typeorm/cli",
17 | "typeorm:create": "NODE_ENV=development npm run typeorm:cli:ts -- migration:create ./src/typeorm/migrations/created",
18 | "typeorm:generate": "NODE_ENV=development npm run typeorm:cli:ts -- -d ./src/typeorm/typeorm.cli.ts migration:generate ./src/typeorm/migrations/generated",
19 | "typeorm:run:dev": "NODE_ENV=development npm run typeorm:cli:ts -- -d ./src/typeorm/typeorm.cli.ts migration:run",
20 | "typeorm:cli:js": "node -r tsconfig-paths/register ./node_modules/typeorm/cli",
21 | "typeorm:run:build": "npm run typeorm:cli:js -- -d ./dist/typeorm/typeorm.cli.js migration:run",
22 | "madge:full": "madge --image graph.png --extensions ts src",
23 | "madge:circular": "madge --image graph.png --extensions ts --circular src",
24 | "prepare": "husky"
25 | },
26 | "dependencies": {
27 | "@envelop/core": "^5.0.3",
28 | "@envelop/response-cache": "^6.3.0",
29 | "@golevelup/nestjs-rabbitmq": "^5.7.0",
30 | "@graphql-tools/delegate": "^10.2.9",
31 | "@graphql-tools/stitch": "^9.4.14",
32 | "@graphql-tools/stitching-directives": "^3.1.24",
33 | "@graphql-tools/utils": "^10.7.2",
34 | "@graphql-yoga/nestjs": "^3.10.10",
35 | "@nestjs/common": "^11.0.6",
36 | "@nestjs/config": "^4.0.0",
37 | "@nestjs/core": "^11.0.6",
38 | "@nestjs/graphql": "^13.0.2",
39 | "@nestjs/microservices": "^11.0.6",
40 | "@nestjs/platform-express": "^11.0.6",
41 | "@nestjs/platform-socket.io": "^11.0.6",
42 | "@nestjs/swagger": "^11.0.3",
43 | "@nestjs/terminus": "^11.0.0",
44 | "@nestjs/typeorm": "^11.0.0",
45 | "@nestjs/websockets": "^11.0.6",
46 | "@socket.io/redis-adapter": "^8.3.0",
47 | "@whatwg-node/fetch": "^0.10.3",
48 | "bcryptjs": "^2.4.3",
49 | "body-parser": "^1.20.3",
50 | "class-transformer": "^0.5.1",
51 | "class-validator": "^0.14.1",
52 | "cookie-parser": "^1.4.7",
53 | "graphql": "^16.10.0",
54 | "graphql-redis-subscriptions": "^2.7.0",
55 | "graphql-scalars": "^1.24.0",
56 | "graphql-upload-ts": "^2.1.2",
57 | "graphql-ws": "^6.0.1",
58 | "hasha": "5.2.2",
59 | "helmet": "^8.0.0",
60 | "ioredis": "^5.4.2",
61 | "jsonwebtoken": "^9.0.2",
62 | "nestjs-graphql-easy": "^3.1.4",
63 | "node-abort-controller": "^3.1.1",
64 | "pg": "^8.13.1",
65 | "reflect-metadata": "^0.2.2",
66 | "rxjs": "^7.8.1",
67 | "socket.io": "^4.8.1",
68 | "transliteration": "^2.3.5",
69 | "typeorm": "^0.3.20",
70 | "typeorm-extension": "^3.6.3",
71 | "uuid": "^11.0.5"
72 | },
73 | "devDependencies": {
74 | "@trivago/prettier-plugin-sort-imports": "^5.2.1",
75 | "@types/bcryptjs": "^2.4.6",
76 | "@types/body-parser": "^1.19.5",
77 | "@types/cookie-parser": "^1.4.7",
78 | "@types/express": "^5.0.0",
79 | "@types/jsonwebtoken": "^9.0.7",
80 | "@types/node": "^22.10.7",
81 | "@types/nodemon": "^1.19.6",
82 | "@types/pg": "^8.11.10",
83 | "@types/uuid": "^10.0.0",
84 | "eslint": "^9.18.0",
85 | "eslint-config-prettier": "^10.0.1",
86 | "eslint-plugin-prettier": "^5.2.3",
87 | "eslint-plugin-promise": "^7.2.1",
88 | "husky": "^9.1.7",
89 | "jest": "^29.7.0",
90 | "jest-mock-extended": "^4.0.0-beta1",
91 | "madge": "^8.0.0",
92 | "nodemon": "^3.1.9",
93 | "prettier": "^3.4.2",
94 | "rimraf": "^6.0.1",
95 | "ts-jest": "^29.2.5",
96 | "ts-loader": "^9.5.2",
97 | "ts-node": "^10.9.2",
98 | "tsconfig-paths": "^4.2.0",
99 | "typescript": "^5.7.3",
100 | "typescript-eslint": "^8.20.0"
101 | }
102 | }
103 |
--------------------------------------------------------------------------------
/prettier.config.cjs:
--------------------------------------------------------------------------------
1 | /** @type {import("prettier").Config} */
2 | module.exports = {
3 | printWidth: 140,
4 | semi: true,
5 | tabWidth: 2,
6 | singleQuote: true,
7 | trailingComma: 'es5',
8 | endOfLine: 'auto',
9 |
10 | plugins: ['@trivago/prettier-plugin-sort-imports'],
11 |
12 | importOrder: ['', '^[./]'],
13 | importOrderSeparation: true,
14 | importOrderSortSpecifiers: true,
15 | importOrderGroupNamespaceSpecifiers: true,
16 | importOrderCaseInsensitive: true,
17 | importOrderParserPlugins: ['decorators-legacy', 'typescript'],
18 | };
19 |
--------------------------------------------------------------------------------
/src/app.env.ts:
--------------------------------------------------------------------------------
1 | export const ENV_FILE_PATHS = [`./config/.env.${process.env.NODE_ENV}.local`, `./config/.env.${process.env.NODE_ENV}`, './config/.env'];
2 | export const EXPAND_VARIABLES = true;
3 |
--------------------------------------------------------------------------------
/src/app.module.ts:
--------------------------------------------------------------------------------
1 | import { MiddlewareConsumer, Module, RequestMethod } from '@nestjs/common';
2 | import { ConfigModule } from '@nestjs/config';
3 |
4 | import { ENV_FILE_PATHS, EXPAND_VARIABLES } from './app.env';
5 | import { GraphQLModule } from './graphql/graphql.module';
6 | import { HealthzModule } from './healthz/healthz.module';
7 | import { JwtModule } from './jwt/jwt.module';
8 | import { LoggerMiddleware } from './logger/logger.middleware';
9 | import { LoggerModule } from './logger/logger.module';
10 | import { ModelsModule } from './models/models.module';
11 | import { OAuthMiddleware } from './oauth/oauth.middleware';
12 | import { OAuthModule } from './oauth/oauth.module';
13 | import { RabbitMQModule } from './rabbitmq/rabbitmq.module';
14 | import { RedisModule } from './redis/redis.module';
15 | import { TypeOrmModule } from './typeorm/typeorm.module';
16 | import { UploadModule } from './upload/upload.module';
17 | import { WsModule } from './ws/ws.module';
18 |
19 | @Module({
20 | imports: [
21 | ConfigModule.forRoot({
22 | isGlobal: true,
23 | expandVariables: EXPAND_VARIABLES,
24 | envFilePath: ENV_FILE_PATHS,
25 | }),
26 | LoggerModule,
27 | HealthzModule,
28 | TypeOrmModule.forRoot(),
29 | RedisModule,
30 | RabbitMQModule.forRoot(),
31 | JwtModule,
32 | GraphQLModule,
33 | OAuthModule,
34 | ModelsModule,
35 | UploadModule,
36 | WsModule,
37 | ],
38 | })
39 | export class AppModule {
40 | public configure(consumer: MiddlewareConsumer): void | MiddlewareConsumer {
41 | consumer.apply(LoggerMiddleware).forRoutes('*');
42 |
43 | consumer.apply(OAuthMiddleware).forRoutes(
44 | {
45 | path: 'graphql',
46 | method: RequestMethod.POST,
47 | },
48 | {
49 | path: 'upload',
50 | method: RequestMethod.ALL,
51 | }
52 | );
53 | }
54 | }
55 |
--------------------------------------------------------------------------------
/src/cors/cors.middleware.ts:
--------------------------------------------------------------------------------
1 | import { CorsOptions, CorsOptionsDelegate } from '@nestjs/common/interfaces/external/cors-options.interface';
2 | import { Request } from 'express';
3 |
4 | import { getMethod, getOrigin, getPath } from '../utils/request';
5 |
6 | export interface ICorsOptions {
7 | allowed_origins: string[];
8 | allowed_methods: string[];
9 | allowed_paths: string[];
10 | credentials: boolean;
11 | }
12 |
13 | export const CorsMiddleware =
14 | (options: ICorsOptions): CorsOptionsDelegate =>
15 | (req, callback) => {
16 | const cors_options: CorsOptions = {
17 | methods: options.allowed_methods,
18 | credentials: options.credentials,
19 | origin: false,
20 | };
21 |
22 | let error: Error | null = new Error('CORS_NOT_ALLOWED');
23 |
24 | const origin = getOrigin(req);
25 | const method = getMethod(req);
26 | const path = getPath(req);
27 |
28 | if (!origin || !options.allowed_origins.length || options.allowed_origins.includes(origin)) {
29 | cors_options.origin = true;
30 | error = null;
31 | } else if (options.allowed_methods.length && options.allowed_methods.includes(method)) {
32 | cors_options.origin = true;
33 | error = null;
34 | } else if (options.allowed_paths.length && options.allowed_paths.includes(path)) {
35 | cors_options.origin = true;
36 | error = null;
37 | }
38 |
39 | callback(error, cors_options);
40 | };
41 |
--------------------------------------------------------------------------------
/src/graphql/graphql.module.ts:
--------------------------------------------------------------------------------
1 | import { YogaDriver, YogaDriverConfig } from '@graphql-yoga/nestjs';
2 | import { Global, Module } from '@nestjs/common';
3 | import { GraphQLModule as NestJSGraphQLModule } from '@nestjs/graphql';
4 | import { RedisPubSub } from 'graphql-redis-subscriptions';
5 |
6 | import { JwtModule } from '../jwt/jwt.module';
7 | import { RedisModule } from '../redis/redis.module';
8 | import { RedisService } from '../redis/redis.service';
9 | import { GraphQLOptions } from './graphql.options';
10 | import { GraphQLReloaderModule } from './reloader/reloader.module';
11 | import { GraphQLStitchingModule } from './stitching/stitching.module';
12 |
13 | export const GRAPHQL_SUBSCRIPTION = 'GRAPHQL_SUBSCRIPTION';
14 |
15 | @Global()
16 | @Module({
17 | imports: [
18 | RedisModule,
19 | NestJSGraphQLModule.forRootAsync({
20 | imports: [JwtModule, GraphQLReloaderModule, GraphQLStitchingModule],
21 | useClass: GraphQLOptions,
22 | driver: YogaDriver,
23 | }),
24 | ],
25 | providers: [
26 | {
27 | provide: GRAPHQL_SUBSCRIPTION,
28 | useFactory: (redis: RedisService) =>
29 | new RedisPubSub({
30 | publisher: redis.pub_client,
31 | subscriber: redis.sub_client,
32 | }),
33 | inject: [RedisService],
34 | },
35 | ],
36 | exports: [GRAPHQL_SUBSCRIPTION],
37 | })
38 | export class GraphQLModule {}
39 |
--------------------------------------------------------------------------------
/src/graphql/graphql.options.ts:
--------------------------------------------------------------------------------
1 | import { type Plugin, type SetSchemaFn, useLogger } from '@envelop/core';
2 | import { useResponseCache } from '@envelop/response-cache';
3 | import { YogaDriver, YogaDriverConfig } from '@graphql-yoga/nestjs';
4 | import { Injectable } from '@nestjs/common';
5 | import { ConfigService } from '@nestjs/config';
6 | import { GqlOptionsFactory } from '@nestjs/graphql';
7 | import { Request } from 'express';
8 | import { DocumentNode, GraphQLArgs, GraphQLSchema } from 'graphql';
9 | import { ConnectionInitMessage, Context } from 'graphql-ws';
10 | import { IncomingMessage } from 'http';
11 | import { setDataSource } from 'nestjs-graphql-easy';
12 | import { DataSource } from 'typeorm';
13 |
14 | import { JwtService } from '../jwt/jwt.service';
15 | import { LoggerService } from '../logger/logger.service';
16 | import { LoggerStore } from '../logger/logger.store';
17 | import { IAccessToken } from '../oauth/oauth.service';
18 | import { IJwtPayload } from '../oauth/user/user.entity';
19 | import { getCookie } from '../utils/request';
20 | import { GraphQLStitchingService } from './stitching/stitching.service';
21 |
22 | const setSchemaUpdater: (setFn: (schemaUpdater: SetSchemaFn) => GraphQLSchema) => Plugin = (fn) => ({
23 | onPluginInit({ setSchema: set_schema }) {
24 | fn(set_schema);
25 | },
26 | });
27 |
28 | @Injectable()
29 | export class GraphQLOptions implements GqlOptionsFactory {
30 | public schemaUpdater: SetSchemaFn | null = null;
31 |
32 | constructor(
33 | private readonly config: ConfigService,
34 | private readonly data_source: DataSource,
35 | private readonly logger: LoggerService,
36 | private readonly jwt: JwtService,
37 | private readonly stitching_service: GraphQLStitchingService
38 | ) {
39 | setDataSource(this.data_source);
40 |
41 | this.stitching_service.schema$.subscribe((schema: GraphQLSchema) => {
42 | if (this.schemaUpdater != null) {
43 | this.schemaUpdater(schema);
44 | }
45 | });
46 | }
47 |
48 | public setSchemaUpdater(updater: SetSchemaFn) {
49 | this.schemaUpdater = updater;
50 | }
51 |
52 | public createGqlOptions(): Promise | YogaDriverConfig {
53 | return {
54 | autoSchemaFile: true,
55 | driver: YogaDriver,
56 | subscriptions: {
57 | 'graphql-ws': {
58 | onConnect: (
59 | ctx: Context<
60 | ConnectionInitMessage['payload'],
61 | { request: IncomingMessage & { logger_store: LoggerStore; current_user: IJwtPayload } }
62 | >
63 | ) => {
64 | const access_token =
65 | (ctx.connectionParams as { authorization?: string } | undefined)?.authorization ??
66 | getCookie(ctx.extra.request.headers.cookie ?? '', 'access_token');
67 |
68 | if (access_token?.length) {
69 | try {
70 | const { current_user, token_type } = this.jwt.verify(access_token);
71 |
72 | if (token_type !== 'access') {
73 | return false;
74 | }
75 |
76 | ctx.extra.request.current_user = current_user;
77 | ctx.extra.request.logger_store = new LoggerStore(this.logger);
78 |
79 | ctx.extra.request.logger_store.info('GraphQLOptions: onConnect', { user_id: ctx.extra.request.current_user.id });
80 |
81 | return true;
82 | } catch (e) {
83 | return false;
84 | }
85 | }
86 |
87 | return false;
88 | },
89 | onDisconnect: async (
90 | ctx: Context<
91 | ConnectionInitMessage['payload'],
92 | { request: IncomingMessage & { logger_store: LoggerStore; current_user: IJwtPayload } }
93 | >
94 | ) => {
95 | ctx.extra.request.logger_store.info('GraphQLOptions: onDisconnect', { user_id: ctx.extra.request.current_user.id });
96 | },
97 | },
98 | 'subscriptions-transport-ws': false,
99 | },
100 | context: ({ req }: { req: Request & { logger_store: LoggerStore; current_user: IJwtPayload } }) => ({
101 | req,
102 | logger_store: req.logger_store,
103 | current_user: req.current_user,
104 | }),
105 | transformSchema: async (schema: GraphQLSchema) => {
106 | this.stitching_service.setCurrentSchema(schema);
107 |
108 | await this.stitching_service.reloadSchemas();
109 |
110 | if (this.stitching_service.schema$.value) {
111 | return this.stitching_service.schema$.value;
112 | }
113 |
114 | return schema;
115 | },
116 | plugins: [
117 | setSchemaUpdater(this.setSchemaUpdater.bind(this)),
118 | useResponseCache({
119 | session: ({ current_user }: { current_user: IJwtPayload }) => String(current_user.id),
120 | ttl: parseInt(this.config.getOrThrow('GRAPHQL_CACHE_TTL'), 10),
121 | invalidateViaMutation: true,
122 | }),
123 | useLogger({
124 | logFn: (
125 | event_name: string,
126 | {
127 | args,
128 | }: {
129 | args: GraphQLArgs & {
130 | document: DocumentNode;
131 | contextValue: {
132 | req: Request;
133 | logger_store: LoggerStore;
134 | params: {
135 | query: string;
136 | };
137 | };
138 | };
139 | result?: unknown;
140 | }
141 | ) => {
142 | const ctx = args.contextValue;
143 | const logger_store: LoggerStore = ctx.logger_store;
144 |
145 | let operation = '';
146 | const selections: string[] = [];
147 |
148 | args.document.definitions.forEach((definition) => {
149 | if (definition.kind === 'OperationDefinition') {
150 | operation = definition.operation;
151 |
152 | definition.selectionSet.selections.forEach((selection) => {
153 | if (selection.kind === 'Field') {
154 | selections.push(selection.name.value);
155 | }
156 | });
157 | }
158 | });
159 |
160 | logger_store.info(`GraphQL ${event_name}`, { event: event_name, operation, selections });
161 | },
162 | }),
163 | ],
164 | };
165 | }
166 | }
167 |
--------------------------------------------------------------------------------
/src/graphql/reloader/reloader.controller.ts:
--------------------------------------------------------------------------------
1 | import { Controller, Get } from '@nestjs/common';
2 |
3 | import { GraphQLReloaderService } from './reloader.service';
4 |
5 | @Controller('schema')
6 | export class GraphQLReloaderController {
7 | constructor(private readonly reloader_service: GraphQLReloaderService) {}
8 |
9 | @Get('reload')
10 | public async reload() {
11 | return this.reloader_service.reloadGraphQLSchema(true);
12 | }
13 | }
14 |
--------------------------------------------------------------------------------
/src/graphql/reloader/reloader.module.ts:
--------------------------------------------------------------------------------
1 | import { Module } from '@nestjs/common';
2 |
3 | import { LoggerModule } from '../../logger/logger.module';
4 | import { RedisModule } from '../../redis/redis.module';
5 | import { GraphQLStitchingModule } from '../stitching/stitching.module';
6 | import { GraphQLReloaderController } from './reloader.controller';
7 | import { GraphQLReloaderService } from './reloader.service';
8 |
9 | @Module({
10 | imports: [RedisModule, GraphQLStitchingModule, LoggerModule],
11 | providers: [GraphQLReloaderService],
12 | controllers: [GraphQLReloaderController],
13 | })
14 | export class GraphQLReloaderModule {}
15 |
--------------------------------------------------------------------------------
/src/graphql/reloader/reloader.service.ts:
--------------------------------------------------------------------------------
1 | import { INestApplication, Injectable } from '@nestjs/common';
2 | import { GraphQLModule } from '@nestjs/graphql';
3 | import { v4 } from 'uuid';
4 |
5 | import { LoggerService } from '../../logger/logger.service';
6 | import { RedisService } from '../../redis/redis.service';
7 | import { GraphQLStitchingService } from '../stitching/stitching.service';
8 |
9 | const CHECK_SERVER_READY_INTERVAL = 2000;
10 |
11 | @Injectable()
12 | export class GraphQLReloaderService {
13 | private app: INestApplication | null = null;
14 |
15 | private ready = false;
16 | private readonly redis_key: string = 'reload_graphql_schema';
17 | private readonly service_id: string = v4();
18 |
19 | private check_yoga_interval: NodeJS.Timeout | null = null;
20 |
21 | constructor(
22 | private readonly redis_service: RedisService,
23 | private readonly stitching_service: GraphQLStitchingService,
24 | private readonly logger: LoggerService
25 | ) {
26 | this.check_yoga_interval = setInterval(() => {
27 | this.checkYogaServer();
28 | }, CHECK_SERVER_READY_INTERVAL);
29 |
30 | this.redis_service.fromEvent(this.redis_key).subscribe(async (data: { service_id: string }) => {
31 | this.logger.info('GraphQLReloaderService: fromEvent', {
32 | current_service_id: this.service_id,
33 | from_service_id: data.service_id,
34 | });
35 |
36 | if (this.service_id !== data.service_id) {
37 | await this.reloadGraphQLSchema();
38 | }
39 | });
40 | }
41 |
42 | public applyApp(app: INestApplication) {
43 | this.app = app;
44 | }
45 |
46 | private async checkYogaServer() {
47 | this.logger.info('GraphQLReloaderService: checkYogaServer', { ready: this.ready, service_id: this.service_id });
48 |
49 | if (this.app) {
50 | if (this.app.get(GraphQLModule).graphQlAdapter) {
51 | if (this.check_yoga_interval) {
52 | clearInterval(this.check_yoga_interval);
53 | this.check_yoga_interval = null;
54 | }
55 |
56 | this.ready = true;
57 |
58 | this.logger.info('GraphQLReloaderService: checkYogaServer', { ready: this.ready, service_id: this.service_id });
59 | }
60 | }
61 | }
62 |
63 | public async reloadGraphQLSchema(send_alert = false) {
64 | this.logger.info('GraphQLReloaderService: reloadGraphQLSchema', { ready: this.ready, service_id: this.service_id });
65 |
66 | if (this.ready) {
67 | await this.stitching_service.reloadSchemas();
68 |
69 | if (send_alert) {
70 | await this.redis_service.publish(this.redis_key, { service_id: this.service_id });
71 | }
72 | }
73 | }
74 | }
75 |
--------------------------------------------------------------------------------
/src/graphql/stitching/stitching.module.ts:
--------------------------------------------------------------------------------
1 | import { Module } from '@nestjs/common';
2 |
3 | import { LoggerModule } from '../../logger/logger.module';
4 | import { GraphQLStitchingService } from './stitching.service';
5 |
6 | @Module({
7 | imports: [LoggerModule],
8 | providers: [GraphQLStitchingService],
9 | exports: [GraphQLStitchingService],
10 | })
11 | export class GraphQLStitchingModule {}
12 |
--------------------------------------------------------------------------------
/src/graphql/stitching/stitching.service.ts:
--------------------------------------------------------------------------------
1 | import { SubschemaConfig } from '@graphql-tools/delegate';
2 | import { stitchSchemas } from '@graphql-tools/stitch';
3 | import { stitchingDirectives } from '@graphql-tools/stitching-directives';
4 | import { AsyncExecutor } from '@graphql-tools/utils';
5 | import { schemaFromExecutor } from '@graphql-tools/wrap';
6 | import { Injectable } from '@nestjs/common';
7 | import { ConfigService } from '@nestjs/config';
8 | import { fetch } from '@whatwg-node/fetch';
9 | import { Request } from 'express';
10 | import { GraphQLSchema, print } from 'graphql';
11 | import { AbortController } from 'node-abort-controller';
12 | import { BehaviorSubject } from 'rxjs';
13 |
14 | import { LoggerService } from '../../logger/logger.service';
15 | import { LoggerStore } from '../../logger/logger.store';
16 | import { IJwtPayload } from '../../oauth/user/user.entity';
17 | import { getForwardedIp, getIp } from '../../utils/request';
18 |
19 | const ABORT_TIME_OUT: number = process.env.ABORT_TIME_OUT ? +process.env.ABORT_TIME_OUT : 20000;
20 | const { stitchingDirectivesTransformer } = stitchingDirectives();
21 |
22 | @Injectable()
23 | export class GraphQLStitchingService {
24 | private current_schema: GraphQLSchema | null = null;
25 | private sub_schemas: (SubschemaConfig | GraphQLSchema)[] = [];
26 |
27 | public readonly schema$ = new BehaviorSubject(null);
28 | private readonly api_urls: string[] = [];
29 |
30 | constructor(
31 | private readonly config: ConfigService,
32 | private readonly logger: LoggerService
33 | ) {
34 | this.api_urls.push(...JSON.parse(this.config.getOrThrow('GRAPHQL_API_URLS')));
35 | }
36 |
37 | public async reloadSchemas() {
38 | this.logger.info(`GraphQLStitchingService: reloadSchemas`, { third_party_schemes: this.api_urls.length });
39 |
40 | this.sub_schemas = this.current_schema ? [this.current_schema] : [];
41 |
42 | if (this.api_urls.length) {
43 | await Promise.all(this.api_urls.map((api_uri) => this.loadSchema(api_uri)));
44 | }
45 |
46 | this.schema$.next(
47 | stitchSchemas({
48 | subschemaConfigTransforms: [stitchingDirectivesTransformer],
49 | subschemas: this.sub_schemas,
50 | })
51 | );
52 | }
53 |
54 | public setCurrentSchema(schema: GraphQLSchema) {
55 | this.current_schema = schema;
56 | }
57 |
58 | private createExecutor(uri: string, abort: boolean): AsyncExecutor {
59 | return async ({ document, variables, operationName, extensions, context }) => {
60 | const query = print(document);
61 |
62 | const abort_controller: AbortController = new AbortController();
63 | let time_out: NodeJS.Timeout | null = null;
64 |
65 | if (abort) {
66 | time_out = setTimeout(() => {
67 | abort_controller.abort();
68 | }, ABORT_TIME_OUT);
69 | }
70 |
71 | let current_user = '{}';
72 | let ip = '-';
73 | let forwarded_ip = '-';
74 | let request_id = '';
75 |
76 | const req: (Request & { logger_store: LoggerStore; current_user: IJwtPayload }) | undefined = context?.req;
77 |
78 | if (req) {
79 | ip = getIp(req);
80 | forwarded_ip = getForwardedIp(req);
81 | current_user = JSON.stringify(req.current_user || {});
82 | request_id = req.logger_store.request_id;
83 | }
84 |
85 | const fetch_result = await fetch(uri, {
86 | method: 'POST',
87 | headers: {
88 | 'Content-Type': 'application/json; charset=utf-8',
89 | Accept: 'application/json',
90 | current_user,
91 | ip,
92 | 'X-Forwarded-For': forwarded_ip,
93 | request_id,
94 | },
95 | body: JSON.stringify({ query, variables, operationName, extensions }),
96 | signal: abort_controller.signal as AbortSignal,
97 | keepalive: true,
98 | });
99 |
100 | if (time_out) {
101 | clearTimeout(time_out);
102 | time_out = null;
103 | }
104 |
105 | return fetch_result.json();
106 | };
107 | }
108 |
109 | private async loadSchema(uri: string) {
110 | try {
111 | const sub_schema: SubschemaConfig = {
112 | schema: await schemaFromExecutor(this.createExecutor(uri, true)),
113 | executor: this.createExecutor(uri, false),
114 | batch: true,
115 | };
116 |
117 | this.sub_schemas.push(sub_schema);
118 |
119 | this.logger.info(`GraphQLStitchingService: loadSchema`, { query: 'introspectSchema', uri });
120 | } catch (error) {
121 | this.logger.error(error, { query: 'introspectSchema', uri });
122 | }
123 | }
124 | }
125 |
--------------------------------------------------------------------------------
/src/healthz/healthz.controller.ts:
--------------------------------------------------------------------------------
1 | import { Controller, Get } from '@nestjs/common';
2 | import { ConfigService } from '@nestjs/config';
3 | import { Transport } from '@nestjs/microservices';
4 | import {
5 | HealthCheck,
6 | HealthCheckService,
7 | HealthIndicatorFunction,
8 | MemoryHealthIndicator,
9 | MicroserviceHealthIndicator,
10 | TypeOrmHealthIndicator,
11 | } from '@nestjs/terminus';
12 |
13 | const TOTAL_MEMORY_HEAP = 1000 * 1024 * 1024; // 1G
14 | const TOTAL_MEMORY_RSS = 1000 * 1024 * 1024; // 1G
15 |
16 | @Controller('healthz')
17 | export class HealthzController {
18 | constructor(
19 | private readonly config: ConfigService,
20 | private readonly healthz: HealthCheckService,
21 | private readonly db_healthz: TypeOrmHealthIndicator,
22 | private readonly memory_healthz: MemoryHealthIndicator,
23 | private readonly micro_healthz: MicroserviceHealthIndicator
24 | ) {}
25 |
26 | @Get()
27 | public async liveness() {
28 | return { message: 'ok' };
29 | }
30 |
31 | @Get('readiness')
32 | @HealthCheck()
33 | public async readiness() {
34 | const service_ping_checks: HealthIndicatorFunction[] = [
35 | () => this.db_healthz.pingCheck('PostgreSQL'),
36 | () =>
37 | this.micro_healthz.pingCheck('Redis', {
38 | transport: Transport.REDIS,
39 | options: {
40 | host: this.config.getOrThrow('REDIS_HOST'),
41 | port: parseInt(this.config.getOrThrow('REDIS_PORT'), 10),
42 | password: this.config.get('REDIS_PASSWORD'),
43 | keyPrefix: this.config.get('REDIS_KEY'),
44 | },
45 | }),
46 | () =>
47 | this.micro_healthz.pingCheck('RabbitMQ', {
48 | transport: Transport.RMQ,
49 | options: {
50 | urls: [
51 | `amqp://${this.config.getOrThrow('RABBITMQ_USERNAME')}:${this.config.getOrThrow(
52 | 'RABBITMQ_PASSWORD'
53 | )}@${this.config.getOrThrow('RABBITMQ_HOST')}:${parseInt(
54 | this.config.getOrThrow('RABBITMQ_PORT'),
55 | 10
56 | )}/${this.config.getOrThrow('RABBITMQ_VHOST')}`,
57 | ],
58 | },
59 | }),
60 | () => this.memory_healthz.checkHeap('MEMORY_HEAP', TOTAL_MEMORY_HEAP),
61 | () => this.memory_healthz.checkRSS('MEMORY_RSS', TOTAL_MEMORY_RSS),
62 | ];
63 |
64 | return this.healthz.check(service_ping_checks);
65 | }
66 | }
67 |
--------------------------------------------------------------------------------
/src/healthz/healthz.module.ts:
--------------------------------------------------------------------------------
1 | import { Module } from '@nestjs/common';
2 | import { TerminusModule } from '@nestjs/terminus';
3 |
4 | import { HealthzController } from './healthz.controller';
5 |
6 | @Module({
7 | imports: [TerminusModule],
8 | controllers: [HealthzController],
9 | })
10 | export class HealthzModule {}
11 |
--------------------------------------------------------------------------------
/src/jwt/jwt.module.ts:
--------------------------------------------------------------------------------
1 | import { Module } from '@nestjs/common';
2 |
3 | import { JwtService } from './jwt.service';
4 |
5 | @Module({
6 | providers: [JwtService],
7 | exports: [JwtService],
8 | })
9 | export class JwtModule {}
10 |
--------------------------------------------------------------------------------
/src/jwt/jwt.service.ts:
--------------------------------------------------------------------------------
1 | import { Injectable } from '@nestjs/common';
2 | import { ConfigService } from '@nestjs/config';
3 | import { Algorithm, sign, verify } from 'jsonwebtoken';
4 | import { StringValue } from 'ms';
5 | import { v4 } from 'uuid';
6 |
7 | import { access_token_expired_signature, refresh_token_expired_signature } from '../utils/errors';
8 |
9 | @Injectable()
10 | export class JwtService {
11 | constructor(private readonly config: ConfigService) {}
12 |
13 | public generateAccess(payload: T) {
14 | return sign({ ...payload, token_type: 'access' }, this.config.getOrThrow('JWT_SECRET_KEY'), {
15 | jwtid: v4(),
16 | expiresIn: `${this.config.getOrThrow('JWT_ACCESS_TOKEN_LIFETIME_IN_MINUTES')} Minutes` as StringValue,
17 | algorithm: this.config.getOrThrow('JWT_ALGORITHM'),
18 | });
19 | }
20 |
21 | public generateRefresh(payload: T, jwtid: string) {
22 | return sign({ ...payload, token_type: 'refresh' }, this.config.getOrThrow('JWT_SECRET_KEY'), {
23 | jwtid,
24 | expiresIn: `${this.config.getOrThrow('JWT_REFRESH_TOKEN_LIFETIME_IN_MINUTES')} Minutes` as StringValue,
25 | algorithm: this.config.getOrThrow('JWT_ALGORITHM'),
26 | });
27 | }
28 |
29 | public verify(jwt_token: string, is_access_token = true) {
30 | try {
31 | return verify(jwt_token, this.config.getOrThrow('JWT_SECRET_KEY')) as T;
32 | } catch (error) {
33 | if (is_access_token) {
34 | throw access_token_expired_signature();
35 | } else {
36 | throw refresh_token_expired_signature();
37 | }
38 | }
39 | }
40 | }
41 |
--------------------------------------------------------------------------------
/src/logger/logger.decorator.ts:
--------------------------------------------------------------------------------
1 | import { createParamDecorator, ExecutionContext } from '@nestjs/common';
2 |
3 | export const RestLogger = createParamDecorator((_data: unknown, ctx: ExecutionContext) => {
4 | const request = ctx.switchToHttp().getRequest();
5 |
6 | return request.logger_store;
7 | });
8 |
--------------------------------------------------------------------------------
/src/logger/logger.filter.ts:
--------------------------------------------------------------------------------
1 | const FILTERED = 'FILTERED';
2 |
3 | const EMAIL_REGEX = /\b[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}\b/gi;
4 | const JWT_REGEX = /^([a-zA-Z0-9_=]+)\.([a-zA-Z0-9_=]+)\.([a-zA-Z0-9_\-\+\/=]*)/gi;
5 | const KEY_VALUE_REGEX = /("([^"]+)"|'([^']+)'|`([^`]+)`|[\w]+)\s*[:=-]\s*("([^"]+)"|'([^']+)'|`([^`]+)`|[\w]+)/gi;
6 |
7 | function filterValue(keywords: string[], value: unknown, field?: string) {
8 | if (value) {
9 | if (field && keywords.includes(field)) {
10 | return FILTERED;
11 | }
12 |
13 | if (typeof value === 'string') {
14 | let new_value: string = value.replace(EMAIL_REGEX, FILTERED);
15 | new_value = new_value.replace(JWT_REGEX, FILTERED);
16 | new_value = new_value.replace(KEY_VALUE_REGEX, (match, key) => {
17 | const keyword = key.replace(/['"`\s]/gi, '');
18 |
19 | if (keywords.includes(keyword)) {
20 | return `${keyword}: ${FILTERED}`;
21 | }
22 |
23 | return match;
24 | });
25 |
26 | return new_value;
27 | }
28 | }
29 |
30 | return value;
31 | }
32 |
33 | function filterUnknown(keywords: string[], value: unknown, field?: string) {
34 | if (field && keywords.includes(field)) {
35 | return FILTERED;
36 | }
37 |
38 | if (value) {
39 | if (Array.isArray(value)) {
40 | return filterArray(keywords, value);
41 | } else if (typeof value === 'object' && !(value instanceof Date)) {
42 | return filterObject(keywords, value);
43 | } else {
44 | return filterValue(keywords, value, field);
45 | }
46 | }
47 |
48 | return value;
49 | }
50 |
51 | function filterObject(keywords: string[], obj: object) {
52 | const filtered_object: Record = {};
53 |
54 | Object.keys(obj).forEach((key) => {
55 | filtered_object[key] = filterUnknown(keywords, obj[key], key);
56 | });
57 |
58 | return filtered_object;
59 | }
60 |
61 | function filterArray(keywords: string[], arr: unknown[]) {
62 | return Array.from(arr).map((value) => filterUnknown(keywords, value));
63 | }
64 |
65 | export function filterContext(keywords: string[], context: Record) {
66 | return filterObject(keywords, context);
67 | }
68 |
69 | export function filterMessage(keywords: string[], message: unknown) {
70 | return filterUnknown(keywords, message);
71 | }
72 |
--------------------------------------------------------------------------------
/src/logger/logger.middleware.ts:
--------------------------------------------------------------------------------
1 | import { Injectable, NestMiddleware } from '@nestjs/common';
2 | import { ConfigService } from '@nestjs/config';
3 | import { NextFunction, Request, Response } from 'express';
4 |
5 | import { getAction, getForwardedIp, getIp, getMethod, getOrigin, getPath, getReferrer, getUserAgent } from '../utils/request';
6 | import { LoggerService } from './logger.service';
7 | import { LoggerStore } from './logger.store';
8 |
9 | @Injectable()
10 | export class LoggerMiddleware implements NestMiddleware {
11 | private readonly silence: string[] = [];
12 |
13 | constructor(
14 | private readonly config: ConfigService,
15 | private readonly logger: LoggerService
16 | ) {
17 | this.silence = JSON.parse(this.config.getOrThrow('LOGGER_SILENCE'));
18 | }
19 |
20 | public use(req: Request & { logger_store: LoggerStore }, res: Response, next: NextFunction) {
21 | const logger_store = new LoggerStore(this.logger);
22 | req.logger_store = logger_store;
23 |
24 | if (this.silence.includes(getAction(req))) {
25 | return next();
26 | }
27 |
28 | req.on('error', (error: Error) => {
29 | logger_store.error(error, { statusCode: req.statusCode });
30 | });
31 |
32 | res.on('error', (error: Error) => {
33 | logger_store.error(error, { statusCode: req.statusCode });
34 | });
35 |
36 | res.on('finish', () => {
37 | const message = {
38 | method: getMethod(req),
39 | path: getPath(req),
40 | referrer: getReferrer(req),
41 | origin: getOrigin(req),
42 | userAgent: getUserAgent(req),
43 | remoteAddress: getIp(req),
44 | forwardedAddress: getForwardedIp(req),
45 | statusCode: res.statusCode,
46 | statusMessage: res.statusMessage,
47 | };
48 |
49 | if (res.statusCode < 200) {
50 | logger_store.log(message, { statusCode: res.statusCode, statusMessage: res.statusMessage });
51 | } else if (res.statusCode < 300) {
52 | logger_store.info(message, { statusCode: res.statusCode, statusMessage: res.statusMessage });
53 | } else if (res.statusCode < 400) {
54 | logger_store.warn(message, { statusCode: res.statusCode, statusMessage: res.statusMessage });
55 | } else {
56 | logger_store.error(message, { statusCode: res.statusCode, statusMessage: res.statusMessage });
57 | }
58 | });
59 |
60 | return next();
61 | }
62 | }
63 |
--------------------------------------------------------------------------------
/src/logger/logger.module.ts:
--------------------------------------------------------------------------------
1 | import { Global, Module } from '@nestjs/common';
2 |
3 | import { LoggerService } from './logger.service';
4 |
5 | @Global()
6 | @Module({
7 | providers: [LoggerService],
8 | exports: [LoggerService],
9 | })
10 | export class LoggerModule {}
11 |
--------------------------------------------------------------------------------
/src/logger/logger.service.ts:
--------------------------------------------------------------------------------
1 | import { Injectable, Logger } from '@nestjs/common';
2 | import { ConfigService } from '@nestjs/config';
3 |
4 | import { filterContext, filterMessage } from './logger.filter';
5 |
6 | export enum ELogLevel {
7 | debug,
8 | info,
9 | warn,
10 | error,
11 | }
12 |
13 | export interface ILogBase {
14 | message: unknown;
15 | trace?: string;
16 | context: Record;
17 | }
18 |
19 | export interface ILog extends ILogBase {
20 | level: ELogLevel;
21 | }
22 |
23 | @Injectable()
24 | export class LoggerService {
25 | private readonly _current_level: ELogLevel;
26 | private readonly app_name: string;
27 | private readonly keywords: string[] = [];
28 |
29 | constructor(private readonly config: ConfigService) {
30 | this._current_level = ELogLevel[this.config.getOrThrow('LOGGER_LEVEL')];
31 | this.app_name = this.config.getOrThrow('APP_NAME');
32 | this.keywords = JSON.parse(this.config.getOrThrow('LOGGER_KEYWORDS'));
33 | }
34 |
35 | public log(message: unknown, context: Record = {}) {
36 | this.addLog({ level: ELogLevel.debug, message, context });
37 | }
38 |
39 | public info(message: unknown, context: Record = {}) {
40 | this.addLog({ level: ELogLevel.info, message, context });
41 | }
42 |
43 | public warn(message: unknown, context: Record = {}) {
44 | this.addLog({ level: ELogLevel.warn, message, context });
45 | }
46 |
47 | public error(err: Error, context?: Record): void;
48 | public error(message: unknown, context?: Record, trace?: string): void;
49 | public error(message_or_error: unknown | Error, context: Record = {}, trace?: string) {
50 | if (message_or_error instanceof Error) {
51 | this.addLog({
52 | level: ELogLevel.error,
53 | message: message_or_error.message,
54 | trace: message_or_error.stack,
55 | context,
56 | });
57 | } else {
58 | this.addLog({ level: ELogLevel.error, message: message_or_error, context, trace });
59 | }
60 | }
61 |
62 | public isValidLevel(level: ELogLevel): boolean {
63 | return level <= this._current_level;
64 | }
65 |
66 | public addLog(log: ILog) {
67 | if (this.isValidLevel(log.level)) {
68 | const msg = JSON.stringify(filterMessage(this.keywords, log.message));
69 | const ctx = filterContext(this.keywords, log.context);
70 |
71 | ctx.app_name = this.app_name;
72 |
73 | switch (log.level) {
74 | case ELogLevel.warn:
75 | Logger.warn(msg, JSON.stringify(ctx));
76 | break;
77 | case ELogLevel.error:
78 | Logger.error(msg, log.trace, JSON.stringify(ctx));
79 | break;
80 | default:
81 | Logger.log(msg, JSON.stringify(ctx));
82 | break;
83 | }
84 | }
85 | }
86 | }
87 |
--------------------------------------------------------------------------------
/src/logger/logger.store.ts:
--------------------------------------------------------------------------------
1 | import { v4 } from 'uuid';
2 |
3 | import { ELogLevel, ILog, ILogBase, LoggerService } from './logger.service';
4 |
5 | export class LoggerStore {
6 | public request_id: string = v4();
7 | private log_index = 0;
8 | private readonly starts_at: Date = new Date();
9 | private prev_log_time: number = Date.now();
10 |
11 | constructor(
12 | private readonly logger: LoggerService,
13 | private readonly base_context: Record = {}
14 | ) {}
15 |
16 | public log(message: unknown, context: Record = {}) {
17 | this.addLog(ELogLevel.debug, { message, context });
18 | }
19 |
20 | public info(message: unknown, context: Record = {}) {
21 | this.addLog(ELogLevel.info, { message, context });
22 | }
23 |
24 | public warn(message: unknown, context: Record = {}) {
25 | this.addLog(ELogLevel.warn, { message, context });
26 | }
27 |
28 | public error(err: Error, context?: Record): void;
29 | public error(message: unknown, context?: Record, trace?: string): void;
30 | public error(message_or_error: unknown | Error, context: Record = {}, trace?: string) {
31 | if (message_or_error instanceof Error) {
32 | this.addLog(ELogLevel.error, { message: message_or_error.message, context, trace: message_or_error.stack });
33 | } else {
34 | this.addLog(ELogLevel.error, { message: message_or_error, context, trace });
35 | }
36 | }
37 |
38 | private addLog(level: ELogLevel, log_data: ILogBase) {
39 | if (this.logger.isValidLevel(level)) {
40 | const current_log_time = Date.now();
41 |
42 | const log: ILog = {
43 | level,
44 | message: log_data.message,
45 | trace: log_data.trace,
46 | context: {
47 | request_id: this.request_id,
48 | log_index: this.log_index,
49 | starts_at: this.starts_at,
50 | handle_time: `${current_log_time - this.prev_log_time} ms`,
51 | ...this.base_context,
52 | ...log_data.context,
53 | },
54 | };
55 |
56 | this.prev_log_time = current_log_time;
57 |
58 | this.log_index++;
59 |
60 | this.logger.addLog(log);
61 | }
62 | }
63 | }
64 |
--------------------------------------------------------------------------------
/src/main.ts:
--------------------------------------------------------------------------------
1 | import { ValidationPipe } from '@nestjs/common';
2 | import { ConfigService } from '@nestjs/config';
3 | import { NestFactory } from '@nestjs/core';
4 | import { ExpressAdapter } from '@nestjs/platform-express';
5 | import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
6 | import { json, urlencoded } from 'body-parser';
7 | import cookieParser from 'cookie-parser';
8 | import express from 'express';
9 | import { graphqlUploadExpress } from 'graphql-upload-ts';
10 | import helmet from 'helmet';
11 |
12 | import { AppModule } from './app.module';
13 | import { CorsMiddleware } from './cors/cors.middleware';
14 | import { GraphQLReloaderService } from './graphql/reloader/reloader.service';
15 | import { JwtService } from './jwt/jwt.service';
16 | import { RedisService } from './redis/redis.service';
17 | import { WsAdapter } from './ws/ws.adapter';
18 |
19 | async function bootstrap() {
20 | const server = express();
21 |
22 | const app = await NestFactory.create(AppModule, new ExpressAdapter(server), {
23 | bodyParser: true,
24 | });
25 |
26 | const config = app.get(ConfigService);
27 | const redis = app.get(RedisService);
28 | const jwt = app.get(JwtService);
29 | const graphql_reload = app.get(GraphQLReloaderService);
30 |
31 | graphql_reload.applyApp(app);
32 |
33 | app.use(graphqlUploadExpress({ maxFileSize: config.getOrThrow('APP_BODY_PARAMETER_LIMIT'), maxFiles: 1 }));
34 | app.use(json({ limit: config.getOrThrow('APP_BODY_LIMIT') }));
35 | app.use(
36 | urlencoded({
37 | limit: config.getOrThrow('APP_BODY_LIMIT'),
38 | extended: true,
39 | parameterLimit: config.getOrThrow('APP_BODY_PARAMETER_LIMIT'),
40 | })
41 | );
42 |
43 | app.use(
44 | helmet({
45 | contentSecurityPolicy: false,
46 | crossOriginEmbedderPolicy: false,
47 | })
48 | );
49 |
50 | app.use(cookieParser());
51 |
52 | app.enableCors(
53 | CorsMiddleware({
54 | allowed_origins: JSON.parse(config.getOrThrow('CORS_ALLOWED_ORIGINS')),
55 | allowed_methods: JSON.parse(config.getOrThrow('CORS_ALLOWED_METHODS')),
56 | allowed_paths: JSON.parse(config.getOrThrow('CORS_ALLOWED_PATHS')),
57 | credentials: config.getOrThrow('CORS_CREDENTIALS'),
58 | })
59 | );
60 |
61 | app.useWebSocketAdapter(new WsAdapter(app, config, redis, jwt));
62 |
63 | app.useGlobalPipes(
64 | new ValidationPipe({
65 | transform: true,
66 | })
67 | );
68 |
69 | const options = new DocumentBuilder().setTitle('NestJS-Example').setVersion('1.0').build();
70 | const document = SwaggerModule.createDocument(app, options);
71 | SwaggerModule.setup('swagger', app, document);
72 |
73 | await app.listen(parseInt(config.getOrThrow('APP_PORT'), 10)).then(() => {
74 | console.log(`${config.getOrThrow('APP_NAME')} listening on http://localhost:${config.getOrThrow('APP_PORT')}`);
75 |
76 | console.log(
77 | `${config.getOrThrow('APP_NAME')} listening on ws://localhost:${config.getOrThrow('WS_PORT')}${config.getOrThrow('WS_PATH')}`
78 | );
79 | });
80 | }
81 |
82 | bootstrap();
83 |
--------------------------------------------------------------------------------
/src/models/book/book.entity.ts:
--------------------------------------------------------------------------------
1 | import { ID } from '@nestjs/graphql';
2 | import { DateTimeISOResolver } from 'graphql-scalars';
3 | import { Column, CreateDateColumn, Entity, Field, ObjectType, PrimaryGeneratedColumn, UpdateDateColumn } from 'nestjs-graphql-easy';
4 | import { Index, OneToMany } from 'typeorm';
5 |
6 | import { Section } from '../section/section.entity';
7 |
8 | @ObjectType()
9 | @Entity()
10 | export class Book {
11 | @Field(() => ID, { filterable: true, sortable: true, nullable: false })
12 | @PrimaryGeneratedColumn('uuid')
13 | public id: string;
14 |
15 | @Field(() => DateTimeISOResolver, { nullable: false })
16 | @CreateDateColumn({
17 | type: 'timestamp without time zone',
18 | default: () => 'CURRENT_TIMESTAMP',
19 | precision: 3,
20 | })
21 | public created_at: Date;
22 |
23 | @Field(() => DateTimeISOResolver, { nullable: false })
24 | @UpdateDateColumn({
25 | type: 'timestamp without time zone',
26 | default: () => 'CURRENT_TIMESTAMP',
27 | precision: 3,
28 | })
29 | public updated_at: Date;
30 |
31 | @Field(() => String, { nullable: false })
32 | @Column()
33 | public title: string;
34 |
35 | @Field(() => Boolean, { filterable: true, nullable: false })
36 | @Index()
37 | @Column('boolean', { nullable: false, default: () => 'false' })
38 | public is_private: boolean;
39 |
40 | @OneToMany(() => Section, (section) => section.book)
41 | public sections: Section[];
42 | }
43 |
--------------------------------------------------------------------------------
/src/models/book/book.module.ts:
--------------------------------------------------------------------------------
1 | import { Module } from '@nestjs/common';
2 | import { TypeOrmModule } from '@nestjs/typeorm';
3 |
4 | import { Book } from './book.entity';
5 | import { BookResolver } from './book.resolver';
6 | import { BookService } from './book.service';
7 |
8 | @Module({
9 | imports: [TypeOrmModule.forFeature([Book])],
10 | providers: [BookResolver, BookService],
11 | exports: [BookService],
12 | })
13 | export class BookModule {}
14 |
--------------------------------------------------------------------------------
/src/models/book/book.resolver.ts:
--------------------------------------------------------------------------------
1 | import { Inject } from '@nestjs/common';
2 | import { Args, Context, GraphQLExecutionContext, Parent, Resolver, Subscription } from '@nestjs/graphql';
3 | import { RedisPubSub } from 'graphql-redis-subscriptions';
4 | import { ELoaderType, Filter, Loader, Mutation, Order, Pagination, Query, ResolveField } from 'nestjs-graphql-easy';
5 |
6 | import { GRAPHQL_SUBSCRIPTION } from '../../graphql/graphql.module';
7 | import { Section } from '../section/section.entity';
8 | import { Book } from './book.entity';
9 | import { BookService } from './book.service';
10 | import { BookCreateDTO } from './mutation-input/create.dto';
11 |
12 | @Resolver(() => Book)
13 | export class BookResolver {
14 | constructor(
15 | private readonly book_service: BookService,
16 | @Inject(GRAPHQL_SUBSCRIPTION) private readonly subscription: RedisPubSub
17 | ) {}
18 |
19 | @Query(() => [Book])
20 | public async books(
21 | @Loader({
22 | loader_type: ELoaderType.MANY,
23 | field_name: 'books',
24 | entity: () => Book,
25 | entity_fk_key: 'id',
26 | })
27 | field_alias: string,
28 | @Filter(() => Book) _filter: unknown,
29 | @Order(() => Book) _order: unknown,
30 | @Pagination() _pagination: unknown,
31 | @Context() ctx: GraphQLExecutionContext
32 | ) {
33 | return await ctx[field_alias];
34 | }
35 |
36 | @ResolveField(() => [Section], { nullable: true })
37 | public async sections(
38 | @Parent() book: Book,
39 | @Loader({
40 | loader_type: ELoaderType.ONE_TO_MANY,
41 | field_name: 'sections',
42 | entity: () => Section,
43 | entity_fk_key: 'book_id',
44 | })
45 | field_alias: string,
46 | @Filter(() => Section) _filter: unknown,
47 | @Order(() => Section) _order: unknown,
48 | @Context() ctx: GraphQLExecutionContext
49 | ): Promise {
50 | return await ctx[field_alias].load(book.id);
51 | }
52 |
53 | @Mutation(() => Book)
54 | protected async bookCreate(@Args('data') data: BookCreateDTO) {
55 | const book = await this.book_service.create(data);
56 |
57 | this.subscription.publish('bookCreateEvent', { bookCreateEvent: book });
58 |
59 | return book;
60 | }
61 |
62 | @Subscription(() => Book)
63 | protected async bookCreateEvent() {
64 | return this.subscription.asyncIterableIterator('bookCreateEvent');
65 | }
66 | }
67 |
--------------------------------------------------------------------------------
/src/models/book/book.service.ts:
--------------------------------------------------------------------------------
1 | import { Injectable } from '@nestjs/common';
2 | import { InjectRepository } from '@nestjs/typeorm';
3 | import { Repository } from 'typeorm';
4 |
5 | import { Book } from './book.entity';
6 | import { BookCreateDTO } from './mutation-input/create.dto';
7 |
8 | @Injectable()
9 | export class BookService {
10 | constructor(@InjectRepository(Book) private readonly repository: Repository) {}
11 |
12 | public async create(data: BookCreateDTO) {
13 | const {
14 | raw: [book],
15 | }: { raw: [Book] } = await this.repository.createQueryBuilder().insert().values(data).returning('*').execute();
16 |
17 | return book;
18 | }
19 | }
20 |
--------------------------------------------------------------------------------
/src/models/book/mutation-input/create.dto.ts:
--------------------------------------------------------------------------------
1 | import { Field, InputType } from '@nestjs/graphql';
2 | import { IsNotEmpty, IsString } from 'class-validator';
3 |
4 | @InputType()
5 | export class BookCreateDTO {
6 | @Field(() => String, { nullable: false })
7 | @IsString()
8 | @IsNotEmpty()
9 | public title: string;
10 | }
11 |
--------------------------------------------------------------------------------
/src/models/models.module.ts:
--------------------------------------------------------------------------------
1 | import { Module } from '@nestjs/common';
2 |
3 | import { BookModule } from './book/book.module';
4 | import { SectionModule } from './section/section.module';
5 |
6 | @Module({
7 | imports: [BookModule, SectionModule],
8 | exports: [BookModule, SectionModule],
9 | })
10 | export class ModelsModule {}
11 |
--------------------------------------------------------------------------------
/src/models/section/mutation-input/create.dto.ts:
--------------------------------------------------------------------------------
1 | import { Field, ID, InputType } from '@nestjs/graphql';
2 | import { IsNotEmpty, IsString, IsUUID } from 'class-validator';
3 |
4 | @InputType()
5 | export class SectionCreateDTO {
6 | @Field(() => String, { nullable: false })
7 | @IsString()
8 | @IsNotEmpty()
9 | public title: string;
10 |
11 | @Field(() => ID, { nullable: false })
12 | @IsUUID()
13 | public book_id: string;
14 | }
15 |
--------------------------------------------------------------------------------
/src/models/section/section.entity.ts:
--------------------------------------------------------------------------------
1 | import { ID } from '@nestjs/graphql';
2 | import { DateTimeISOResolver } from 'graphql-scalars';
3 | import { Column, CreateDateColumn, Entity, Field, ObjectType, PrimaryGeneratedColumn, UpdateDateColumn } from 'nestjs-graphql-easy';
4 | import { Index, JoinColumn, ManyToOne } from 'typeorm';
5 |
6 | import { Book } from '../book/book.entity';
7 |
8 | @ObjectType()
9 | @Entity()
10 | export class Section {
11 | @Field(() => ID, { filterable: true, sortable: true, nullable: false })
12 | @PrimaryGeneratedColumn('uuid')
13 | public id: string;
14 |
15 | @Field(() => DateTimeISOResolver, { nullable: false })
16 | @CreateDateColumn({
17 | type: 'timestamp without time zone',
18 | default: () => 'CURRENT_TIMESTAMP',
19 | precision: 3,
20 | })
21 | public created_at: Date;
22 |
23 | @Field(() => DateTimeISOResolver, { nullable: false })
24 | @UpdateDateColumn({
25 | type: 'timestamp without time zone',
26 | default: () => 'CURRENT_TIMESTAMP',
27 | precision: 3,
28 | })
29 | public updated_at: Date;
30 |
31 | @Field(() => String, { nullable: false })
32 | @Column()
33 | public title: string;
34 |
35 | @Field(() => ID, { filterable: true, sortable: true, nullable: false })
36 | @Index()
37 | @Column('uuid', { nullable: false })
38 | public book_id: string;
39 |
40 | @ManyToOne(() => Book, { nullable: false, onDelete: 'CASCADE' })
41 | @JoinColumn({ name: 'book_id' })
42 | public book: Book;
43 | }
44 |
--------------------------------------------------------------------------------
/src/models/section/section.module.ts:
--------------------------------------------------------------------------------
1 | import { Module } from '@nestjs/common';
2 | import { TypeOrmModule } from '@nestjs/typeorm';
3 |
4 | import { Section } from './section.entity';
5 | import { SectionResolver } from './section.resolver';
6 | import { SectionService } from './section.service';
7 |
8 | @Module({
9 | imports: [TypeOrmModule.forFeature([Section])],
10 | providers: [SectionResolver, SectionService],
11 | exports: [SectionService],
12 | })
13 | export class SectionModule {}
14 |
--------------------------------------------------------------------------------
/src/models/section/section.resolver.ts:
--------------------------------------------------------------------------------
1 | import { Inject } from '@nestjs/common';
2 | import { Args, Context, GraphQLExecutionContext, ID, Parent, Resolver, Subscription } from '@nestjs/graphql';
3 | import { RedisPubSub } from 'graphql-redis-subscriptions';
4 | import { ELoaderType, Filter, Loader, Mutation, Order, Pagination, Query, ResolveField } from 'nestjs-graphql-easy';
5 |
6 | import { GRAPHQL_SUBSCRIPTION } from '../../graphql/graphql.module';
7 | import { Book } from '../book/book.entity';
8 | import { SectionCreateDTO } from './mutation-input/create.dto';
9 | import { Section } from './section.entity';
10 | import { SectionService } from './section.service';
11 |
12 | @Resolver(() => Section)
13 | export class SectionResolver {
14 | constructor(
15 | private readonly section_service: SectionService,
16 | @Inject(GRAPHQL_SUBSCRIPTION) private readonly subscription: RedisPubSub
17 | ) {}
18 |
19 | @Query(() => [Section])
20 | public async sections(
21 | @Loader({
22 | loader_type: ELoaderType.MANY,
23 | field_name: 'sections',
24 | entity: () => Section,
25 | entity_fk_key: 'id',
26 | })
27 | field_alias: string,
28 | @Filter(() => Section) _filter: unknown,
29 | @Order(() => Section) _order: unknown,
30 | @Pagination() _pagination: unknown,
31 | @Context() ctx: GraphQLExecutionContext
32 | ) {
33 | return await ctx[field_alias];
34 | }
35 |
36 | @ResolveField(() => Book, { nullable: false })
37 | public async book(
38 | @Parent() section: Section,
39 | @Loader({
40 | loader_type: ELoaderType.MANY_TO_ONE,
41 | field_name: 'book',
42 | entity: () => Book,
43 | entity_fk_key: 'id',
44 | })
45 | field_alias: string,
46 | @Context() ctx: GraphQLExecutionContext
47 | ): Promise {
48 | return await ctx[field_alias].load(section.book_id);
49 | }
50 |
51 | @Mutation(() => Section)
52 | protected async sectionCreate(@Args('data') data: SectionCreateDTO) {
53 | const section = await this.section_service.create(data);
54 |
55 | this.subscription.publish('sectionCreateEvent', { sectionCreateEvent: section, channel_ids: [section.book_id] });
56 |
57 | return section;
58 | }
59 |
60 | @Subscription(() => Section, {
61 | filter: (payload, variables) => payload.channel_ids.includes(variables.channel_id),
62 | })
63 | protected async sectionCreateEvent(@Args({ name: 'channel_id', type: () => ID, nullable: false }) _channel_id: string) {
64 | return this.subscription.asyncIterableIterator('sectionCreateEvent');
65 | }
66 | }
67 |
--------------------------------------------------------------------------------
/src/models/section/section.service.ts:
--------------------------------------------------------------------------------
1 | import { Injectable } from '@nestjs/common';
2 | import { InjectRepository } from '@nestjs/typeorm';
3 | import { Repository } from 'typeorm';
4 |
5 | import { SectionCreateDTO } from './mutation-input/create.dto';
6 | import { Section } from './section.entity';
7 |
8 | @Injectable()
9 | export class SectionService {
10 | constructor(@InjectRepository(Section) private readonly repository: Repository) {}
11 |
12 | public async create(data: SectionCreateDTO) {
13 | const {
14 | raw: [section],
15 | }: { raw: [Section] } = await this.repository.createQueryBuilder().insert().values(data).returning('*').execute();
16 |
17 | return section;
18 | }
19 | }
20 |
--------------------------------------------------------------------------------
/src/oauth/dto/change-password.dto.ts:
--------------------------------------------------------------------------------
1 | import { ApiProperty } from '@nestjs/swagger';
2 | import { IsUUID, Length } from 'class-validator';
3 |
4 | export class ChangePasswordDTO {
5 | @ApiProperty({
6 | required: true,
7 | description: 'Recovery key',
8 | })
9 | @IsUUID()
10 | public readonly recovery_key: string;
11 |
12 | @ApiProperty({
13 | required: true,
14 | description: 'New password',
15 | })
16 | @Length(4, 32)
17 | public readonly new_password: string;
18 | }
19 |
--------------------------------------------------------------------------------
/src/oauth/dto/registration-response.dto.ts:
--------------------------------------------------------------------------------
1 | import { ApiProperty } from '@nestjs/swagger';
2 |
3 | export class RegistrationResponseDTO {
4 | @ApiProperty({
5 | required: true,
6 | type: String,
7 | isArray: true,
8 | description: 'Recovery keys',
9 | })
10 | public readonly recovery_keys: string[];
11 | }
12 |
--------------------------------------------------------------------------------
/src/oauth/dto/registration.dto.ts:
--------------------------------------------------------------------------------
1 | import { ApiProperty } from '@nestjs/swagger';
2 | import { IsString, Length } from 'class-validator';
3 |
4 | export class RegistrationDTO {
5 | @ApiProperty({
6 | required: true,
7 | description: 'Username',
8 | })
9 | @IsString()
10 | @Length(4, 32)
11 | public readonly username: string;
12 |
13 | @ApiProperty({
14 | required: true,
15 | description: 'Password',
16 | })
17 | @Length(4, 32)
18 | public readonly password: string;
19 | }
20 |
--------------------------------------------------------------------------------
/src/oauth/dto/sign-in-by-password.dto.ts:
--------------------------------------------------------------------------------
1 | import { IsString, Length } from 'class-validator';
2 |
3 | export class SignInByPasswordDTO {
4 | @IsString()
5 | @Length(4, 32)
6 | public readonly username: string;
7 |
8 | @Length(4, 32)
9 | public readonly password: string;
10 | }
11 |
--------------------------------------------------------------------------------
/src/oauth/dto/sign-in-by-refresh-token.dto.ts:
--------------------------------------------------------------------------------
1 | import { IsJWT } from 'class-validator';
2 |
3 | export class SignInByRefreshTokenDTO {
4 | @IsJWT()
5 | public readonly refresh_token: string;
6 | }
7 |
--------------------------------------------------------------------------------
/src/oauth/dto/sign-in-response.dto.ts:
--------------------------------------------------------------------------------
1 | import { ApiProperty } from '@nestjs/swagger';
2 |
3 | export class SignInResponseDTO {
4 | @ApiProperty({
5 | required: true,
6 | description: 'JWT access token',
7 | })
8 | public readonly access_token: string;
9 |
10 | @ApiProperty({
11 | required: true,
12 | description: 'JWT refresh token',
13 | })
14 | public readonly refresh_token: string;
15 | }
16 |
--------------------------------------------------------------------------------
/src/oauth/oauth.controller.ts:
--------------------------------------------------------------------------------
1 | import { Body, Controller, Post, Req } from '@nestjs/common';
2 | import { ApiBody, ApiOkResponse, ApiOperation, ApiQuery, ApiTags } from '@nestjs/swagger';
3 | import { Request } from 'express';
4 |
5 | import { authorization_failed } from '../utils/errors';
6 | import { validateDTO } from '../utils/validate';
7 | import { ChangePasswordDTO } from './dto/change-password.dto';
8 | import { RegistrationResponseDTO } from './dto/registration-response.dto';
9 | import { RegistrationDTO } from './dto/registration.dto';
10 | import { SignInByPasswordDTO } from './dto/sign-in-by-password.dto';
11 | import { SignInByRefreshTokenDTO } from './dto/sign-in-by-refresh-token.dto';
12 | import { SignInResponseDTO } from './dto/sign-in-response.dto';
13 | import { OAuthService } from './oauth.service';
14 |
15 | @ApiTags('oauth')
16 | @Controller('oauth')
17 | export class OAuthController {
18 | constructor(private readonly oauth_service: OAuthService) {}
19 |
20 | @ApiOperation({
21 | summary: 'User registration',
22 | })
23 | @ApiBody({
24 | type: RegistrationDTO,
25 | required: true,
26 | })
27 | @ApiOkResponse({
28 | isArray: false,
29 | type: RegistrationResponseDTO,
30 | })
31 | @Post('registration')
32 | public async registration(@Body() body: RegistrationDTO) {
33 | return this.oauth_service.registration(body.username, body.password);
34 | }
35 |
36 | @ApiOperation({
37 | summary: 'User authorization',
38 | })
39 | @ApiQuery({
40 | name: 'grand_type',
41 | description: 'Grand type',
42 | enum: ['password', 'refresh_token'],
43 | required: true,
44 | })
45 | @ApiQuery({
46 | name: 'username',
47 | description: 'Username. If grand_type = password',
48 | required: false,
49 | })
50 | @ApiQuery({
51 | name: 'password',
52 | description: 'Password. If grand_type = password',
53 | required: false,
54 | })
55 | @ApiQuery({
56 | name: 'refresh_token',
57 | description: 'Refresh token. If grand_type = refresh_token',
58 | required: false,
59 | })
60 | @ApiOkResponse({
61 | type: SignInResponseDTO,
62 | isArray: false,
63 | })
64 | @Post('token')
65 | public async token(@Req() req: Request) {
66 | if (Object.keys(req.query).length) {
67 | switch (req.query.grand_type) {
68 | case 'password':
69 | const sign_in_by_password_dto = {
70 | username: req.query.username as string,
71 | password: req.query.password as string,
72 | };
73 |
74 | validateDTO(SignInByPasswordDTO, sign_in_by_password_dto);
75 |
76 | return this.oauth_service.signInByPassword(sign_in_by_password_dto.username, sign_in_by_password_dto.password);
77 | case 'refresh_token':
78 | const sign_in_by_refresh_token_dto = {
79 | refresh_token: req.query.refresh_token as string,
80 | };
81 |
82 | validateDTO(SignInByRefreshTokenDTO, sign_in_by_refresh_token_dto);
83 |
84 | return this.oauth_service.signInByRefreshToken(sign_in_by_refresh_token_dto.refresh_token);
85 | }
86 | }
87 |
88 | throw authorization_failed();
89 | }
90 |
91 | @ApiOperation({
92 | summary: 'Password recovery',
93 | })
94 | @ApiBody({
95 | type: ChangePasswordDTO,
96 | required: true,
97 | })
98 | @Post('change_password')
99 | public async changePassword(@Body() body: ChangePasswordDTO) {
100 | return this.oauth_service.changePassword(body.recovery_key, body.new_password);
101 | }
102 | }
103 |
--------------------------------------------------------------------------------
/src/oauth/oauth.decorator.ts:
--------------------------------------------------------------------------------
1 | import { createParamDecorator, ExecutionContext } from '@nestjs/common';
2 |
3 | export const RestCurrentUser = createParamDecorator((_data: unknown, ctx: ExecutionContext) => {
4 | const request = ctx.switchToHttp().getRequest();
5 |
6 | return request.current_user;
7 | });
8 |
--------------------------------------------------------------------------------
/src/oauth/oauth.middleware.ts:
--------------------------------------------------------------------------------
1 | import { Injectable, NestMiddleware } from '@nestjs/common';
2 | import { NextFunction, Request, Response } from 'express';
3 |
4 | import { JwtService } from '../jwt/jwt.service';
5 | import { access_token_expired_signature, authorization_failed, unauthorized } from '../utils/errors';
6 | import { IAccessToken } from './oauth.service';
7 | import { IJwtPayload } from './user/user.entity';
8 |
9 | @Injectable()
10 | export class OAuthMiddleware implements NestMiddleware {
11 | constructor(private readonly jwt: JwtService) {}
12 |
13 | public async use(req: Request & { current_user?: IJwtPayload }, _res: Response, next: NextFunction) {
14 | const access_token: string = req.headers.authorization ?? (req.cookies as { access_token: string }).access_token;
15 |
16 | if (access_token) {
17 | try {
18 | const { current_user, token_type } = this.jwt.verify(access_token);
19 |
20 | if (token_type !== 'access') {
21 | return next(authorization_failed());
22 | }
23 |
24 | req.current_user = current_user;
25 |
26 | return next();
27 | } catch (error) {
28 | return next(access_token_expired_signature());
29 | }
30 | }
31 |
32 | return next(unauthorized());
33 | }
34 | }
35 |
--------------------------------------------------------------------------------
/src/oauth/oauth.module.ts:
--------------------------------------------------------------------------------
1 | import { Module } from '@nestjs/common';
2 |
3 | import { JwtModule } from '../jwt/jwt.module';
4 | import { OAuthController } from './oauth.controller';
5 | import { OAuthService } from './oauth.service';
6 | import { RecoveryKeyModule } from './recovery-key/recovery-key.module';
7 | import { RefreshTokenModule } from './refresh-token/refresh-token.module';
8 | import { UserModule } from './user/user.module';
9 |
10 | @Module({
11 | imports: [UserModule, RefreshTokenModule, RecoveryKeyModule, JwtModule],
12 | providers: [OAuthService],
13 | controllers: [OAuthController],
14 | exports: [UserModule, RefreshTokenModule, RecoveryKeyModule],
15 | })
16 | export class OAuthModule {}
17 |
--------------------------------------------------------------------------------
/src/oauth/oauth.service.ts:
--------------------------------------------------------------------------------
1 | import { Injectable } from '@nestjs/common';
2 | import { DataSource, DeepPartial, EntityManager } from 'typeorm';
3 |
4 | import { JwtService } from '../jwt/jwt.service';
5 | import { checkPassword, passwordToHash } from '../utils/bcrypt';
6 | import { authorization_failed, bad_request } from '../utils/errors';
7 | import { RecoveryKey } from './recovery-key/recovery-key.entity';
8 | import { RefreshToken } from './refresh-token/refresh-token.entity';
9 | import { IJwtPayload, User } from './user/user.entity';
10 |
11 | export interface IJwtToken {
12 | iat: number;
13 | exp: number;
14 | jti: string;
15 | }
16 |
17 | export interface IAccessToken extends IJwtToken {
18 | current_user: IJwtPayload;
19 | token_type: 'access';
20 | }
21 |
22 | export interface IRefreshToken extends IJwtToken {
23 | current_user: { id: string };
24 | token_type: 'refresh';
25 | }
26 |
27 | @Injectable()
28 | export class OAuthService {
29 | constructor(
30 | private readonly jwt: JwtService,
31 | private readonly data_source: DataSource
32 | ) {}
33 |
34 | public async registration(username: string, password: string) {
35 | return this.data_source.transaction(async (entity_manager) => {
36 | const exist_user = await entity_manager.getRepository(User).findOne({ where: { username: username.trim().toLowerCase() } });
37 |
38 | if (exist_user) {
39 | throw bad_request();
40 | }
41 |
42 | const user_dto: DeepPartial = {
43 | username: username.trim().toLowerCase(),
44 | encrypted_password: passwordToHash(password),
45 | };
46 |
47 | const inserted_user = await entity_manager.getRepository(User).insert(user_dto);
48 |
49 | return this.reGenerateRecoveryKeys(entity_manager, inserted_user.identifiers[0].id);
50 | });
51 | }
52 |
53 | public async signInByPassword(username: string, password: string) {
54 | return this.data_source.transaction(async (entity_manager) => {
55 | const user = await entity_manager.getRepository(User).findOne({ where: { username: username.trim().toLowerCase() } });
56 |
57 | if (!user || !checkPassword(user.encrypted_password, password)) {
58 | throw authorization_failed();
59 | }
60 |
61 | return this.generateJwt(entity_manager, user);
62 | });
63 | }
64 |
65 | public async signInByRefreshToken(refresh_token: string) {
66 | return this.data_source.transaction(async (entity_manager) => {
67 | const { current_user, token_type, jti } = this.jwt.verify(refresh_token, false);
68 |
69 | if (!token_type || token_type !== 'refresh') {
70 | throw authorization_failed();
71 | }
72 |
73 | const deleted_token = await entity_manager.getRepository(RefreshToken).delete({ id: jti, user_id: current_user.id });
74 |
75 | if (!deleted_token.affected) {
76 | throw authorization_failed();
77 | }
78 |
79 | const user = await entity_manager.getRepository(User).findOne({ where: { id: current_user.id } });
80 |
81 | if (!user) {
82 | throw authorization_failed();
83 | }
84 |
85 | return this.generateJwt(entity_manager, user);
86 | });
87 | }
88 |
89 | public async changePassword(recovery_key: string, new_password: string) {
90 | return await this.data_source.transaction(async (entityManager) => {
91 | const recovery_key_entity = await entityManager.getRepository(RecoveryKey).findOne({ where: { id: recovery_key } });
92 |
93 | if (!recovery_key_entity) {
94 | throw authorization_failed();
95 | }
96 |
97 | await entityManager.getRepository(User).update(recovery_key_entity.user_id, { encrypted_password: passwordToHash(new_password) });
98 |
99 | await entityManager.getRepository(RecoveryKey).delete(recovery_key);
100 |
101 | return { message: 'OK' };
102 | });
103 | }
104 |
105 | private async generateJwt(entity_manager: EntityManager, user: User) {
106 | const refresh = await entity_manager.getRepository(RefreshToken).save({ user_id: user.id });
107 |
108 | const access_token = this.jwt.generateAccess({ current_user: user.getJwtPayload() });
109 | const refresh_token = this.jwt.generateRefresh({ current_user: user.getJwtPayload() }, refresh.id);
110 |
111 | return {
112 | access_token,
113 | refresh_token,
114 | };
115 | }
116 |
117 | private async reGenerateRecoveryKeys(entity_manager: EntityManager, user_id: string) {
118 | await entity_manager.getRepository(RecoveryKey).delete({ user_id });
119 |
120 | const keys: { user_id: string }[] = [];
121 |
122 | for (let i = 0; i < 5; i++) {
123 | keys.push({ user_id });
124 | }
125 |
126 | const recovery_keys = (await entity_manager.getRepository(RecoveryKey).save(keys)).map((r: RecoveryKey) => r.id);
127 |
128 | return {
129 | recovery_keys,
130 | };
131 | }
132 | }
133 |
--------------------------------------------------------------------------------
/src/oauth/recovery-key/recovery-key.entity.ts:
--------------------------------------------------------------------------------
1 | import { Column, CreateDateColumn, Entity, Index, JoinColumn, ManyToOne, PrimaryGeneratedColumn, UpdateDateColumn } from 'typeorm';
2 |
3 | import { User } from '../user/user.entity';
4 |
5 | @Entity()
6 | export class RecoveryKey {
7 | @PrimaryGeneratedColumn('uuid')
8 | public id: string;
9 |
10 | @CreateDateColumn({
11 | type: 'timestamp without time zone',
12 | precision: 3,
13 | default: () => 'CURRENT_TIMESTAMP',
14 | })
15 | public created_at: Date;
16 |
17 | @UpdateDateColumn({
18 | type: 'timestamp without time zone',
19 | precision: 3,
20 | default: () => 'CURRENT_TIMESTAMP',
21 | })
22 | public updated_at: Date;
23 |
24 | @Index()
25 | @Column('uuid', { nullable: false })
26 | public user_id: string;
27 |
28 | @ManyToOne(() => User, { nullable: false, onDelete: 'CASCADE' })
29 | @JoinColumn({ name: 'user_id' })
30 | public user: User;
31 | }
32 |
--------------------------------------------------------------------------------
/src/oauth/recovery-key/recovery-key.module.ts:
--------------------------------------------------------------------------------
1 | import { Module } from '@nestjs/common';
2 | import { TypeOrmModule } from '@nestjs/typeorm';
3 |
4 | import { RecoveryKey } from './recovery-key.entity';
5 |
6 | @Module({
7 | imports: [TypeOrmModule.forFeature([RecoveryKey])],
8 | })
9 | export class RecoveryKeyModule {}
10 |
--------------------------------------------------------------------------------
/src/oauth/refresh-token/refresh-token.entity.ts:
--------------------------------------------------------------------------------
1 | import { Column, CreateDateColumn, Entity, Index, JoinColumn, ManyToOne, PrimaryGeneratedColumn, UpdateDateColumn } from 'typeorm';
2 |
3 | import { User } from '../user/user.entity';
4 |
5 | @Entity()
6 | export class RefreshToken {
7 | @PrimaryGeneratedColumn('uuid')
8 | public id: string;
9 |
10 | @CreateDateColumn({
11 | type: 'timestamp without time zone',
12 | precision: 3,
13 | default: () => 'CURRENT_TIMESTAMP',
14 | })
15 | public created_at: Date;
16 |
17 | @UpdateDateColumn({
18 | type: 'timestamp without time zone',
19 | precision: 3,
20 | default: () => 'CURRENT_TIMESTAMP',
21 | })
22 | public updated_at: Date;
23 |
24 | @Index()
25 | @Column('uuid', { nullable: false })
26 | public user_id: string;
27 |
28 | @ManyToOne(() => User, { nullable: false, onDelete: 'CASCADE' })
29 | @JoinColumn({ name: 'user_id' })
30 | public user: User;
31 | }
32 |
--------------------------------------------------------------------------------
/src/oauth/refresh-token/refresh-token.module.ts:
--------------------------------------------------------------------------------
1 | import { Module } from '@nestjs/common';
2 | import { TypeOrmModule } from '@nestjs/typeorm';
3 |
4 | import { RefreshToken } from './refresh-token.entity';
5 |
6 | @Module({
7 | imports: [TypeOrmModule.forFeature([RefreshToken])],
8 | })
9 | export class RefreshTokenModule {}
10 |
--------------------------------------------------------------------------------
/src/oauth/user/user.entity.ts:
--------------------------------------------------------------------------------
1 | import { Column, CreateDateColumn, Entity, Index, OneToMany, PrimaryGeneratedColumn, UpdateDateColumn } from 'typeorm';
2 |
3 | import { RecoveryKey } from '../recovery-key/recovery-key.entity';
4 | import { RefreshToken } from '../refresh-token/refresh-token.entity';
5 |
6 | export interface IJwtPayload {
7 | id: string;
8 | username: string;
9 | }
10 |
11 | @Entity()
12 | export class User {
13 | @PrimaryGeneratedColumn('uuid')
14 | public id: string;
15 |
16 | @CreateDateColumn({
17 | type: 'timestamp without time zone',
18 | precision: 3,
19 | default: () => 'CURRENT_TIMESTAMP',
20 | })
21 | public created_at: Date;
22 |
23 | @UpdateDateColumn({
24 | type: 'timestamp without time zone',
25 | precision: 3,
26 | default: () => 'CURRENT_TIMESTAMP',
27 | })
28 | public updated_at: Date;
29 |
30 | @Column({ nullable: false })
31 | @Index('IDX_78a916df40e02a9deb1c4b75ed', { synchronize: false })
32 | public username: string;
33 |
34 | @Column({ nullable: false })
35 | public encrypted_password: string;
36 |
37 | @OneToMany(() => RefreshToken, (refresh_token) => refresh_token.user)
38 | public refresh_tokens?: RefreshToken[];
39 |
40 | @OneToMany(() => RecoveryKey, (recovery_key) => recovery_key.user)
41 | public recovery_keys?: RecoveryKey[];
42 |
43 | public getJwtPayload(): IJwtPayload {
44 | return {
45 | id: this.id,
46 | username: this.username,
47 | };
48 | }
49 | }
50 |
--------------------------------------------------------------------------------
/src/oauth/user/user.module.ts:
--------------------------------------------------------------------------------
1 | import { Module } from '@nestjs/common';
2 | import { TypeOrmModule } from '@nestjs/typeorm';
3 |
4 | import { User } from './user.entity';
5 |
6 | @Module({
7 | imports: [TypeOrmModule.forFeature([User])],
8 | })
9 | export class UserModule {}
10 |
--------------------------------------------------------------------------------
/src/rabbitmq/rabbitmq.decorator.ts:
--------------------------------------------------------------------------------
1 | import { applyDecorators, SetMetadata } from '@nestjs/common';
2 |
3 | export const RMQ_ROUTE_OPTIONS = 'RMQ_ROUTE_OPTIONS';
4 |
5 | export interface IRabbitMQRouteOptions {
6 | exchange: string;
7 | routingKey: string;
8 | queue: string;
9 | }
10 |
11 | export const RabbitMQSubscribe = (options: IRabbitMQRouteOptions): MethodDecorator =>
12 | applyDecorators(
13 | SetMetadata(RMQ_ROUTE_OPTIONS, {
14 | ...options,
15 | })
16 | );
17 |
--------------------------------------------------------------------------------
/src/rabbitmq/rabbitmq.discovery.ts:
--------------------------------------------------------------------------------
1 | import { DiscoveryService } from '@golevelup/nestjs-discovery';
2 | import { RabbitSubscribe } from '@golevelup/nestjs-rabbitmq';
3 | import { Injectable, OnModuleInit } from '@nestjs/common';
4 | import { ConfigService } from '@nestjs/config';
5 |
6 | import { IRabbitMQRouteOptions, RMQ_ROUTE_OPTIONS } from './rabbitmq.decorator';
7 |
8 | @Injectable()
9 | export class RabbitMQDiscovery implements OnModuleInit {
10 | constructor(
11 | private readonly config: ConfigService,
12 | private readonly discover: DiscoveryService
13 | ) {}
14 |
15 | public async onModuleInit() {
16 | const RABBITMQ_PREFIX = this.config.get('RABBITMQ_PREFIX');
17 |
18 | const rabbit_handlers = await this.discover.providerMethodsWithMetaAtKey(RMQ_ROUTE_OPTIONS);
19 |
20 | rabbit_handlers.forEach((handler) => {
21 | const options: IRabbitMQRouteOptions = {
22 | exchange: `${RABBITMQ_PREFIX}${handler.meta.exchange}`,
23 | routingKey: handler.meta.routingKey,
24 | queue: `${RABBITMQ_PREFIX}${handler.meta.queue}`,
25 | };
26 |
27 | RabbitSubscribe(options)(handler.discoveredMethod.handler);
28 | });
29 | }
30 | }
31 |
--------------------------------------------------------------------------------
/src/rabbitmq/rabbitmq.module.ts:
--------------------------------------------------------------------------------
1 | import { DiscoveryModule, DiscoveryService } from '@golevelup/nestjs-discovery';
2 | import {
3 | AmqpConnection,
4 | MessageHandlerErrorBehavior,
5 | RabbitMQModule as NestJSRabbitMQ,
6 | RABBIT_HANDLER,
7 | RabbitHandlerConfig,
8 | RabbitMQConfig,
9 | } from '@golevelup/nestjs-rabbitmq';
10 | import { DynamicModule, Inject, Module, OnModuleInit } from '@nestjs/common';
11 | import { ConfigService } from '@nestjs/config';
12 |
13 | import { RabbitMQDiscovery } from './rabbitmq.discovery';
14 | import { RabbitMQService } from './rabbitmq.service';
15 |
16 | @Module({})
17 | export class RabbitMQModule implements OnModuleInit {
18 | constructor(
19 | private readonly config: ConfigService,
20 | @Inject(AmqpConnection) private readonly connection: AmqpConnection,
21 | private readonly discover: DiscoveryService
22 | ) {}
23 |
24 | public async onModuleInit() {
25 | const RABBITMQ_PREFIX = this.config.get('RABBITMQ_PREFIX');
26 | const RABBITMQ_EXCHANGE = this.config.getOrThrow('RABBITMQ_EXCHANGE');
27 |
28 | const rabbit_meta = await this.discover.providerMethodsWithMetaAtKey(RABBIT_HANDLER);
29 |
30 | const exchanges = rabbit_meta.reduce>((acc, curr) => {
31 | if (curr.meta.exchange && !curr.meta.exchange.startsWith(`${RABBITMQ_PREFIX}${RABBITMQ_EXCHANGE}`)) {
32 | acc.add(curr.meta.exchange);
33 | }
34 |
35 | return acc;
36 | }, new Set([]));
37 |
38 | await new Promise((resolve) => {
39 | this.connection.managedChannel.waitForConnect(async () => {
40 | for (const exchange of exchanges) {
41 | await this.connection.channel.assertExchange(exchange, 'direct');
42 | }
43 |
44 | return resolve(true);
45 | });
46 | });
47 | }
48 |
49 | public static forRoot(options: Partial = {}): DynamicModule {
50 | return {
51 | imports: [
52 | DiscoveryModule,
53 | NestJSRabbitMQ.forRootAsync({
54 | useFactory: (config: ConfigService) => {
55 | const RABBITMQ_PREFIX = config.get('RABBITMQ_PREFIX');
56 | const RABBITMQ_EXCHANGE = config.getOrThrow('RABBITMQ_EXCHANGE');
57 | const RABBITMQ_USERNAME = config.getOrThrow('RABBITMQ_USERNAME');
58 | const RABBITMQ_PASSWORD = config.getOrThrow('RABBITMQ_PASSWORD');
59 | const RABBITMQ_HOST = config.getOrThrow('RABBITMQ_HOST');
60 | const RABBITMQ_PORT = config.getOrThrow('RABBITMQ_PORT');
61 | const RABBITMQ_VHOST = config.getOrThrow('RABBITMQ_VHOST');
62 |
63 | const default_options: RabbitMQConfig = {
64 | exchanges: [
65 | {
66 | name: RABBITMQ_EXCHANGE,
67 | type: 'direct',
68 | options: { durable: true },
69 | },
70 | ],
71 | uri: `amqp://${RABBITMQ_USERNAME}:${RABBITMQ_PASSWORD}@${RABBITMQ_HOST}:${RABBITMQ_PORT}/${RABBITMQ_VHOST}`,
72 | prefetchCount: 30,
73 | defaultSubscribeErrorBehavior: MessageHandlerErrorBehavior.NACK,
74 | connectionInitOptions: { wait: true },
75 | ...options,
76 | };
77 |
78 | default_options.exchanges?.forEach((exchange, idx) => {
79 | default_options.exchanges![idx] = {
80 | name: `${RABBITMQ_PREFIX}${exchange.name}`,
81 | type: exchange.type ?? 'direct',
82 | options: exchange.options || { durable: true },
83 | };
84 | });
85 |
86 | return default_options;
87 | },
88 | inject: [ConfigService],
89 | }),
90 | ],
91 | providers: [RabbitMQDiscovery, RabbitMQService],
92 | exports: [RabbitMQService],
93 | module: RabbitMQModule,
94 | };
95 | }
96 | }
97 |
--------------------------------------------------------------------------------
/src/rabbitmq/rabbitmq.service.ts:
--------------------------------------------------------------------------------
1 | import { AmqpConnection } from '@golevelup/nestjs-rabbitmq';
2 | import { Injectable } from '@nestjs/common';
3 | import { ConfigService } from '@nestjs/config';
4 | import { v4 } from 'uuid';
5 |
6 | import { LoggerStore } from '../logger/logger.store';
7 |
8 | @Injectable()
9 | export class RabbitMQService {
10 | constructor(
11 | private readonly config: ConfigService,
12 | private readonly amqp_connection: AmqpConnection
13 | ) {}
14 |
15 | public async send(logger_store: LoggerStore, routing_key: string, message: T) {
16 | logger_store.info('RmqResultSendService: send', { routing_key });
17 |
18 | this.amqp_connection.channel.publish(
19 | `${this.config.get('RABBITMQ_PREFIX')}${this.config.getOrThrow('RABBITMQ_EXCHANGE')}`,
20 | routing_key,
21 | Buffer.from(JSON.stringify(message)),
22 | {
23 | timestamp: Date.now(),
24 | correlationId: v4(),
25 | }
26 | );
27 | }
28 | }
29 |
--------------------------------------------------------------------------------
/src/redis/redis.client.ts:
--------------------------------------------------------------------------------
1 | import { Injectable } from '@nestjs/common';
2 | import { ConfigService } from '@nestjs/config';
3 | import IORedis, { Callback, Redis } from 'ioredis';
4 | import { Subject } from 'rxjs';
5 |
6 | @Injectable()
7 | export class RedisClient {
8 | private _pub_client: Redis;
9 | private _sub_client: Redis;
10 |
11 | private readonly subscribed_events = new Set();
12 | public readonly events$ = new Subject<{ channel: string; message: string }>();
13 |
14 | constructor(private readonly config: ConfigService) {
15 | if (!this._pub_client) {
16 | this.createPubClient();
17 | }
18 |
19 | if (!this._sub_client) {
20 | this.createSubClient();
21 | }
22 | }
23 |
24 | public get pub() {
25 | if (!this._pub_client) {
26 | this.createPubClient();
27 | }
28 |
29 | return this._pub_client;
30 | }
31 |
32 | public get sub() {
33 | if (!this._sub_client) {
34 | this.createSubClient();
35 | }
36 |
37 | return this._sub_client;
38 | }
39 |
40 | public subscribe(event_name: string) {
41 | this.subscribed_events.add(event_name);
42 |
43 | this.sub.subscribe(event_name);
44 | }
45 |
46 | public unsubscribe(event_name: string) {
47 | if (this._sub_client) {
48 | this._sub_client.unsubscribe(event_name);
49 | }
50 |
51 | this.subscribed_events.delete(event_name);
52 | }
53 |
54 | public publish(event_name: string, value: string, cb: Callback) {
55 | this.pub.publish(event_name, value, cb);
56 | }
57 |
58 | private createPubClient() {
59 | this._pub_client = new IORedis({
60 | host: this.config.getOrThrow('REDIS_HOST'),
61 | port: parseInt(this.config.getOrThrow('REDIS_PORT'), 10),
62 | password: this.config.get('REDIS_PASSWORD'),
63 | keyPrefix: this.config.getOrThrow('REDIS_KEY'),
64 | });
65 | }
66 |
67 | private createSubClient() {
68 | this._sub_client = new IORedis({
69 | host: this.config.getOrThrow('REDIS_HOST'),
70 | port: parseInt(this.config.getOrThrow('REDIS_PORT'), 10),
71 | password: this.config.get('REDIS_PASSWORD'),
72 | keyPrefix: this.config.getOrThrow('REDIS_KEY'),
73 | });
74 |
75 | this.subscribed_events.forEach((event_name) => {
76 | this._sub_client.subscribe(event_name);
77 | });
78 |
79 | this._sub_client.on('message', (channel, message) => {
80 | if (this.subscribed_events.has(channel)) {
81 | this.events$.next({ channel, message });
82 | }
83 | });
84 | }
85 | }
86 |
--------------------------------------------------------------------------------
/src/redis/redis.module.ts:
--------------------------------------------------------------------------------
1 | import { Module } from '@nestjs/common';
2 |
3 | import { RedisClient } from './redis.client';
4 | import { RedisService } from './redis.service';
5 |
6 | @Module({
7 | providers: [RedisClient, RedisService],
8 | exports: [RedisService],
9 | })
10 | export class RedisModule {}
11 |
--------------------------------------------------------------------------------
/src/redis/redis.service.ts:
--------------------------------------------------------------------------------
1 | import { Injectable } from '@nestjs/common';
2 | import { ConfigService } from '@nestjs/config';
3 | import { RedisKey } from 'ioredis';
4 | import { Observable } from 'rxjs';
5 | import { filter, map } from 'rxjs/operators';
6 | import { v4 } from 'uuid';
7 |
8 | import { RedisClient } from './redis.client';
9 |
10 | export interface IRedisSubscribeMessage {
11 | readonly message: string;
12 | readonly channel: string;
13 | }
14 |
15 | const REDIS_EXPIRE_TIME_IN_SECONDS = 7 * 24 * 60 * 60; // 7 days
16 |
17 | @Injectable()
18 | export class RedisService {
19 | public readonly id: string = v4();
20 |
21 | constructor(
22 | private readonly config: ConfigService,
23 | private readonly client: RedisClient
24 | ) {}
25 |
26 | public get pub_client() {
27 | return this.client.pub;
28 | }
29 |
30 | public get sub_client() {
31 | return this.client.sub;
32 | }
33 |
34 | public fromEvent(event_name: string): Observable {
35 | const REDIS_KEY = this.config.getOrThrow('REDIS_KEY');
36 |
37 | const key = `${REDIS_KEY}_${event_name}`;
38 |
39 | this.client.subscribe(key);
40 |
41 | return this.client.events$.pipe(
42 | filter(({ channel }) => channel === key),
43 | map(({ message }) => JSON.parse(message)),
44 | filter((message) => message.redis_id !== this.id)
45 | );
46 | }
47 |
48 | public async publish(event_name: string, value: Record): Promise {
49 | const REDIS_KEY = this.config.getOrThrow('REDIS_KEY');
50 |
51 | const key = `${REDIS_KEY}_${event_name}`;
52 | const string_value = JSON.stringify({ redis_id: this.id, ...value });
53 |
54 | return new Promise((resolve, reject) =>
55 | this.client.publish(key, string_value, (error, reply) => {
56 | if (error) {
57 | return reject(error);
58 | }
59 |
60 | return resolve(reply!);
61 | })
62 | );
63 | }
64 |
65 | /**
66 | * Determine if a key exists
67 | */
68 | public async exists(key: RedisKey) {
69 | return !!(await this.sub_client.exists(key));
70 | }
71 |
72 | /**
73 | * Get the value of a key
74 | */
75 | public async get(key: RedisKey) {
76 | const res = await this.sub_client.get(key);
77 |
78 | if (res) {
79 | return JSON.parse(res) as T;
80 | }
81 |
82 | return null;
83 | }
84 |
85 | /**
86 | * Set the string value of a key
87 | */
88 | public async set(key: RedisKey, value: unknown, expire_time_in_seconds: number = REDIS_EXPIRE_TIME_IN_SECONDS) {
89 | return this.pub_client.set(key, JSON.stringify(value), 'EX', expire_time_in_seconds);
90 | }
91 |
92 | /**
93 | * Delete a key
94 | */
95 | public async del(...keys: RedisKey[]) {
96 | return this.pub_client.del(keys);
97 | }
98 |
99 | /**
100 | * Find all keys matching the given pattern
101 | */
102 | public async keys(pattern: string) {
103 | const REDIS_KEY = this.config.getOrThrow('REDIS_KEY');
104 |
105 | const key_pattern = `${REDIS_KEY}${pattern}`;
106 |
107 | return (await this.sub_client.keys(key_pattern)).map((key) => key.substring(REDIS_KEY.length));
108 | }
109 |
110 | /**
111 | * Determine if a hash field exists
112 | */
113 | public async hexists(key: RedisKey, field: string) {
114 | return !!(await this.sub_client.hexists(key, field));
115 | }
116 |
117 | /**
118 | * Set the string value of a hash field
119 | */
120 | public async hset(key: RedisKey, field: string, value: unknown) {
121 | return this.pub_client.hset(key, field, JSON.stringify(value));
122 | }
123 |
124 | /**
125 | * Set the string value of a hash field
126 | */
127 | public async hsetall(key: RedisKey, object: object) {
128 | return this.pub_client.hset(key, object);
129 | }
130 |
131 | /**
132 | * Get all the fields and values in a hash
133 | */
134 | public async hgetall(key: RedisKey) {
135 | return this.sub_client.hgetall(key) as Promise;
136 | }
137 |
138 | /**
139 | * Get the value of a hash field
140 | */
141 | public async hget(key: RedisKey, field: string) {
142 | const res = await this.sub_client.hget(key, field);
143 |
144 | if (res) {
145 | return JSON.parse(res) as T;
146 | }
147 |
148 | return null;
149 | }
150 |
151 | /**
152 | * Delete one or more hash fields
153 | */
154 | public async hdel(key: RedisKey, ...fields: string[]) {
155 | return this.pub_client.hdel(key, ...fields);
156 | }
157 |
158 | /**
159 | * Prepend one or multiple values to a list
160 | */
161 | public async lpush(key: RedisKey, value: unknown) {
162 | return this.pub_client.lpush(key, JSON.stringify(value));
163 | }
164 |
165 | /**
166 | * Append one or multiple values to a list
167 | */
168 | public async rpush(key: RedisKey, value: unknown) {
169 | return this.pub_client.rpush(key, JSON.stringify(value));
170 | }
171 |
172 | /**
173 | * Remove and get the first element in a list
174 | * * redis >= 6.2
175 | */
176 | public async lpop(key: RedisKey, count: number) {
177 | const arr = await this.sub_client.lpop(key, count);
178 |
179 | return (arr ?? []).map((i) => JSON.parse(i)) as T[];
180 | }
181 |
182 | /**
183 | * Remove and get the last element in a list
184 | * * redis >= 6.2
185 | */
186 | public async rpop(key: RedisKey, count: number) {
187 | const arr = await this.sub_client.rpop(key, count);
188 |
189 | return (arr ?? []).map((i) => JSON.parse(i)) as T[];
190 | }
191 |
192 | /**
193 | * Get the length of a list
194 | */
195 | public async llen(key: RedisKey) {
196 | return this.sub_client.llen(key);
197 | }
198 |
199 | /**
200 | * Return the index of matching elements on a list
201 | */
202 | public async lpos(key: RedisKey, value: string | number) {
203 | return this.sub_client.lpos(key, value);
204 | }
205 |
206 | /**
207 | * Remove elements from a list
208 | */
209 | public async lrem(key: RedisKey, count: number | string, element: string | Buffer | number) {
210 | return this.sub_client.lrem(key, count, element);
211 | }
212 |
213 | /**
214 | * Get the values of all the given keys
215 | */
216 | public async mget(keys: RedisKey[]) {
217 | const res = await this.sub_client.mget(keys);
218 |
219 | return res.map((data) => {
220 | if (data) {
221 | return JSON.parse(data) as T;
222 | }
223 |
224 | return null;
225 | });
226 | }
227 |
228 | /**
229 | * Set multiple keys to multiple values
230 | */
231 | public async mset(data: RedisKey[]) {
232 | await this.pub_client.mset(data);
233 | }
234 |
235 | /**
236 | * Set a key's time to live in seconds
237 | */
238 | public async expire(key: RedisKey, expire_time_in_seconds: number = REDIS_EXPIRE_TIME_IN_SECONDS) {
239 | return this.sub_client.expire(key, expire_time_in_seconds);
240 | }
241 | }
242 |
--------------------------------------------------------------------------------
/src/repl.module.ts:
--------------------------------------------------------------------------------
1 | import { Module } from '@nestjs/common';
2 | import { ConfigModule } from '@nestjs/config';
3 |
4 | import { ENV_FILE_PATHS, EXPAND_VARIABLES } from './app.env';
5 | import { GraphQLModule } from './graphql/graphql.module';
6 | import { HealthzModule } from './healthz/healthz.module';
7 | import { JwtModule } from './jwt/jwt.module';
8 | import { LoggerModule } from './logger/logger.module';
9 | import { ModelsModule } from './models/models.module';
10 | import { OAuthModule } from './oauth/oauth.module';
11 | import { RabbitMQModule } from './rabbitmq/rabbitmq.module';
12 | import { RedisModule } from './redis/redis.module';
13 | import { TypeOrmModule } from './typeorm/typeorm.module';
14 |
15 | @Module({
16 | imports: [
17 | ConfigModule.forRoot({
18 | isGlobal: true,
19 | expandVariables: EXPAND_VARIABLES,
20 | envFilePath: ENV_FILE_PATHS,
21 | }),
22 | LoggerModule,
23 | HealthzModule,
24 | TypeOrmModule.forRoot(),
25 | RedisModule,
26 | RabbitMQModule.forRoot(),
27 | JwtModule,
28 | GraphQLModule,
29 | OAuthModule,
30 | ModelsModule,
31 | ],
32 | })
33 | export class ReplModule {}
34 |
--------------------------------------------------------------------------------
/src/repl.ts:
--------------------------------------------------------------------------------
1 | import { repl } from '@nestjs/core';
2 |
3 | import { ReplModule } from './repl.module';
4 |
5 | async function bootstrap() {
6 | await repl(ReplModule);
7 | }
8 |
9 | bootstrap();
10 |
--------------------------------------------------------------------------------
/src/typeorm/migrations/.gitkeep:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/tkosminov/nestjs-example/282d3014618c397ded2d42ef2eec77f80110952b/src/typeorm/migrations/.gitkeep
--------------------------------------------------------------------------------
/src/typeorm/migrations/1728545269541-generated.ts:
--------------------------------------------------------------------------------
1 | import { MigrationInterface, QueryRunner } from 'typeorm';
2 |
3 | export class Generated1728545269541 implements MigrationInterface {
4 | name = 'Generated1728545269541';
5 |
6 | public async up(queryRunner: QueryRunner): Promise {
7 | await queryRunner.query(
8 | `CREATE TABLE "recovery_key" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "created_at" TIMESTAMP(3) NOT NULL DEFAULT now(), "updated_at" TIMESTAMP(3) NOT NULL DEFAULT now(), "user_id" uuid NOT NULL, CONSTRAINT "PK_1085f48c9ad9e0228b28e2bab03" PRIMARY KEY ("id"))`
9 | );
10 | await queryRunner.query(`CREATE INDEX "IDX_0d3d5de9983075345e704d7d06" ON "recovery_key" ("user_id") `);
11 | await queryRunner.query(
12 | `CREATE TABLE "refresh_token" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "created_at" TIMESTAMP(3) NOT NULL DEFAULT now(), "updated_at" TIMESTAMP(3) NOT NULL DEFAULT now(), "user_id" uuid NOT NULL, CONSTRAINT "PK_b575dd3c21fb0831013c909e7fe" PRIMARY KEY ("id"))`
13 | );
14 | await queryRunner.query(`CREATE INDEX "IDX_6bbe63d2fe75e7f0ba1710351d" ON "refresh_token" ("user_id") `);
15 | await queryRunner.query(
16 | `CREATE TABLE "user" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "created_at" TIMESTAMP(3) NOT NULL DEFAULT now(), "updated_at" TIMESTAMP(3) NOT NULL DEFAULT now(), "username" character varying NOT NULL, "encrypted_password" character varying NOT NULL, CONSTRAINT "PK_cace4a159ff9f2512dd42373760" PRIMARY KEY ("id"))`
17 | );
18 | await queryRunner.query(`CREATE UNIQUE INDEX "IDX_78a916df40e02a9deb1c4b75ed" ON "user" (lower("username")) `);
19 | await queryRunner.query(
20 | `ALTER TABLE "recovery_key" ADD CONSTRAINT "FK_0d3d5de9983075345e704d7d067" FOREIGN KEY ("user_id") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION`
21 | );
22 | await queryRunner.query(
23 | `ALTER TABLE "refresh_token" ADD CONSTRAINT "FK_6bbe63d2fe75e7f0ba1710351d4" FOREIGN KEY ("user_id") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION`
24 | );
25 | }
26 |
27 | public async down(queryRunner: QueryRunner): Promise {
28 | await queryRunner.query(`ALTER TABLE "refresh_token" DROP CONSTRAINT "FK_6bbe63d2fe75e7f0ba1710351d4"`);
29 | await queryRunner.query(`ALTER TABLE "recovery_key" DROP CONSTRAINT "FK_0d3d5de9983075345e704d7d067"`);
30 | await queryRunner.query(`DROP INDEX "public"."IDX_78a916df40e02a9deb1c4b75ed"`);
31 | await queryRunner.query(`DROP TABLE "user"`);
32 | await queryRunner.query(`DROP INDEX "public"."IDX_6bbe63d2fe75e7f0ba1710351d"`);
33 | await queryRunner.query(`DROP TABLE "refresh_token"`);
34 | await queryRunner.query(`DROP INDEX "public"."IDX_0d3d5de9983075345e704d7d06"`);
35 | await queryRunner.query(`DROP TABLE "recovery_key"`);
36 | }
37 | }
38 |
--------------------------------------------------------------------------------
/src/typeorm/migrations/1729581873271-generated.ts:
--------------------------------------------------------------------------------
1 | import { MigrationInterface, QueryRunner } from 'typeorm';
2 |
3 | export class Generated1729581873271 implements MigrationInterface {
4 | name = 'Generated1729581873271';
5 |
6 | public async up(queryRunner: QueryRunner): Promise {
7 | await queryRunner.query(
8 | `CREATE TABLE "section" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "created_at" TIMESTAMP(3) NOT NULL DEFAULT now(), "updated_at" TIMESTAMP(3) NOT NULL DEFAULT now(), "title" character varying NOT NULL, "book_id" uuid NOT NULL, CONSTRAINT "PK_3c41d2d699384cc5e8eac54777d" PRIMARY KEY ("id"))`
9 | );
10 | await queryRunner.query(`CREATE INDEX "IDX_a1c00fe0a0478cd2e578cc83e0" ON "section" ("book_id") `);
11 | await queryRunner.query(
12 | `CREATE TABLE "book" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "created_at" TIMESTAMP(3) NOT NULL DEFAULT now(), "updated_at" TIMESTAMP(3) NOT NULL DEFAULT now(), "title" character varying NOT NULL, "is_private" boolean NOT NULL DEFAULT false, CONSTRAINT "PK_a3afef72ec8f80e6e5c310b28a4" PRIMARY KEY ("id"))`
13 | );
14 | await queryRunner.query(`CREATE INDEX "IDX_6849f7d295967738326a79d7a4" ON "book" ("is_private") `);
15 | await queryRunner.query(
16 | `ALTER TABLE "section" ADD CONSTRAINT "FK_a1c00fe0a0478cd2e578cc83e02" FOREIGN KEY ("book_id") REFERENCES "book"("id") ON DELETE CASCADE ON UPDATE NO ACTION`
17 | );
18 | }
19 |
20 | public async down(queryRunner: QueryRunner): Promise {
21 | await queryRunner.query(`ALTER TABLE "section" DROP CONSTRAINT "FK_a1c00fe0a0478cd2e578cc83e02"`);
22 | await queryRunner.query(`DROP INDEX "public"."IDX_6849f7d295967738326a79d7a4"`);
23 | await queryRunner.query(`DROP TABLE "book"`);
24 | await queryRunner.query(`DROP INDEX "public"."IDX_a1c00fe0a0478cd2e578cc83e0"`);
25 | await queryRunner.query(`DROP TABLE "section"`);
26 | }
27 | }
28 |
--------------------------------------------------------------------------------
/src/typeorm/typeorm.cli.ts:
--------------------------------------------------------------------------------
1 | import * as dotenv from 'dotenv';
2 | import * as fs from 'fs';
3 | import { DotenvExpandOptions, expand } from 'dotenv-expand';
4 | import { DataSource } from 'typeorm';
5 |
6 | import { ENV_FILE_PATHS, EXPAND_VARIABLES } from '../app.env';
7 |
8 | function loadEnvFile(): Record {
9 | let config: ReturnType = {};
10 |
11 | for (const env_file_path of ENV_FILE_PATHS) {
12 | if (fs.existsSync(env_file_path)) {
13 | config = Object.assign(dotenv.parse(fs.readFileSync(env_file_path)), config);
14 |
15 | if (EXPAND_VARIABLES) {
16 | const expandOptions: DotenvExpandOptions = typeof EXPAND_VARIABLES === 'object' ? EXPAND_VARIABLES : {};
17 | config = expand({ ...expandOptions, parsed: config }).parsed ?? config;
18 | }
19 | }
20 | }
21 | return config;
22 | }
23 |
24 | const config = loadEnvFile();
25 |
26 | export default new DataSource({
27 | type: 'postgres',
28 | host: config.DB_HOST!,
29 | port: parseInt(config.DB_PORT!, 10),
30 | username: config.DB_USERNAME!,
31 | password: config.DB_PASSWORD,
32 | database: `${process.env.NODE_ENV!}_${config.DB_DATABASE!}`,
33 | entities: [__dirname + '/../**/*.entity{.ts,.js}'],
34 | migrations: [__dirname + '/migrations/**/*{.ts,.js}'],
35 | migrationsRun: false,
36 | synchronize: false,
37 | logging: JSON.parse(config.DB_LOGGING!),
38 | });
39 |
--------------------------------------------------------------------------------
/src/typeorm/typeorm.module.ts:
--------------------------------------------------------------------------------
1 | import { DynamicModule, Module } from '@nestjs/common';
2 | import { ConfigService } from '@nestjs/config';
3 | import { TypeOrmModule as NestJSTypeOrmModule, TypeOrmModuleOptions } from '@nestjs/typeorm';
4 | import { DataSourceOptions } from 'typeorm';
5 | import { createDatabase } from 'typeorm-extension';
6 | import { PostgresConnectionOptions } from 'typeorm/driver/postgres/PostgresConnectionOptions';
7 |
8 | @Module({})
9 | export class TypeOrmModule {
10 | public static forRoot(options: Partial = {}): DynamicModule {
11 | return {
12 | imports: [
13 | NestJSTypeOrmModule.forRootAsync({
14 | useFactory: async (config: ConfigService) => {
15 | const default_options: TypeOrmModuleOptions = {
16 | type: 'postgres',
17 | host: config.getOrThrow('DB_HOST'),
18 | port: parseInt(config.getOrThrow('DB_PORT'), 10),
19 | username: config.getOrThrow('DB_USERNAME'),
20 | password: config.getOrThrow('DB_PASSWORD'),
21 | database: `${process.env.NODE_ENV}_${config.getOrThrow('DB_DATABASE')}`,
22 | entities: [__dirname + '/../**/*.entity{.ts,.js}'],
23 | migrations: [__dirname + '/migrations/**/*{.ts,.js}'],
24 | migrationsRun: false,
25 | synchronize: false,
26 | logging: JSON.parse(config.getOrThrow('DB_LOGGING')),
27 | ...options,
28 | };
29 |
30 | await createDatabase({ ifNotExist: true, options: default_options as DataSourceOptions });
31 |
32 | return default_options;
33 | },
34 | inject: [ConfigService],
35 | }),
36 | ],
37 | module: TypeOrmModule,
38 | };
39 | }
40 | }
41 |
--------------------------------------------------------------------------------
/src/upload/graphql/dto/upload.object.ts:
--------------------------------------------------------------------------------
1 | import { Field, ObjectType } from 'nestjs-graphql-easy';
2 |
3 | @ObjectType()
4 | export class UploadDTO {
5 | @Field(() => String, { nullable: false })
6 | public url: string;
7 | }
8 |
--------------------------------------------------------------------------------
/src/upload/graphql/upload-graphql.module.ts:
--------------------------------------------------------------------------------
1 | import { Module } from '@nestjs/common';
2 |
3 | import { UploadGraphQLResolver } from './upload-graphql.resolver';
4 | import { UploadGraphQLService } from './upload-graphql.service';
5 |
6 | @Module({
7 | providers: [UploadGraphQLResolver, UploadGraphQLService],
8 | })
9 | export class UploadGraphQLModule {}
10 |
--------------------------------------------------------------------------------
/src/upload/graphql/upload-graphql.resolver.ts:
--------------------------------------------------------------------------------
1 | import { Args, Context, Resolver } from '@nestjs/graphql';
2 | import { FileUpload, GraphQLUpload } from 'graphql-upload-ts';
3 | import { Mutation } from 'nestjs-graphql-easy';
4 |
5 | import { LoggerStore } from '../../logger/logger.store';
6 | import { IJwtPayload } from '../../oauth/user/user.entity';
7 | import { UploadDTO } from './dto/upload.object';
8 | import { UploadGraphQLService } from './upload-graphql.service';
9 |
10 | @Resolver(() => UploadDTO)
11 | export class UploadGraphQLResolver {
12 | constructor(private readonly upload_service: UploadGraphQLService) {}
13 |
14 | @Mutation(() => UploadDTO)
15 | protected async singleUpload(
16 | @Args({ name: 'file', type: () => GraphQLUpload }) file: FileUpload,
17 | @Context('current_user') current_user: IJwtPayload,
18 | @Context('logger_store') logger_store: LoggerStore
19 | ) {
20 | return this.upload_service.save(file, logger_store, current_user);
21 | }
22 | }
23 |
--------------------------------------------------------------------------------
/src/upload/graphql/upload-graphql.service.ts:
--------------------------------------------------------------------------------
1 | import { Injectable } from '@nestjs/common';
2 | import fs from 'fs';
3 | import { FileUpload } from 'graphql-upload-ts';
4 | import hasha from 'hasha';
5 | import path from 'path';
6 | import { Readable } from 'stream';
7 | import { slugify } from 'transliteration';
8 |
9 | import { LoggerStore } from '../../logger/logger.store';
10 | import { IJwtPayload } from '../../oauth/user/user.entity';
11 | import { UPLOAD_DIR } from '../upload.constants';
12 |
13 | @Injectable()
14 | export class UploadGraphQLService {
15 | public async save(file: FileUpload, _logger_store: LoggerStore, _current_user: IJwtPayload) {
16 | if (!fs.existsSync(UPLOAD_DIR)) {
17 | fs.mkdirSync(UPLOAD_DIR);
18 | }
19 |
20 | const file_stream = file.createReadStream();
21 | const buffer = await this.streamToBuffer(file_stream);
22 |
23 | const hash_sum = hasha(buffer, { algorithm: 'sha256' });
24 |
25 | const file_dir = path.resolve(UPLOAD_DIR, hash_sum);
26 | const file_name = slugify(file.filename.toLowerCase());
27 | const full_path = path.resolve(UPLOAD_DIR, hash_sum, file_name);
28 |
29 | if (!fs.existsSync(file_dir)) {
30 | fs.mkdirSync(file_dir);
31 | }
32 |
33 | await this.saveFile(full_path, buffer);
34 |
35 | return {
36 | url: `/uploads/${hash_sum}/${file_name}`,
37 | };
38 | }
39 |
40 | private async streamToBuffer(stream: Readable): Promise {
41 | const buffer = [];
42 |
43 | return new Promise((resolve, reject) =>
44 | stream
45 | .on('error', (error) => reject(error))
46 | .on('data', (data: never) => buffer.push(data))
47 | .on('end', () => resolve(Buffer.concat(buffer)))
48 | );
49 | }
50 |
51 | private async saveFile(file_path: string, file: Buffer): Promise {
52 | return new Promise((resolve, reject) => {
53 | fs.mkdir(path.dirname(file_path), { recursive: true }, (error_mkdir: Error) => {
54 | if (error_mkdir) {
55 | reject(error_mkdir);
56 | }
57 |
58 | fs.writeFile(file_path, file, (error_write: Error) => {
59 | if (error_write) {
60 | reject(error_write);
61 | }
62 |
63 | resolve();
64 | });
65 | });
66 | });
67 | }
68 | }
69 |
--------------------------------------------------------------------------------
/src/upload/rest/upload-rest.controller.ts:
--------------------------------------------------------------------------------
1 | import { Controller, Post, UploadedFile, UseInterceptors } from '@nestjs/common';
2 | import { FileInterceptor } from '@nestjs/platform-express';
3 |
4 | import { RestLogger } from '../../logger/logger.decorator';
5 | import { LoggerStore } from '../../logger/logger.store';
6 | import { RestCurrentUser } from '../../oauth/oauth.decorator';
7 | import { IJwtPayload } from '../../oauth/user/user.entity';
8 | import { IFile, UploadRestService } from './upload-rest.service';
9 |
10 | @Controller('upload')
11 | export class UploadRestController {
12 | constructor(private readonly upload_service: UploadRestService) {}
13 |
14 | @UseInterceptors(FileInterceptor('file'))
15 | @Post()
16 | public async upload(@UploadedFile() file: IFile, @RestCurrentUser() current_user: IJwtPayload, @RestLogger() logger_store: LoggerStore) {
17 | return await this.upload_service.save(file, logger_store, current_user);
18 | }
19 | }
20 |
--------------------------------------------------------------------------------
/src/upload/rest/upload-rest.module.ts:
--------------------------------------------------------------------------------
1 | import { Module } from '@nestjs/common';
2 |
3 | import { UploadRestController } from './upload-rest.controller';
4 | import { UploadRestService } from './upload-rest.service';
5 |
6 | @Module({
7 | providers: [UploadRestService],
8 | controllers: [UploadRestController],
9 | })
10 | export class UploadRestModule {}
11 |
--------------------------------------------------------------------------------
/src/upload/rest/upload-rest.service.ts:
--------------------------------------------------------------------------------
1 | import { Injectable } from '@nestjs/common';
2 | import fs from 'fs';
3 | import hasha from 'hasha';
4 | import path from 'path';
5 | import { slugify } from 'transliteration';
6 |
7 | import { LoggerStore } from '../../logger/logger.store';
8 | import { IJwtPayload } from '../../oauth/user/user.entity';
9 | import { UPLOAD_DIR } from '../upload.constants';
10 |
11 | export interface IFile {
12 | readonly filename: string;
13 | readonly originalname: string;
14 | readonly encoding?: string;
15 | readonly mimetype: string;
16 | readonly buffer: Buffer;
17 | readonly size: number;
18 | }
19 |
20 | @Injectable()
21 | export class UploadRestService {
22 | public async save(file: IFile, _logger_store: LoggerStore, _current_user: IJwtPayload) {
23 | if (!fs.existsSync(UPLOAD_DIR)) {
24 | fs.mkdirSync(UPLOAD_DIR);
25 | }
26 |
27 | const hash_sum = hasha(file.buffer, { algorithm: 'sha256' });
28 |
29 | const file_dir = path.resolve(UPLOAD_DIR, hash_sum);
30 | const file_name = slugify(file.originalname.toLowerCase());
31 | const full_path = path.resolve(UPLOAD_DIR, hash_sum, file_name);
32 |
33 | if (!fs.existsSync(file_dir)) {
34 | fs.mkdirSync(file_dir);
35 | }
36 |
37 | await this.saveFile(full_path, file.buffer);
38 |
39 | return {
40 | url: `/uploads/${hash_sum}/${file_name}`,
41 | };
42 | }
43 |
44 | private async saveFile(file_path: string, file: Buffer): Promise {
45 | return new Promise((resolve, reject) => {
46 | fs.mkdir(path.dirname(file_path), { recursive: true }, (error_mkdir: Error) => {
47 | if (error_mkdir) {
48 | reject(error_mkdir);
49 | }
50 |
51 | fs.writeFile(file_path, file, (error_write: Error) => {
52 | if (error_write) {
53 | reject(error_write);
54 | }
55 |
56 | resolve();
57 | });
58 | });
59 | });
60 | }
61 | }
62 |
--------------------------------------------------------------------------------
/src/upload/upload.constants.ts:
--------------------------------------------------------------------------------
1 | export const UPLOAD_DIR = __dirname + '/../../uploads';
2 |
--------------------------------------------------------------------------------
/src/upload/upload.module.ts:
--------------------------------------------------------------------------------
1 | import { Module } from '@nestjs/common';
2 |
3 | import { UploadGraphQLModule } from './graphql/upload-graphql.module';
4 | import { UploadRestModule } from './rest/upload-rest.module';
5 |
6 | @Module({
7 | imports: [UploadRestModule, UploadGraphQLModule],
8 | })
9 | export class UploadModule {}
10 |
--------------------------------------------------------------------------------
/src/utils/bcrypt.ts:
--------------------------------------------------------------------------------
1 | import { compareSync, genSaltSync, hashSync } from 'bcryptjs';
2 |
3 | export const passwordToHash = (password: string) => {
4 | const salt = genSaltSync(10);
5 | const hash = hashSync(password, salt);
6 |
7 | return hash;
8 | };
9 |
10 | export const checkPassword = (hash: string, password: string) => {
11 | return compareSync(password, hash);
12 | };
13 |
--------------------------------------------------------------------------------
/src/utils/errors.ts:
--------------------------------------------------------------------------------
1 | import { HttpException, HttpStatus } from '@nestjs/common';
2 |
3 | export const bad_request = (message?: string) =>
4 | new HttpException(
5 | {
6 | status: HttpStatus.BAD_REQUEST,
7 | error: message ?? 'BAD_REQUEST',
8 | },
9 | HttpStatus.BAD_REQUEST
10 | );
11 |
12 | export const unauthorized = (message?: string) =>
13 | new HttpException(
14 | {
15 | status: HttpStatus.UNAUTHORIZED,
16 | error: message ?? 'UNAUTHORIZED',
17 | },
18 | HttpStatus.UNAUTHORIZED
19 | );
20 |
21 | export const authorization_failed = (message?: string) =>
22 | new HttpException(
23 | {
24 | status: HttpStatus.BAD_REQUEST,
25 | error: message ?? 'AUTHORIZATION_FAILED',
26 | },
27 | HttpStatus.BAD_REQUEST
28 | );
29 |
30 | export const access_token_expired_signature = (message?: string) =>
31 | new HttpException(
32 | {
33 | status: HttpStatus.FORBIDDEN,
34 | error: message ?? 'ACCESS_TOKEN_EXPIRED',
35 | },
36 | HttpStatus.FORBIDDEN
37 | );
38 |
39 | export const refresh_token_expired_signature = (message?: string) =>
40 | new HttpException(
41 | {
42 | status: 419,
43 | error: message ?? 'REFRESH_TOKEN_EXPIRED',
44 | },
45 | 419
46 | );
47 |
--------------------------------------------------------------------------------
/src/utils/promise.ts:
--------------------------------------------------------------------------------
1 | export async function sleep(ms: number) {
2 | return new Promise((resolve) => setTimeout(resolve, ms));
3 | }
4 |
--------------------------------------------------------------------------------
/src/utils/random.ts:
--------------------------------------------------------------------------------
1 | export function randomInteger(min: number, max: number) {
2 | const rand = min - 0.5 + Math.random() * (max - min + 1);
3 |
4 | return Math.round(rand);
5 | }
6 |
--------------------------------------------------------------------------------
/src/utils/request.ts:
--------------------------------------------------------------------------------
1 | import { Request, Response } from 'express';
2 |
3 | export function getIp(req: Request): string {
4 | return req.ip ?? '-';
5 | }
6 |
7 | export function getForwardedIp(req: Request): string {
8 | let ips = req.get('X-Forwarded-For');
9 |
10 | if (ips?.length) {
11 | ips = ips.split(', ')[0];
12 | }
13 |
14 | return ips ?? '-';
15 | }
16 |
17 | export function getUrl(req: Request): string {
18 | return req.originalUrl || req.url || req.baseUrl || '-';
19 | }
20 |
21 | export function getPath(req: Request): string {
22 | return getUrl(req).split('?')[0];
23 | }
24 |
25 | export function getAction(req: Request): string {
26 | return getUrl(req).split('/').reverse()[0];
27 | }
28 |
29 | export function getHttpVersion(req: Request): string {
30 | return req.httpVersionMajor + '.' + req.httpVersionMinor;
31 | }
32 |
33 | export function getResponseHeader(res: Response, field: string) {
34 | if (!res.headersSent) {
35 | return undefined;
36 | }
37 |
38 | const header = res.getHeader(field);
39 |
40 | return Array.isArray(header) ? header.join(', ') : (header ?? '-');
41 | }
42 |
43 | export function getReferrer(req: Request) {
44 | let referrer = req.headers.referer ?? req.headers.referrer;
45 |
46 | if (Array.isArray(referrer)) {
47 | referrer = referrer[0];
48 | }
49 |
50 | return (referrer ?? '-').split('?')[0];
51 | }
52 |
53 | export function getOrigin(req: Request) {
54 | const origin = req.headers.origin;
55 |
56 | if (!origin || typeof origin === 'string') {
57 | return origin!;
58 | }
59 |
60 | return origin[0];
61 | }
62 |
63 | export function getMethod(req: Request) {
64 | return req.method;
65 | }
66 |
67 | export function getRequestHeader(req: Request, field: string) {
68 | return req.headers[field] as string;
69 | }
70 |
71 | export function getUserAgent(req: Request) {
72 | return getRequestHeader(req, 'user-agent') ?? '-';
73 | }
74 |
75 | export function getCookie(cookies: string, key: string): string | null {
76 | let value: string | null = null;
77 |
78 | cookies.split(';').forEach((cookie) => {
79 | if (cookie.trim().startsWith(`${key}=`)) {
80 | value = cookie.split('=')[1];
81 | }
82 | });
83 |
84 | return value;
85 | }
86 |
--------------------------------------------------------------------------------
/src/utils/validate.ts:
--------------------------------------------------------------------------------
1 | import { ClassConstructor, plainToClass } from 'class-transformer';
2 | import { validateSync, ValidationError } from 'class-validator';
3 |
4 | import { bad_request } from './errors';
5 |
6 | export function validateDTO(type: ClassConstructor, value: unknown, skip_missing_properties = true) {
7 | const errors: ValidationError[] = validateSync(plainToClass(type, value) as object, { skipMissingProperties: skip_missing_properties });
8 |
9 | if (errors.length > 0) {
10 | const msg = errors.map((error) => Object.values(error.constraints ?? {})).join(', ');
11 |
12 | throw bad_request(msg);
13 | }
14 | }
15 |
--------------------------------------------------------------------------------
/src/ws/ws.adapter.ts:
--------------------------------------------------------------------------------
1 | import { INestApplication } from '@nestjs/common';
2 | import { ConfigService } from '@nestjs/config';
3 | import { IoAdapter } from '@nestjs/platform-socket.io';
4 | import { createAdapter } from '@socket.io/redis-adapter';
5 | import { Server, ServerOptions } from 'socket.io';
6 |
7 | import { CorsMiddleware } from '../cors/cors.middleware';
8 | import { JwtService } from '../jwt/jwt.service';
9 | import { IAccessToken } from '../oauth/oauth.service';
10 | import { RedisService } from '../redis/redis.service';
11 | import { access_token_expired_signature, authorization_failed, unauthorized } from '../utils/errors';
12 | import { getCookie } from '../utils/request';
13 |
14 | export class WsAdapter extends IoAdapter {
15 | constructor(
16 | app: INestApplication,
17 | private readonly config: ConfigService,
18 | private readonly redis: RedisService,
19 | private readonly jwt: JwtService
20 | ) {
21 | super(app);
22 | }
23 |
24 | public createIOServer(port: number, options?: ServerOptions): Server {
25 | const WS_PORT = parseInt(this.config.getOrThrow('WS_PORT'), 10);
26 | const WS_PING_INTERVAL = parseInt(this.config.getOrThrow('WS_PING_INTERVAL'), 10);
27 | const WS_PING_TIMEOUT = parseInt(this.config.getOrThrow('WS_PING_TIMEOUT'), 10);
28 | const WS_PATH = this.config.getOrThrow('WS_PATH');
29 |
30 | const server: Server = super.createIOServer(port || WS_PORT, {
31 | pingInterval: WS_PING_INTERVAL,
32 | pingTimeout: WS_PING_TIMEOUT,
33 | path: WS_PATH,
34 | ...options,
35 | cors: CorsMiddleware({
36 | allowed_origins: JSON.parse(this.config.getOrThrow('CORS_ALLOWED_ORIGINS')),
37 | allowed_methods: JSON.parse(this.config.getOrThrow('CORS_ALLOWED_METHODS')),
38 | allowed_paths: JSON.parse(this.config.getOrThrow('CORS_ALLOWED_PATHS')),
39 | credentials: this.config.getOrThrow('CORS_CREDENTIALS'),
40 | }),
41 | allowEIO3: true,
42 | });
43 |
44 | server.adapter(
45 | createAdapter(this.redis.pub_client, this.redis.sub_client, {
46 | key: this.config.getOrThrow('REDIS_KEY'),
47 | })
48 | );
49 |
50 | server.use((socket, next) => {
51 | const access_token = socket.handshake.auth.token || getCookie(socket.handshake.headers.cookie ?? '', 'access_token');
52 |
53 | if (access_token?.length) {
54 | try {
55 | const { token_type } = this.jwt.verify(access_token);
56 |
57 | if (token_type !== 'access') {
58 | return next(authorization_failed());
59 | }
60 |
61 | return next();
62 | } catch (e) {
63 | return next(access_token_expired_signature());
64 | }
65 | }
66 |
67 | return next(unauthorized());
68 | });
69 |
70 | return server;
71 | }
72 | }
73 |
--------------------------------------------------------------------------------
/src/ws/ws.gateway.ts:
--------------------------------------------------------------------------------
1 | import { OnGatewayConnection, OnGatewayDisconnect, WebSocketGateway, WebSocketServer } from '@nestjs/websockets';
2 | import io from 'socket.io';
3 |
4 | @WebSocketGateway()
5 | export class WsGateway implements OnGatewayConnection, OnGatewayDisconnect {
6 | @WebSocketServer()
7 | public server: io.Server;
8 |
9 | public clients = new Map();
10 |
11 | private getClientQuery(client: io.Socket): Record {
12 | return client.handshake.query;
13 | }
14 |
15 | public broadcastAll(event_name: string, message: Record) {
16 | this.server.emit(event_name, message);
17 | }
18 |
19 | public async handleConnection(client: io.Socket) {
20 | const { user_id } = this.getClientQuery(client);
21 |
22 | this.clients.set(user_id, client);
23 |
24 | return this.broadcastAll('event', { connected: user_id });
25 | }
26 |
27 | public async handleDisconnect(client: io.Socket) {
28 | const { user_id } = this.getClientQuery(client);
29 |
30 | this.clients.delete(user_id);
31 |
32 | return this.broadcastAll('event', { disconnected: user_id });
33 | }
34 | }
35 |
--------------------------------------------------------------------------------
/src/ws/ws.module.ts:
--------------------------------------------------------------------------------
1 | import { Module } from '@nestjs/common';
2 |
3 | import { WsGateway } from './ws.gateway';
4 |
5 | @Module({
6 | providers: [WsGateway],
7 | exports: [WsGateway],
8 | })
9 | export class WsModule {}
10 |
--------------------------------------------------------------------------------
/tsconfig.build.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "./tsconfig.json",
3 | "compilerOptions": {
4 | "outDir": "dist",
5 | "rootDir": "./src",
6 | },
7 | "include": ["./src"],
8 | "exclude": [
9 | "node_modules",
10 | "dist",
11 | "**/*spec.ts",
12 | "**/*/schema.ts"
13 | ]
14 | }
15 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "module": "CommonJS",
4 | "target": "ESNext",
5 | "moduleResolution": "node",
6 | "esModuleInterop": true,
7 | "declaration": true,
8 | "removeComments": true,
9 |
10 | "composite": false,
11 | "incremental": false,
12 | "sourceMap": true,
13 |
14 | "skipLibCheck": true,
15 | "noUnusedLocals": true,
16 | "noUnusedParameters": true,
17 | "noImplicitAny": false,
18 | "strictNullChecks": true,
19 |
20 | "emitDecoratorMetadata": true,
21 | "experimentalDecorators": true,
22 | "allowSyntheticDefaultImports": true,
23 | "strictBindCallApply": false,
24 | "forceConsistentCasingInFileNames": false,
25 | "noFallthroughCasesInSwitch": false,
26 |
27 | "rootDir": "./",
28 | "typeRoots": [
29 | "./node_modules/@types",
30 | ],
31 | },
32 | }
33 |
--------------------------------------------------------------------------------
/uploads/.gitkeep:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/tkosminov/nestjs-example/282d3014618c397ded2d42ef2eec77f80110952b/uploads/.gitkeep
--------------------------------------------------------------------------------