├── .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 |
16 |
17 |
18 |
19 | 20 | 21 |
22 |
23 | 24 |
25 |
26 |
27 |
28 |
Result:
29 |
30 |
31 |
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 |
16 |
17 |
18 |
19 | 20 | 21 |
22 |
23 | 24 |
25 |
26 |
27 |
28 |
Result:
29 |
30 |
31 |
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 --------------------------------------------------------------------------------