├── .docker.env ├── .dockerignore ├── .env ├── .eslintrc.js ├── .gitignore ├── .prettierrc ├── Dockerfile ├── README.md ├── docker-compose.yml ├── docker └── nginx │ └── templates │ └── default.conf.conf ├── nest-cli.json ├── nginx.conf ├── nodemon.json ├── package.json ├── src ├── app.controller.ts ├── app.module.ts ├── core │ ├── adapter │ │ ├── custom-socket-io.adapter.ts │ │ ├── external-socket-io-adapter.ts │ │ └── redis-io.adapter.ts │ ├── core.module.ts │ ├── filter │ │ └── exceptions.filter.ts │ └── mail │ │ ├── config │ │ └── mailer.config.ts │ │ ├── mail.module.ts │ │ └── template │ │ └── recover.hbs ├── environments │ └── environments.ts ├── features │ ├── auth │ │ ├── auth.module.ts │ │ ├── config │ │ │ ├── auth-config.default.ts │ │ │ └── auth.config.ts │ │ ├── controller │ │ │ └── auth.controller.ts │ │ ├── decorators │ │ │ ├── auth-not-required.decorator.ts │ │ │ └── current-user.decorator.ts │ │ ├── dto │ │ │ ├── apple-login.dto.ts │ │ │ ├── login.dto.ts │ │ │ └── register.dto.ts │ │ ├── guard │ │ │ └── jwt-auth.guard.ts │ │ └── service │ │ │ ├── apple-auth.service.ts │ │ │ ├── auth.service.ts │ │ │ └── google-auth.service.ts │ ├── features.module.ts │ ├── messages │ │ ├── controller │ │ │ └── message.controller.ts │ │ ├── dto │ │ │ ├── delete-direct-message.dto.ts │ │ │ ├── delete-room-message.dto.ts │ │ │ ├── direct-message.dto.ts │ │ │ ├── fetch-messages.dto.ts │ │ │ └── room-message.dto.ts │ │ ├── gateway │ │ │ └── message.gateway.ts │ │ ├── messages.module.ts │ │ ├── schema │ │ │ └── message.schema.ts │ │ └── service │ │ │ └── message.service.ts │ ├── notification │ │ ├── api │ │ │ └── firebase.ts │ │ ├── config │ │ │ └── notification.config.ts │ │ ├── controller │ │ │ └── notification.controller.ts │ │ ├── notification.module.ts │ │ └── service │ │ │ ├── mobile-notification.service.ts │ │ │ └── web-notification.service.ts │ ├── room │ │ ├── controller │ │ │ └── room.controller.ts │ │ ├── dto │ │ │ └── room.dto.ts │ │ ├── gateway │ │ │ └── room.gateway.ts │ │ ├── room.module.ts │ │ ├── schema │ │ │ └── room.schema.ts │ │ └── service │ │ │ └── room.service.ts │ └── user │ │ ├── controller │ │ ├── recover.controller.ts │ │ ├── settings.controller.ts │ │ ├── subscription.controller.ts │ │ └── user.controller.ts │ │ ├── dto │ │ ├── recover-password.dto.ts │ │ ├── update-email.dto.ts │ │ ├── update-password.dto.ts │ │ └── update-username.dto.ts │ │ ├── gateway │ │ └── user.gateway.ts │ │ ├── schema │ │ ├── recover.schema.ts │ │ ├── socket-connection.schema.ts │ │ ├── subscription.schema.ts │ │ └── user.schema.ts │ │ ├── service │ │ ├── recover.service.ts │ │ ├── socket-connection.service.ts │ │ ├── subscription.service.ts │ │ └── user.service.ts │ │ └── user.module.ts ├── main.ts └── shared │ ├── constants │ └── paths.ts │ ├── mongoose │ ├── create-schema.ts │ └── object-id.ts │ ├── pipe │ └── parse-object-id.pipe.ts │ ├── shared.module.ts │ └── utils │ ├── get-address.ts │ ├── get-client.ts │ ├── get-request.ts │ ├── get-socket-client.ts │ ├── get-socket-user.ts │ ├── get-url.ts │ ├── random-string.ts │ └── remove.ts ├── tsconfig.build.json ├── tsconfig.json └── yarn.lock /.docker.env: -------------------------------------------------------------------------------- 1 | PROXY_ENABLED=true 2 | 3 | PORT=3000 4 | 5 | ACCESS_TOKEN_SECRET=ghfjbdnsfmaddnbjhdsdgfnadivsbeaiwfagshshagfdwGHTJ 6 | ACCESS_TOKEN_EXPIRATION=3600s 7 | 8 | REFRESH_TOKEN_SECRET=rtrtffjrteyrjfgncvbxdshrdjhfdggsethfethdgsdssfggzb 9 | REFRESH_TOKEN_EXPIRATION=1y 10 | 11 | MONGO_URI=mongodb://mongo:27017/auth 12 | FRONTEND_URL=http://localhost:4200 13 | 14 | RECOVER_CODE_EXPIRATION=86400 15 | 16 | REDIS_ENABLED=true 17 | REDIS_HOST=redis 18 | REDIS_PORT=6379 -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | # compiled output 2 | /dist 3 | /node_modules 4 | 5 | # Logs 6 | logs 7 | *.log 8 | npm-debug.log* 9 | yarn-debug.log* 10 | yarn-error.log* 11 | lerna-debug.log* 12 | 13 | # OS 14 | .DS_Store 15 | 16 | # Tests 17 | /coverage 18 | /.nyc_output 19 | 20 | # IDEs and editors 21 | /.idea 22 | .project 23 | .classpath 24 | .c9/ 25 | *.launch 26 | .settings/ 27 | *.sublime-workspace 28 | 29 | # IDE - VSCode 30 | .vscode/* 31 | !.vscode/settings.json 32 | !.vscode/tasks.json 33 | !.vscode/launch.json 34 | !.vscode/extensions.json 35 | 36 | /docker/volumes -------------------------------------------------------------------------------- /.env: -------------------------------------------------------------------------------- 1 | PROXY_ENABLED=false 2 | 3 | PORT=3000 4 | 5 | ACCESS_TOKEN_SECRET=ghfjbdnsfmaddnbjhdsdgfnadivsbeaiwfagshshagfdwGHTJ 6 | ACCESS_TOKEN_EXPIRATION=3600s 7 | 8 | REFRESH_TOKEN_SECRET=rtrtffjrteyrjfgncvbxdshrdjhfdggsethfethdgsdssfggzb 9 | REFRESH_TOKEN_EXPIRATION=1y 10 | 11 | MONGO_URI=mongodb://localhost/auth 12 | FRONTEND_URL=http://localhost:8080 13 | 14 | RECOVER_CODE_EXPIRATION=86400 15 | 16 | REDIS_ENABLED=false 17 | REDIS_HOST=localhost 18 | REDIS_PORT=6379 19 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | parser: '@typescript-eslint/parser', 3 | parserOptions: { 4 | project: 'tsconfig.json', 5 | sourceType: 'module', 6 | }, 7 | plugins: ['@typescript-eslint/eslint-plugin'], 8 | extends: [ 9 | 'plugin:@typescript-eslint/eslint-recommended', 10 | 'plugin:@typescript-eslint/recommended', 11 | 'prettier', 12 | 'prettier/@typescript-eslint', 13 | ], 14 | root: true, 15 | env: { 16 | node: true, 17 | jest: true, 18 | }, 19 | rules: { 20 | '@typescript-eslint/interface-name-prefix': 'off', 21 | '@typescript-eslint/explicit-function-return-type': 'off', 22 | '@typescript-eslint/explicit-module-boundary-types': 'off', 23 | '@typescript-eslint/no-explicit-any': 'off', 24 | }, 25 | }; 26 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # compiled output 2 | /dist 3 | /node_modules 4 | 5 | # Logs 6 | logs 7 | *.log 8 | npm-debug.log* 9 | yarn-debug.log* 10 | yarn-error.log* 11 | lerna-debug.log* 12 | 13 | # OS 14 | .DS_Store 15 | 16 | # Tests 17 | /coverage 18 | /.nyc_output 19 | 20 | # IDEs and editors 21 | /.idea 22 | .project 23 | .classpath 24 | .c9/ 25 | *.launch 26 | .settings/ 27 | *.sublime-workspace 28 | 29 | # IDE - VSCode 30 | .vscode/* 31 | !.vscode/settings.json 32 | !.vscode/tasks.json 33 | !.vscode/launch.json 34 | !.vscode/extensions.json 35 | 36 | /config/*.config.json 37 | /secrets/ 38 | 39 | /docker/volumes -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "bracketSpacing": true, 3 | "singleQuote": true, 4 | "printWidth": 100, 5 | "trailingComma": "all", 6 | "tabWidth": 2, 7 | "arrowParens": "avoid", 8 | "overrides": [ 9 | { 10 | "files": "**/*.md", 11 | "options": { 12 | "parser": "markdown", 13 | "proseWrap": "always" 14 | } 15 | } 16 | ] 17 | } 18 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:latest 2 | 3 | ENV PORT=3000 4 | ENV TZ=America/New_York 5 | 6 | WORKDIR /app 7 | COPY package.json /app/ 8 | RUN yarn 9 | COPY . /app/ 10 | COPY .docker.env /app/.env 11 | CMD ["yarn", "run", "start"] 12 | EXPOSE ${PORT} -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## NestJS authentication with MongoDB, WebSocket and JWT (Real Time Messages, Login, Register, Google Login, Facebook Login, Apple Login) 2 | 3 | To use this server, get the [Angular Client](https://github.com/DenzelCode/nest-angular-auth-client) or [Flutter App](https://github.com/DenzelCode/flutter-auth) up and running. 4 | 5 | You can also use this API using Postman or any other request framework. 6 | 7 | Try it out now! [Demo](https://nest-auth.ubbly.club/) 8 | 9 | # Features 10 | - Login 11 | - Register 12 | - Google Login 13 | - Facebook Login 14 | - Apple Login 15 | - WebSockets authentication 16 | - Http authentication 17 | - Websockets real-time chat. 18 | - Rooms chats 19 | - Private DMs 20 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3.9' 2 | services: 3 | app: 4 | build: . 5 | deploy: 6 | replicas: 5 7 | restart_policy: 8 | max_attempts: 3 9 | condition: on-failure 10 | update_config: 11 | parallelism: 3 12 | delay: 10s 13 | depends_on: 14 | - mongo 15 | - redis 16 | volumes: 17 | - ./config:/app/config 18 | environment: 19 | PORT: 3000 20 | VAPID_SUBJECT: 'mailto:denzelcodedev@gmail.com' 21 | VAPID_PRIVATE_KEY: '' 22 | VAPID_PUBLIC_KEY: '' 23 | nginx: 24 | image: nginx 25 | ports: 26 | - '3000:80' 27 | depends_on: 28 | - app 29 | environment: 30 | NGINX_ENVSUBST_TEMPLATE_SUFFIX: '.conf' 31 | API_HOST: app 32 | API_PORT: 3000 33 | deploy: 34 | placement: 35 | constraints: [ node.role == manager ] 36 | volumes: 37 | - ./docker/nginx/templates:/etc/nginx/templates/ 38 | mongo: 39 | image: mongo 40 | ports: 41 | - '27018:27017' 42 | volumes: 43 | - ./docker/volumes/mongodb_data_container:/data/db 44 | redis: 45 | image: redis 46 | ports: 47 | - '6379:6379' 48 | -------------------------------------------------------------------------------- /docker/nginx/templates/default.conf.conf: -------------------------------------------------------------------------------- 1 | map $proxy_add_x_forwarded_for $client_ip { 2 | "~(?([0-9]{1,3}\.){3}[0-9]{1,3}),.*" $IP; 3 | } 4 | 5 | map $client_ip $client_real_ip { 6 | '' $remote_addr; 7 | default $client_ip; 8 | } 9 | 10 | upstream loadbalance { 11 | least_conn; 12 | server ${API_HOST}:${API_PORT}; 13 | } 14 | 15 | upstream hashloadbalance { 16 | hash $client_real_ip; 17 | server ${API_HOST}:${API_PORT}; 18 | } 19 | 20 | server { 21 | location / { 22 | proxy_set_header X-Real-IP $client_real_ip; 23 | proxy_set_header X-Forwarded-For $client_real_ip; 24 | proxy_set_header Host $http_host; 25 | proxy_set_header X-NginX-Proxy true; 26 | proxy_set_header X-Forwarded-Proto $scheme; 27 | proxy_pass http://loadbalance; 28 | } 29 | 30 | location /test { 31 | proxy_set_header X-Real-IP $client_real_ip; 32 | proxy_set_header X-Forwarded-For $client_real_ip; 33 | proxy_set_header Host $http_host; 34 | proxy_set_header X-NginX-Proxy true; 35 | proxy_set_header X-Forwarded-Proto $scheme; 36 | proxy_pass http://hashloadbalance; 37 | } 38 | 39 | location ~* \.io { 40 | proxy_set_header X-Real-IP $client_real_ip; 41 | proxy_set_header X-Forwarded-For $client_real_ip; 42 | proxy_set_header X-NginX-Proxy true; 43 | proxy_set_header X-Forwarded-Proto $scheme; 44 | proxy_set_header Upgrade $http_upgrade; 45 | proxy_set_header Connection "upgrade"; 46 | proxy_pass http://hashloadbalance; 47 | proxy_ssl_session_reuse off; 48 | proxy_set_header Host $http_host; 49 | proxy_cache_bypass $http_upgrade; 50 | proxy_redirect off; 51 | } 52 | } -------------------------------------------------------------------------------- /nest-cli.json: -------------------------------------------------------------------------------- 1 | { 2 | "collection": "@nestjs/schematics", 3 | "sourceRoot": "src", 4 | "compilerOptions": { 5 | "assets": ["**/*.hbs"], 6 | "watchAssets": false 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /nginx.conf: -------------------------------------------------------------------------------- 1 | # Replace /opt/nest-auth/front-end for your front-end path. 2 | # Replace 3 | server { 4 | charset utf-8; 5 | server_name nest-auth.ubbly.club; 6 | listen 443 ssl; 7 | listen 80; 8 | ssl_certificate nest-auth.ubbly.club.crt; 9 | ssl_certificate_key nest-auth.ubbly.club.key; 10 | 11 | location / { 12 | root /opt/nest-auth/front-end; 13 | try_files $uri /index.html; 14 | } 15 | 16 | location /api/ { 17 | proxy_set_header X-Real-IP $remote_addr; 18 | proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; 19 | proxy_set_header X-NginX-Proxy true; 20 | proxy_pass http://localhost:3000/; 21 | } 22 | 23 | location ~* \.io { 24 | proxy_set_header X-Real-IP $remote_addr; 25 | proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; 26 | proxy_set_header X-NginX-Proxy true; 27 | proxy_set_header X-Forwarded-Proto $scheme; 28 | proxy_set_header Upgrade $http_upgrade; 29 | proxy_set_header Connection "upgrade"; 30 | proxy_pass http://127.0.0.1:3000; 31 | proxy_ssl_session_reuse off; 32 | proxy_set_header Host $http_host; 33 | proxy_cache_bypass $http_upgrade; 34 | proxy_redirect off; 35 | } 36 | } -------------------------------------------------------------------------------- /nodemon.json: -------------------------------------------------------------------------------- 1 | { 2 | "watch": ["src"], 3 | "ext": "ts, hbs, env, json", 4 | "ignore": ["src/**/*.spec.ts", "config/**.json"], 5 | "exec": "yarn run start" 6 | } 7 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "auth", 3 | "version": "1.0.0", 4 | "description": "NestJS authentication", 5 | "author": "Denzel Giraldo", 6 | "private": true, 7 | "license": "MIT", 8 | "scripts": { 9 | "prebuild": "rimraf dist", 10 | "build": "nest build", 11 | "format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"", 12 | "start": "node -r tsconfig-paths/register -r ts-node/register src/main.ts", 13 | "start:dev": "nodemon", 14 | "start:debug": "nest start --debug --watch", 15 | "start:prod": "node dist/main", 16 | "web-push-keys": "web-push generate-vapid-keys", 17 | "docker:build": "docker-compose build", 18 | "docker:up": "docker-compose up", 19 | "docker:down": "docker-compose down", 20 | "docker": "npm run docker:build; npm run docker:up", 21 | "lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix", 22 | "test": "jest", 23 | "test:watch": "jest --watch", 24 | "test:cov": "jest --coverage", 25 | "test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand", 26 | "test:e2e": "jest --config ./test/jest-e2e.json" 27 | }, 28 | "dependencies": { 29 | "@nestjs-modules/mailer": "^1.6.0", 30 | "@nestjs/common": "^7.0.0", 31 | "@nestjs/config": "^0.6.3", 32 | "@nestjs/core": "^7.0.0", 33 | "@nestjs/jwt": "^7.2.0", 34 | "@nestjs/mongoose": "^7.2.4", 35 | "@nestjs/platform-express": "^7.0.0", 36 | "@nestjs/platform-socket.io": "^7.6.15", 37 | "@nestjs/websockets": "^7.6.15", 38 | "apple-signin-auth": "^1.5.1", 39 | "bcrypt": "^5.0.1", 40 | "class-transformer": "^0.5.1", 41 | "class-validator": "^0.13.2", 42 | "code-config": "^1.0.37", 43 | "dotenv": "^10.0.0", 44 | "facebook-auth-nestjs": "^0.0.3", 45 | "firebase-admin": "^9.11.0", 46 | "googleapis": "^82.0.0", 47 | "handlebars": "^4.7.7", 48 | "mongoose": "^5.12.2", 49 | "nodemailer": "^6.6.1", 50 | "qs": "^6.10.1", 51 | "redis": "^3.1.2", 52 | "reflect-metadata": "^0.1.13", 53 | "rimraf": "^3.0.2", 54 | "rxjs": "^6.5.4", 55 | "socket.io": "^4.1.3", 56 | "socket.io-redis": "^6.1.1", 57 | "web-push": "^3.4.5" 58 | }, 59 | "devDependencies": { 60 | "@nestjs/cli": "^7.0.0", 61 | "@nestjs/schematics": "^7.0.0", 62 | "@nestjs/testing": "^7.0.0", 63 | "@types/bcrypt": "^3.0.0", 64 | "@types/express": "^4.17.3", 65 | "@types/jest": "26.0.10", 66 | "@types/node": "^13.9.1", 67 | "@types/passport": "^1.0.6", 68 | "@types/passport-jwt": "^3.0.5", 69 | "@types/passport-local": "^1.0.33", 70 | "@types/qs": "^6.9.7", 71 | "@types/redis": "^2.8.31", 72 | "@types/socket.io": "^2.1.13", 73 | "@types/supertest": "^2.0.8", 74 | "@types/web-push": "^3.3.2", 75 | "@typescript-eslint/eslint-plugin": "3.9.1", 76 | "@typescript-eslint/parser": "3.9.1", 77 | "eslint": "7.7.0", 78 | "eslint-config-prettier": "^6.10.0", 79 | "eslint-plugin-import": "^2.20.1", 80 | "jest": "26.4.2", 81 | "nodemon": "^2.0.7", 82 | "prettier": "^1.19.1", 83 | "supertest": "^4.0.2", 84 | "ts-jest": "26.2.0", 85 | "ts-loader": "^6.2.1", 86 | "ts-node": "^9.1.1", 87 | "tsconfig-paths": "^3.9.0", 88 | "typescript": "^3.7.4" 89 | }, 90 | "jest": { 91 | "moduleFileExtensions": [ 92 | "js", 93 | "json", 94 | "ts" 95 | ], 96 | "rootDir": "src", 97 | "testRegex": ".spec.ts$", 98 | "transform": { 99 | "^.+\\.(t|j)s$": "ts-jest" 100 | }, 101 | "coverageDirectory": "../coverage", 102 | "testEnvironment": "node" 103 | } 104 | } 105 | -------------------------------------------------------------------------------- /src/app.controller.ts: -------------------------------------------------------------------------------- 1 | import { Controller, Get, Req } from '@nestjs/common'; 2 | import { Request } from 'express'; 3 | import { hostname } from 'os'; 4 | 5 | @Controller() 6 | export class AppController { 7 | @Get() 8 | main(@Req() req: Request) { 9 | return this.getContainer(req); 10 | } 11 | 12 | @Get('test') 13 | test(@Req() req: Request) { 14 | return this.getContainer(req); 15 | } 16 | 17 | private getContainer(req: Request) { 18 | return { 19 | ip: req.ip, 20 | forwardedFor: req.headers['x-forwarded-for'], 21 | realIp: req.headers['x-real-ip'], 22 | container: hostname(), 23 | }; 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/app.module.ts: -------------------------------------------------------------------------------- 1 | import { FeaturesModule } from './features/features.module'; 2 | import { CoreModule } from './core/core.module'; 3 | import { Module, ValidationPipe } from '@nestjs/common'; 4 | import { ConfigModule } from '@nestjs/config'; 5 | import { AppController } from './app.controller'; 6 | import { MongooseModule } from '@nestjs/mongoose'; 7 | import { APP_FILTER, APP_PIPE } from '@nestjs/core'; 8 | import { ExceptionsFilter } from './core/filter/exceptions.filter'; 9 | import { environments } from './environments/environments'; 10 | 11 | @Module({ 12 | imports: [ 13 | FeaturesModule, 14 | CoreModule, 15 | ConfigModule.forRoot(), 16 | MongooseModule.forRoot(environments.mongoUri, { 17 | autoIndex: false, 18 | useFindAndModify: false, 19 | }), 20 | ], 21 | providers: [ 22 | { 23 | provide: APP_PIPE, 24 | useValue: new ValidationPipe({ transform: true }), 25 | }, 26 | { 27 | provide: APP_FILTER, 28 | useClass: ExceptionsFilter, 29 | }, 30 | ], 31 | controllers: [AppController], 32 | }) 33 | export class AppModule {} 34 | -------------------------------------------------------------------------------- /src/core/adapter/custom-socket-io.adapter.ts: -------------------------------------------------------------------------------- 1 | import { Server, ServerOptions } from 'socket.io'; 2 | import { getAddress } from '../../shared/utils/get-address'; 3 | import { ExternalSocketIoAdapter } from './external-socket-io-adapter'; 4 | 5 | export class CustomSocketIoAdapter extends ExternalSocketIoAdapter { 6 | createIOServer(port: number, options?: ServerOptions) { 7 | const server = super.createIOServer(port, options) as Server; 8 | 9 | server.use((socket, next) => { 10 | socket.handshake.address = getAddress(socket); 11 | 12 | next(); 13 | }); 14 | 15 | return server; 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/core/adapter/external-socket-io-adapter.ts: -------------------------------------------------------------------------------- 1 | import { INestApplicationContext } from '@nestjs/common'; 2 | import { isFunction, isNil } from '@nestjs/common/utils/shared.utils'; 3 | import { AbstractWsAdapter, MessageMappingProperties } from '@nestjs/websockets'; 4 | import { DISCONNECT_EVENT } from '@nestjs/websockets/constants'; 5 | import { fromEvent } from 'rxjs'; 6 | import { filter, first, map, mergeMap, share, takeUntil } from 'rxjs/operators'; 7 | import { Server } from 'socket.io'; 8 | 9 | export class ExternalSocketIoAdapter extends AbstractWsAdapter { 10 | constructor(app?: INestApplicationContext, private readonly cors: string | string[] = '*') { 11 | super(app); 12 | } 13 | 14 | public create(port: number, options?: any & { namespace?: string; server?: any }): any { 15 | if (!options) { 16 | return this.createIOServer(port); 17 | } 18 | const { namespace, server, ...opt } = options; 19 | return server && isFunction(server.of) 20 | ? server.of(namespace) 21 | : namespace 22 | ? this.createIOServer(port, opt).of(namespace) 23 | : this.createIOServer(port, opt); 24 | } 25 | 26 | public createIOServer(port: number, options?: any): any { 27 | if (this.httpServer && port === 0) { 28 | const s = new Server(this.httpServer, { 29 | cors: { 30 | origin: this.cors, 31 | methods: ['GET', 'POST'], 32 | credentials: true, 33 | }, 34 | cookie: { 35 | httpOnly: true, 36 | path: '/', 37 | } as any, 38 | // Allow 1MB of data per request. 39 | maxHttpBufferSize: 1e6, 40 | }); 41 | 42 | return s; 43 | } 44 | return new Server(port, options); 45 | } 46 | 47 | public bindMessageHandlers(client: any, handlers: MessageMappingProperties[], transform: any) { 48 | const disconnect$ = fromEvent(client, DISCONNECT_EVENT).pipe(share(), first()); 49 | 50 | handlers.forEach(({ message, callback }) => { 51 | const source$ = fromEvent(client, message).pipe( 52 | mergeMap((payload: any) => { 53 | const { data, ack } = this.mapPayload(payload); 54 | return transform(callback(data, ack)).pipe( 55 | filter((response: any) => !isNil(response)), 56 | map((response: any) => [response, ack]), 57 | ); 58 | }), 59 | takeUntil(disconnect$), 60 | ); 61 | source$.subscribe(([response, ack]) => { 62 | if (response.event) { 63 | return client.emit(response.event, response.data); 64 | } 65 | isFunction(ack) && ack(response); 66 | }); 67 | }); 68 | } 69 | 70 | public mapPayload(payload: any): { data: any; ack?: () => any } { 71 | if (!Array.isArray(payload)) { 72 | return { data: payload }; 73 | } 74 | const lastElement = payload[payload.length - 1]; 75 | const isAck = isFunction(lastElement); 76 | if (isAck) { 77 | const size = payload.length - 1; 78 | return { 79 | data: size === 1 ? payload[0] : payload.slice(0, size), 80 | ack: lastElement, 81 | }; 82 | } 83 | return { data: payload }; 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /src/core/adapter/redis-io.adapter.ts: -------------------------------------------------------------------------------- 1 | import { INestApplicationContext } from '@nestjs/common'; 2 | import { RedisClient } from 'redis'; 3 | import { ServerOptions } from 'socket.io'; 4 | import { createAdapter, RedisAdapter } from 'socket.io-redis'; 5 | import { CustomSocketIoAdapter } from './custom-socket-io.adapter'; 6 | 7 | export class RedisIoAdapter extends CustomSocketIoAdapter { 8 | private redisAdapter: RedisAdapter; 9 | 10 | constructor(host: string, port: number, app: INestApplicationContext) { 11 | super(app); 12 | 13 | const pubClient = new RedisClient({ host, port }); 14 | const subClient = pubClient.duplicate(); 15 | this.redisAdapter = createAdapter({ pubClient, subClient }); 16 | } 17 | 18 | createIOServer(port: number, options?: ServerOptions) { 19 | const server = super.createIOServer(port, options); 20 | 21 | server.adapter(this.redisAdapter as any); 22 | 23 | return server; 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/core/core.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { MailModule } from './mail/mail.module'; 3 | 4 | @Module({ 5 | imports: [MailModule], 6 | controllers: [], 7 | providers: [], 8 | exports: [MailModule], 9 | }) 10 | export class CoreModule {} 11 | -------------------------------------------------------------------------------- /src/core/filter/exceptions.filter.ts: -------------------------------------------------------------------------------- 1 | import { ExceptionFilter, Catch, ArgumentsHost, HttpException, HttpStatus } from '@nestjs/common'; 2 | import { WsException } from '@nestjs/websockets'; 3 | import { Socket } from 'socket.io'; 4 | import { getRequest } from '../../shared/utils/get-request'; 5 | import { Request } from 'express'; 6 | 7 | export type Exceptions = HttpException | WsException; 8 | 9 | @Catch(WsException, HttpException) 10 | export class ExceptionsFilter implements ExceptionFilter { 11 | catch(exception: Exceptions, host: ArgumentsHost) { 12 | const request = getRequest(host); 13 | 14 | const statusCode = this.isHttpException(exception) 15 | ? exception.getStatus() 16 | : HttpStatus.INTERNAL_SERVER_ERROR; 17 | 18 | const response = { 19 | statusCode, 20 | error: 'Error', 21 | message: exception.message, 22 | timestamp: Date.now() / 1000, 23 | }; 24 | 25 | const error = this.isHttpException(exception) ? exception.getResponse() : exception.getError(); 26 | 27 | if (typeof error === 'string') { 28 | response.message = error; 29 | } else { 30 | Object.assign(response, error); 31 | } 32 | 33 | switch (host.getType()) { 34 | case 'http': 35 | host 36 | .switchToHttp() 37 | .getResponse() 38 | .status(statusCode) 39 | .json(response); 40 | break; 41 | 42 | case 'ws': 43 | const callback = host.getArgByIndex(2); 44 | 45 | if (typeof callback === 'function') { 46 | callback(response); 47 | } 48 | 49 | request.emit('exception', response); 50 | break; 51 | 52 | default: 53 | break; 54 | } 55 | 56 | return response; 57 | } 58 | 59 | isHttpException(err: Exceptions): err is HttpException { 60 | return err instanceof HttpException; 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /src/core/mail/config/mailer.config.ts: -------------------------------------------------------------------------------- 1 | import { MailerOptions } from '@nestjs-modules/mailer'; 2 | import { ConfigFactory } from 'code-config'; 3 | import { join } from 'path'; 4 | import { PATHS } from '../../../shared/constants/paths'; 5 | 6 | export interface MailerSchema { 7 | transport: MailerOptions['transport']; 8 | defaults: MailerOptions['defaults']; 9 | } 10 | 11 | const defaultValue = { 12 | transport: { 13 | host: 'smtp.example.com', 14 | secure: false, 15 | auth: { 16 | user: 'user@example.com', 17 | pass: 'topsecret', 18 | }, 19 | }, 20 | defaults: { 21 | from: '"No Reply" ', 22 | }, 23 | }; 24 | 25 | export const mailerConfig = ConfigFactory.getConfig( 26 | join(PATHS.config, 'mailer.config.json'), 27 | defaultValue, 28 | ); 29 | 30 | mailerConfig.initPrettify(); 31 | -------------------------------------------------------------------------------- /src/core/mail/mail.module.ts: -------------------------------------------------------------------------------- 1 | import { MailerModule } from '@nestjs-modules/mailer'; 2 | import { Module } from '@nestjs/common'; 3 | import { mailerConfig } from './config/mailer.config'; 4 | import { HandlebarsAdapter } from '@nestjs-modules/mailer/dist/adapters/handlebars.adapter'; 5 | import { join } from 'path'; 6 | 7 | @Module({ 8 | imports: [ 9 | MailerModule.forRoot({ 10 | ...mailerConfig.toObject(), 11 | template: { 12 | dir: join(__dirname, 'template'), 13 | adapter: new HandlebarsAdapter(), 14 | options: { 15 | strict: true, 16 | }, 17 | }, 18 | }), 19 | ], 20 | controllers: [], 21 | providers: [], 22 | }) 23 | export class MailModule {} 24 | -------------------------------------------------------------------------------- /src/core/mail/template/recover.hbs: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Recover your password 8 | 9 | 10 |

Recover your password

11 |

Click this URL to change your password: {{url}}/recover/{{code}}

12 |

This code will expire in {{expiration}} hour(s)

13 | 14 | -------------------------------------------------------------------------------- /src/environments/environments.ts: -------------------------------------------------------------------------------- 1 | import { config } from 'dotenv'; 2 | 3 | config(); 4 | 5 | const env = process.env; 6 | 7 | export const environments = { 8 | port: Number(env.PORT || 3000), 9 | mongoUri: env.MONGO_URI, 10 | proxyEnabled: env.PROXY_ENABLED === 'true', 11 | frontEndUrl: env.FRONTEND_URL, 12 | accessTokenSecret: env.ACCESS_TOKEN_SECRET, 13 | accessTokenExpiration: env.ACCESS_TOKEN_EXPIRATION, 14 | refreshTokenSecret: env.REFRESH_TOKEN_SECRET, 15 | refreshTokenExpiration: env.REFRESH_TOKEN_EXPIRATION, 16 | recoverCodeExpiration: Number(env.RECOVER_CODE_EXPIRATION), 17 | redis: { 18 | enabled: env.REDIS_ENABLED === 'true', 19 | host: env.REDIS_HOST, 20 | port: Number(env.REDIS_PORT), 21 | }, 22 | vapid: { 23 | publicKey: env.VAPID_PUBLIC_KEY, 24 | privateKey: env.VAPID_PRIVATE_KEY, 25 | subject: env.VAPID_SUBJECT, 26 | }, 27 | }; 28 | -------------------------------------------------------------------------------- /src/features/auth/auth.module.ts: -------------------------------------------------------------------------------- 1 | import { AuthController } from './controller/auth.controller'; 2 | import { AuthService } from './service/auth.service'; 3 | import { Module } from '@nestjs/common'; 4 | import { JwtModule } from '@nestjs/jwt'; 5 | import { ConfigModule } from '@nestjs/config'; 6 | import { JwtAuthGuard } from './guard/jwt-auth.guard'; 7 | import { FacebookAuthModule } from 'facebook-auth-nestjs'; 8 | import { authConfig } from './config/auth.config'; 9 | import { GoogleAuthService } from './service/google-auth.service'; 10 | import { AppleAuthService } from './service/apple-auth.service'; 11 | import { UserModule } from '../user/user.module'; 12 | 13 | const facebook = authConfig.facebook; 14 | 15 | @Module({ 16 | imports: [ 17 | ConfigModule, 18 | JwtModule.register(null), 19 | FacebookAuthModule.forRoot({ 20 | clientId: facebook.appId as number, 21 | clientSecret: facebook.appSecret, 22 | }), 23 | UserModule, 24 | ], 25 | controllers: [AuthController], 26 | providers: [AuthService, JwtAuthGuard, GoogleAuthService, AppleAuthService], 27 | exports: [JwtAuthGuard, AuthService, JwtModule, ConfigModule, UserModule], 28 | }) 29 | export class AuthModule {} 30 | -------------------------------------------------------------------------------- /src/features/auth/config/auth-config.default.ts: -------------------------------------------------------------------------------- 1 | import { SecretsSchema } from './auth.config'; 2 | 3 | export const authConfigDefault: SecretsSchema = { 4 | facebook: { 5 | appId: 1234, 6 | appSecret: 'secret', 7 | }, 8 | google: { 9 | appId: 1234, 10 | appSecret: 'secret', 11 | }, 12 | apple: { 13 | ios: { 14 | clientId: 'com.code.auth', 15 | packageId: 'com.code.auth', 16 | redirectUri: 'https://nest-auth.ubbly.club/', 17 | }, 18 | android: { 19 | clientId: 'nest-auth.ubbly.club', 20 | packageId: 'com.code.auth', 21 | redirectUri: 'https://nest-auth.ubbly.club/api/auth/apple-callback', 22 | }, 23 | web: { 24 | clientId: 'nest-auth.ubbly.club', 25 | redirectUri: 'https://nest-auth.ubbly.club/', 26 | }, 27 | teamId: '', 28 | keyIdentifier: '', 29 | }, 30 | }; 31 | -------------------------------------------------------------------------------- /src/features/auth/config/auth.config.ts: -------------------------------------------------------------------------------- 1 | import { ConfigFactory } from 'code-config'; 2 | import { join } from 'path'; 3 | import { PATHS } from '../../../shared/constants/paths'; 4 | import { authConfigDefault } from './auth-config.default'; 5 | 6 | interface Secret { 7 | appId: number | string; 8 | appSecret: string; 9 | } 10 | 11 | interface Platform { 12 | clientId: string; 13 | redirectUri: string; 14 | packageId?: string; 15 | } 16 | 17 | export interface SecretsSchema { 18 | facebook: Secret; 19 | google: Secret; 20 | apple: { 21 | ios: Platform; 22 | web: Platform; 23 | android: Platform; 24 | teamId: string; 25 | keyIdentifier: string; 26 | }; 27 | } 28 | 29 | export const authConfig = ConfigFactory.getConfig( 30 | join(PATHS.config, 'auth.config.json'), 31 | authConfigDefault, 32 | ); 33 | 34 | authConfig.initPrettify(); 35 | -------------------------------------------------------------------------------- /src/features/auth/controller/auth.controller.ts: -------------------------------------------------------------------------------- 1 | import { 2 | BadRequestException, 3 | Body, 4 | Controller, 5 | Delete, 6 | Get, 7 | Post, 8 | Res, 9 | UseGuards, 10 | } from '@nestjs/common'; 11 | import { User } from '../../user/schema/user.schema'; 12 | import { UserService } from '../../user/service/user.service'; 13 | import { CurrentUser } from '../decorators/current-user.decorator'; 14 | import { AuthService } from '../service/auth.service'; 15 | import { JwtAuthGuard } from '../guard/jwt-auth.guard'; 16 | import { RegisterDto } from '../dto/register.dto'; 17 | import { LoginDto } from '../dto/login.dto'; 18 | import { FacebookAuthService } from 'facebook-auth-nestjs'; 19 | import { GoogleAuthService } from '../service/google-auth.service'; 20 | import { AppleAuthService } from '../service/apple-auth.service'; 21 | import { AppleLoginDto } from '../dto/apple-login.dto'; 22 | import { Dictionary } from 'code-config'; 23 | import { Response } from 'express'; 24 | import { authConfig } from '../config/auth.config'; 25 | import { stringify } from 'qs'; 26 | import { SubscriptionService } from '../../user/service/subscription.service'; 27 | import { AuthNotRequired } from '../decorators/auth-not-required.decorator'; 28 | 29 | @Controller('auth') 30 | export class AuthController { 31 | constructor( 32 | private authService: AuthService, 33 | private userService: UserService, 34 | private facebookService: FacebookAuthService, 35 | private googleService: GoogleAuthService, 36 | private appleService: AppleAuthService, 37 | private subscriptionService: SubscriptionService, 38 | ) {} 39 | 40 | @Post('login') 41 | async login(@Body() body: LoginDto) { 42 | return this.authService.login(await this.authService.validate(body.username, body.password)); 43 | } 44 | 45 | @Post('facebook-login') 46 | @AuthNotRequired() 47 | @UseGuards(JwtAuthGuard) 48 | async facebookLogin(@CurrentUser() user: User, @Body('accessToken') accessToken: string) { 49 | return this.authService.loginWithThirdParty('facebookId', () => 50 | this.facebookService.getUser(accessToken, 'id', 'name', 'email', 'first_name', 'last_name'), 51 | ); 52 | } 53 | 54 | @Post('google-login') 55 | @AuthNotRequired() 56 | @UseGuards(JwtAuthGuard) 57 | async googleLogin(@CurrentUser() user: User, @Body('accessToken') accessToken: string) { 58 | return this.authService.loginWithThirdParty( 59 | 'googleId', 60 | () => this.googleService.getUser(accessToken), 61 | user, 62 | ); 63 | } 64 | 65 | @Post('apple-login') 66 | @AuthNotRequired() 67 | @UseGuards(JwtAuthGuard) 68 | async appleLogin(@CurrentUser() user: User, @Body() body: AppleLoginDto) { 69 | return this.authService.loginWithThirdParty( 70 | 'appleId', 71 | () => this.appleService.getUser(body), 72 | user, 73 | ); 74 | } 75 | 76 | @Post('refresh-token') 77 | async refreshToken(@Body('refreshToken') refreshToken: string) { 78 | return this.authService.loginWithRefreshToken(refreshToken); 79 | } 80 | 81 | @Post('register') 82 | async register(@Body() body: RegisterDto) { 83 | if (await this.userService.getUserByName(body.username)) { 84 | throw new BadRequestException('Username already exists'); 85 | } 86 | 87 | if (await this.userService.getUserByEmail(body.email)) { 88 | throw new BadRequestException('Email already exists'); 89 | } 90 | 91 | const user = await this.userService.create(body); 92 | 93 | return this.authService.login(user); 94 | } 95 | 96 | @Post('apple-callback') 97 | appleCallback(@Body() body: Dictionary, @Res() res: Response) { 98 | const uri = `intent://callback?${stringify(body)}#Intent;package=${ 99 | authConfig.apple.android.packageId 100 | };scheme=signinwithapple;end`; 101 | 102 | return res.redirect(uri); 103 | } 104 | 105 | @UseGuards(JwtAuthGuard) 106 | @Delete('logout-from-all-devices') 107 | async logoutFromAllDevices(@CurrentUser() user: User) { 108 | user.generateSessionToken(); 109 | 110 | await user.save(); 111 | 112 | await this.subscriptionService.deleteAll(user); 113 | 114 | return this.authService.login(user); 115 | } 116 | 117 | @Get('me') 118 | @UseGuards(JwtAuthGuard) 119 | me(@CurrentUser() user: User) { 120 | return this.userService.filterUser(user, ['email']); 121 | } 122 | } 123 | -------------------------------------------------------------------------------- /src/features/auth/decorators/auth-not-required.decorator.ts: -------------------------------------------------------------------------------- 1 | import { SetMetadata } from '@nestjs/common'; 2 | 3 | export const AUTH_NOT_REQUIRED = 'auth-not-required'; 4 | 5 | export const AuthNotRequired = () => SetMetadata(AUTH_NOT_REQUIRED, true); 6 | -------------------------------------------------------------------------------- /src/features/auth/decorators/current-user.decorator.ts: -------------------------------------------------------------------------------- 1 | import { createParamDecorator, ExecutionContext } from '@nestjs/common'; 2 | import { getClient } from '../../../shared/utils/get-client'; 3 | 4 | export const CurrentUser = createParamDecorator( 5 | (data: unknown, ctx: ExecutionContext) => getClient(ctx)?.user, 6 | ); 7 | -------------------------------------------------------------------------------- /src/features/auth/dto/apple-login.dto.ts: -------------------------------------------------------------------------------- 1 | import { IsNotEmpty, IsOptional, IsString } from 'class-validator'; 2 | 3 | export class AppleLoginDto { 4 | @IsOptional() 5 | @IsNotEmpty() 6 | @IsString() 7 | name?: string; 8 | 9 | @IsOptional() 10 | @IsNotEmpty() 11 | @IsString() 12 | accessToken?: string; 13 | 14 | @IsOptional() 15 | @IsNotEmpty() 16 | @IsString() 17 | authorizationCode?: string; 18 | 19 | @IsOptional() 20 | @IsNotEmpty() 21 | @IsString() 22 | type?: 'web' | 'ios' | 'android'; 23 | } 24 | -------------------------------------------------------------------------------- /src/features/auth/dto/login.dto.ts: -------------------------------------------------------------------------------- 1 | import { IsNotEmpty, IsString } from 'class-validator'; 2 | 3 | export class LoginDto { 4 | @IsNotEmpty() 5 | @IsString() 6 | username: string; 7 | 8 | @IsNotEmpty() 9 | @IsString() 10 | password: string; 11 | } 12 | -------------------------------------------------------------------------------- /src/features/auth/dto/register.dto.ts: -------------------------------------------------------------------------------- 1 | import { IsEmail, IsNotEmpty, IsString, Matches, MaxLength, MinLength } from 'class-validator'; 2 | 3 | export class RegisterDto { 4 | @IsNotEmpty() 5 | @IsString() 6 | @Matches(/[a-zA-Z0-9_-]{2,20}/, { 7 | message: 'Invalid username', 8 | }) 9 | username: string; 10 | 11 | @IsNotEmpty() 12 | @IsString() 13 | @MinLength(6) 14 | @MaxLength(60) 15 | password: string; 16 | 17 | @IsNotEmpty() 18 | @IsEmail() 19 | email: string; 20 | } 21 | -------------------------------------------------------------------------------- /src/features/auth/guard/jwt-auth.guard.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Injectable, 3 | CanActivate, 4 | ExecutionContext, 5 | UnauthorizedException, 6 | Inject, 7 | forwardRef, 8 | } from '@nestjs/common'; 9 | import { Reflector } from '@nestjs/core'; 10 | import { JwtService } from '@nestjs/jwt'; 11 | import { Socket } from 'socket.io'; 12 | import { Client, getClient } from '../../../shared/utils/get-client'; 13 | import { UserService } from '../../user/service/user.service'; 14 | import { AuthService } from '../service/auth.service'; 15 | import { AUTH_NOT_REQUIRED } from '../decorators/auth-not-required.decorator'; 16 | 17 | export interface Token { 18 | sub: string; 19 | username: string; 20 | } 21 | 22 | @Injectable() 23 | export class JwtAuthGuard implements CanActivate { 24 | reflector: Reflector; 25 | 26 | constructor( 27 | private authService: AuthService, 28 | private jwtService: JwtService, 29 | @Inject(forwardRef(() => UserService)) private userService: UserService, 30 | ) { 31 | this.reflector = new Reflector(); 32 | } 33 | 34 | async canActivate(ctx: ExecutionContext): Promise { 35 | const client = this.getRequest(ctx); 36 | 37 | const allowAny = this.reflector.get(AUTH_NOT_REQUIRED, ctx.getHandler()); 38 | 39 | try { 40 | client.user = await this.handleRequest(ctx, client); 41 | } catch (e) { 42 | if (allowAny) { 43 | return true; 44 | } 45 | 46 | throw e; 47 | } 48 | 49 | return client.user != null; 50 | } 51 | 52 | private async handleRequest(ctx: ExecutionContext, client: Client) { 53 | const token = this.getToken(ctx, client); 54 | 55 | const decoded = this.jwtService.decode(token) as Token; 56 | 57 | if (!decoded) { 58 | this.throwException(ctx, 'Unable to decode token'); 59 | } 60 | 61 | try { 62 | const user = await this.validate(decoded); 63 | 64 | await this.jwtService.verifyAsync(token, this.authService.getAccessTokenOptions(user)); 65 | 66 | return user; 67 | } catch (e) { 68 | this.throwException(ctx, 'Invalid token'); 69 | } 70 | } 71 | 72 | private validate({ sub }: Token) { 73 | return this.userService.validateUserById(sub); 74 | } 75 | 76 | private getToken(ctx: ExecutionContext, client: Client): string { 77 | const authorization = client.headers.authorization?.split(' '); 78 | 79 | if (!authorization) { 80 | this.throwException(ctx, 'Token not found'); 81 | } 82 | 83 | if (authorization[0].toLowerCase() !== 'bearer') { 84 | this.throwException(ctx, 'Authorization type not valid'); 85 | } 86 | 87 | if (!authorization[1]) { 88 | this.throwException(ctx, 'Token not provided'); 89 | } 90 | 91 | return authorization[1]; 92 | } 93 | 94 | throwException(ctx: ExecutionContext, message: string) { 95 | if (ctx.getType() === 'ws') { 96 | ctx 97 | .switchToWs() 98 | .getClient() 99 | .disconnect(true); 100 | } 101 | 102 | throw new UnauthorizedException(message); 103 | } 104 | 105 | private getRequest(ctx: ExecutionContext) { 106 | return getClient(ctx); 107 | } 108 | } 109 | -------------------------------------------------------------------------------- /src/features/auth/service/apple-auth.service.ts: -------------------------------------------------------------------------------- 1 | import { HttpException, Injectable, UnauthorizedException } from '@nestjs/common'; 2 | import { AppleLoginDto } from '../dto/apple-login.dto'; 3 | import { join } from 'path'; 4 | import appleSignin from 'apple-signin-auth'; 5 | import { authConfig } from '../config/auth.config'; 6 | import { SocialUser } from './auth.service'; 7 | import { existsSync, readFileSync } from 'fs'; 8 | import { PATHS } from '../../../shared/constants/paths'; 9 | 10 | @Injectable() 11 | export class AppleAuthService { 12 | private privateKey: string; 13 | 14 | private privateKeyPath = join(PATHS.secrets, 'apple-key.p8'); 15 | 16 | constructor() { 17 | if (existsSync(this.privateKeyPath)) { 18 | this.privateKey = readFileSync(this.privateKeyPath, 'utf-8'); 19 | } 20 | } 21 | 22 | async getUser({ name, authorizationCode, type }: AppleLoginDto): Promise { 23 | try { 24 | const clientId = authConfig.apple[type || 'ios'].clientId; 25 | 26 | const clientSecret = appleSignin.getClientSecret({ 27 | clientID: clientId, 28 | privateKey: this.privateKey, 29 | teamID: authConfig.apple.teamId, 30 | keyIdentifier: authConfig.apple.keyIdentifier, 31 | }); 32 | 33 | const response = await appleSignin.getAuthorizationToken(authorizationCode, { 34 | clientSecret, 35 | clientID: clientId, 36 | redirectUri: authConfig.apple[type].redirectUri, 37 | }); 38 | 39 | if (!response?.id_token) { 40 | throw new UnauthorizedException( 41 | `Access token cannot be retrieved from Apple: ${JSON.stringify(response)}`, 42 | ); 43 | } 44 | 45 | const json = await appleSignin.verifyIdToken(response.id_token, { 46 | audience: clientId, 47 | ignoreExpiration: true, 48 | }); 49 | 50 | return { 51 | name, 52 | id: json.sub, 53 | email: json.email, 54 | }; 55 | } catch (e) { 56 | if (e instanceof HttpException) { 57 | throw e; 58 | } 59 | 60 | throw new UnauthorizedException(e.message || e); 61 | } 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /src/features/auth/service/auth.service.ts: -------------------------------------------------------------------------------- 1 | import { 2 | BadRequestException, 3 | forwardRef, 4 | HttpException, 5 | Inject, 6 | Injectable, 7 | UnauthorizedException, 8 | } from '@nestjs/common'; 9 | import { JwtService, JwtSignOptions } from '@nestjs/jwt'; 10 | import { environments } from '../../../environments/environments'; 11 | import { User } from '../../user/schema/user.schema'; 12 | import { UserService } from '../../user/service/user.service'; 13 | import { Token } from '../guard/jwt-auth.guard'; 14 | 15 | export interface TokenResponse { 16 | access_token: string; 17 | refresh_token: string; 18 | } 19 | 20 | export interface SocialUser { 21 | id: number | string; 22 | name: string; 23 | email: string; 24 | } 25 | 26 | export type GetSocialUserHandler = () => Promise>; 27 | 28 | @Injectable() 29 | export class AuthService { 30 | constructor( 31 | @Inject(forwardRef(() => UserService)) private userService: UserService, 32 | private jwtService: JwtService, 33 | ) {} 34 | 35 | async validate(username: string, password: string) { 36 | const user = await this.userService.getUser(username); 37 | 38 | if (!user) { 39 | throw new UnauthorizedException('User does not exist'); 40 | } 41 | 42 | if (!(await user.validatePassword(password))) { 43 | throw new UnauthorizedException('Incorrect password'); 44 | } 45 | 46 | return user; 47 | } 48 | 49 | async login(user: User, isGenerateRefreshToken = true): Promise { 50 | const payload: Token = { 51 | sub: user.id, 52 | username: user.username, 53 | }; 54 | 55 | let refresh_token: string; 56 | 57 | if (environments.accessTokenExpiration && isGenerateRefreshToken) { 58 | const refreshTokenOptions = this.getRefreshTokenOptions(user); 59 | 60 | refresh_token = await this.jwtService.signAsync(payload, refreshTokenOptions); 61 | } 62 | 63 | const accessTokenOptions = this.getAccessTokenOptions(user); 64 | 65 | const accessToken = await this.jwtService.signAsync(payload, accessTokenOptions); 66 | 67 | return { 68 | refresh_token, 69 | access_token: accessToken, 70 | }; 71 | } 72 | 73 | async loginWithThirdParty( 74 | fieldId: keyof User, 75 | getSocialUser: GetSocialUserHandler, 76 | currentUser?: User, 77 | customName?: string, 78 | ) { 79 | try { 80 | const { name, email, id } = await getSocialUser(); 81 | 82 | const existentUser = await this.userService.getUserBy({ [fieldId]: id }); 83 | 84 | if (existentUser && !currentUser) { 85 | return this.login(existentUser); 86 | } 87 | 88 | if (existentUser && currentUser) { 89 | throw new BadRequestException(`${fieldId} already exists`); 90 | } 91 | 92 | if (!currentUser && (await this.userService.getUserByEmail(email))) { 93 | throw new BadRequestException('Email already exists'); 94 | } 95 | 96 | if (currentUser) { 97 | currentUser[fieldId as string] = id; 98 | await currentUser.save(); 99 | 100 | return this.login(currentUser); 101 | } 102 | 103 | const username = await this.userService.generateUsername(customName || name); 104 | 105 | const user = await this.userService.create({ 106 | username, 107 | email, 108 | [fieldId]: id, 109 | }); 110 | 111 | return this.login(user); 112 | } catch (e) { 113 | if (e instanceof HttpException) { 114 | throw e; 115 | } 116 | 117 | throw new UnauthorizedException('Invalid access token'); 118 | } 119 | } 120 | 121 | async loginWithRefreshToken(refreshToken: string) { 122 | try { 123 | const decoded = this.jwtService.decode(refreshToken) as Token; 124 | 125 | if (!decoded) { 126 | throw new Error(); 127 | } 128 | 129 | const user = await this.userService.validateUserById(decoded.sub); 130 | 131 | await this.jwtService.verifyAsync(refreshToken, this.getRefreshTokenOptions(user)); 132 | 133 | return this.login(user, false); 134 | } catch { 135 | throw new UnauthorizedException('Invalid token'); 136 | } 137 | } 138 | 139 | getRefreshTokenOptions(user: User): JwtSignOptions { 140 | return this.getTokenOptions('refresh', user); 141 | } 142 | 143 | getAccessTokenOptions(user: User): JwtSignOptions { 144 | return this.getTokenOptions('access', user); 145 | } 146 | 147 | private getTokenOptions(type: 'refresh' | 'access', user: User) { 148 | const options: JwtSignOptions = { 149 | secret: environments[type + 'TokenSecret'] + user.sessionToken, 150 | }; 151 | 152 | const expiration = environments[type + 'TokenExpiration']; 153 | 154 | if (expiration) { 155 | options.expiresIn = expiration; 156 | } 157 | 158 | return options; 159 | } 160 | } 161 | -------------------------------------------------------------------------------- /src/features/auth/service/google-auth.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { google } from 'googleapis'; 3 | import { authConfig } from '../config/auth.config'; 4 | 5 | @Injectable() 6 | export class GoogleAuthService { 7 | async getUser(accessToken: string) { 8 | const client = new google.auth.OAuth2( 9 | authConfig.google.appId as string, 10 | authConfig.google.appSecret, 11 | ); 12 | 13 | client.setCredentials({ access_token: accessToken }); 14 | 15 | const oauth2 = google.oauth2({ 16 | auth: client, 17 | version: 'v2', 18 | }); 19 | 20 | const { data } = await oauth2.userinfo.get(); 21 | 22 | return data; 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/features/features.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { AuthModule } from './auth/auth.module'; 3 | import { MessagesModule } from './messages/messages.module'; 4 | import { NotificationModule } from './notification/notification.module'; 5 | import { RoomModule } from './room/room.module'; 6 | import { UserModule } from './user/user.module'; 7 | 8 | @Module({ 9 | imports: [AuthModule, UserModule, RoomModule, MessagesModule, NotificationModule], 10 | controllers: [], 11 | exports: [AuthModule, UserModule, RoomModule, MessagesModule, NotificationModule], 12 | }) 13 | export class FeaturesModule {} 14 | -------------------------------------------------------------------------------- /src/features/messages/controller/message.controller.ts: -------------------------------------------------------------------------------- 1 | import { Query } from '@nestjs/common'; 2 | import { 3 | Body, 4 | Controller, 5 | Delete, 6 | Get, 7 | Param, 8 | UnauthorizedException, 9 | UseGuards, 10 | } from '@nestjs/common'; 11 | import { CurrentUser } from '../../auth/decorators/current-user.decorator'; 12 | import { JwtAuthGuard } from '../../auth/guard/jwt-auth.guard'; 13 | import { RoomService } from '../../room/service/room.service'; 14 | import { User } from '../../user/schema/user.schema'; 15 | import { UserService } from '../../user/service/user.service'; 16 | import { DeleteDirectMessageDto } from '../dto/delete-direct-message.dto'; 17 | import { DeleteRoomMessageDto } from '../dto/delete-room-message.dto'; 18 | import { FetchMessagesDto } from '../dto/fetch-messages.dto'; 19 | import { MessageService } from '../service/message.service'; 20 | 21 | @UseGuards(JwtAuthGuard) 22 | @Controller('message') 23 | export class MessageController { 24 | constructor( 25 | private userService: UserService, 26 | private roomService: RoomService, 27 | private messageService: MessageService, 28 | ) {} 29 | 30 | @Get('direct-first-message/:userId') 31 | async getFirstDirectMessage(@CurrentUser() user: User, @Param('userId') to: string) { 32 | return this.messageService.getFirstDirectMessage( 33 | user, 34 | await this.userService.validateUserById(to), 35 | ); 36 | } 37 | 38 | @Get('direct/:userId') 39 | async getDirectMessages( 40 | @CurrentUser() user: User, 41 | @Param('userId') to: string, 42 | @Query() query: FetchMessagesDto, 43 | ) { 44 | return this.messageService.getDirectMessages( 45 | user, 46 | await this.userService.validateUserById(to), 47 | query.limit, 48 | query.before, 49 | ); 50 | } 51 | 52 | @Delete('direct') 53 | async deleteDirectMessage(@Body() body: DeleteDirectMessageDto, @CurrentUser() from: User) { 54 | await this.userService.validateUserById(body.to); 55 | 56 | const message = await this.messageService.validatePopulatedMessage(body.messageId); 57 | 58 | if (message.from.id !== from.id && message.to.id !== from.id) { 59 | throw new UnauthorizedException('You do not have access to this chat'); 60 | } 61 | 62 | return this.messageService.deleteDirectMessage(message); 63 | } 64 | 65 | @Delete('direct/all') 66 | async deleteDirectMessages(@Body() body: DeleteDirectMessageDto, @CurrentUser() from: User) { 67 | const to = await this.userService.validateUserById(body.to); 68 | 69 | return this.messageService.deleteDirectMessages(from, to); 70 | } 71 | 72 | @Get('room-first-message/:roomId') 73 | async getFirstRoomMessage(@Param('roomId') roomId: string) { 74 | return this.messageService.getFirstRoomMessage(await this.roomService.validateRoom(roomId)); 75 | } 76 | 77 | @Get('room/:roomId') 78 | async getRoomMessages(@Param('roomId') roomId: string, @Query() query: FetchMessagesDto) { 79 | return this.messageService.getRoomMessages( 80 | await this.roomService.validateRoom(roomId), 81 | query.limit, 82 | query.before, 83 | ); 84 | } 85 | 86 | @Delete('room') 87 | async deleteRoomMessage(@Body() body: DeleteRoomMessageDto, @CurrentUser() user: User) { 88 | const room = await this.roomService.validateRoom(body.roomId); 89 | 90 | const message = await this.messageService.validateMessage(body.messageId); 91 | 92 | if (room.owner.id !== user.id && message.from.id !== user.id) { 93 | throw new UnauthorizedException('You are not the message owner'); 94 | } 95 | 96 | return this.messageService.deleteRoomMessage(room, body.messageId); 97 | } 98 | 99 | @Delete('room/all') 100 | async deleteRoomMessages(@Body() body: DeleteRoomMessageDto, @CurrentUser() user: User) { 101 | const room = await this.roomService.validateRoom(body.roomId); 102 | 103 | if (user.id !== room.owner.id) { 104 | throw new UnauthorizedException('You are not the room owner'); 105 | } 106 | 107 | return this.messageService.deleteRoomMessages(room); 108 | } 109 | } 110 | -------------------------------------------------------------------------------- /src/features/messages/dto/delete-direct-message.dto.ts: -------------------------------------------------------------------------------- 1 | import { IsMongoId, IsOptional } from 'class-validator'; 2 | 3 | export class DeleteDirectMessageDto { 4 | @IsMongoId() 5 | to: string; 6 | 7 | @IsOptional() 8 | @IsMongoId() 9 | messageId?: string; 10 | } 11 | -------------------------------------------------------------------------------- /src/features/messages/dto/delete-room-message.dto.ts: -------------------------------------------------------------------------------- 1 | import { IsMongoId, IsOptional } from 'class-validator'; 2 | 3 | export class DeleteRoomMessageDto { 4 | @IsOptional() 5 | @IsMongoId() 6 | messageId?: string; 7 | 8 | @IsMongoId() 9 | roomId: string; 10 | } 11 | -------------------------------------------------------------------------------- /src/features/messages/dto/direct-message.dto.ts: -------------------------------------------------------------------------------- 1 | import { IsMongoId, IsString, MaxLength } from 'class-validator'; 2 | 3 | export class DirectMessageDto { 4 | @IsString() 5 | @MaxLength(2000) 6 | message: string; 7 | 8 | @IsMongoId() 9 | to: string; 10 | } 11 | -------------------------------------------------------------------------------- /src/features/messages/dto/fetch-messages.dto.ts: -------------------------------------------------------------------------------- 1 | import { Type } from 'class-transformer'; 2 | import { IsDate, IsNumber, IsOptional } from 'class-validator'; 3 | 4 | export class FetchMessagesDto { 5 | @IsOptional() 6 | @Type(() => Number) 7 | @IsNumber() 8 | limit = 30; 9 | 10 | @IsOptional() 11 | @Type(() => Date) 12 | @IsDate() 13 | before: Date; 14 | } 15 | -------------------------------------------------------------------------------- /src/features/messages/dto/room-message.dto.ts: -------------------------------------------------------------------------------- 1 | import { IsMongoId, IsString, MaxLength } from 'class-validator'; 2 | 3 | export class RoomMessageDto { 4 | @IsString() 5 | @MaxLength(2000) 6 | message: string; 7 | 8 | @IsMongoId() 9 | roomId: string; 10 | } 11 | -------------------------------------------------------------------------------- /src/features/messages/gateway/message.gateway.ts: -------------------------------------------------------------------------------- 1 | import { UseFilters, UseGuards, UsePipes, ValidationPipe } from '@nestjs/common'; 2 | import { 3 | ConnectedSocket, 4 | MessageBody, 5 | SubscribeMessage, 6 | WebSocketGateway, 7 | WebSocketServer, 8 | } from '@nestjs/websockets'; 9 | import { Server, Socket } from 'socket.io'; 10 | import { ExceptionsFilter } from '../../../core/filter/exceptions.filter'; 11 | import { environments } from '../../../environments/environments'; 12 | import { ParseObjectIdPipe } from '../../../shared/pipe/parse-object-id.pipe'; 13 | import { CurrentUser } from '../../auth/decorators/current-user.decorator'; 14 | import { JwtAuthGuard } from '../../auth/guard/jwt-auth.guard'; 15 | import { RoomService } from '../../room/service/room.service'; 16 | import { User } from '../../user/schema/user.schema'; 17 | import { NotificationType, SubscriptionService } from '../../user/service/subscription.service'; 18 | import { UserService } from '../../user/service/user.service'; 19 | import { DirectMessageDto } from '../dto/direct-message.dto'; 20 | import { RoomMessageDto } from '../dto/room-message.dto'; 21 | import { MessageService } from '../service/message.service'; 22 | 23 | @UsePipes(new ValidationPipe()) 24 | @UseFilters(new ExceptionsFilter()) 25 | @UseGuards(JwtAuthGuard) 26 | @WebSocketGateway() 27 | export class MessageGateway { 28 | @WebSocketServer() server: Server; 29 | 30 | constructor( 31 | private userService: UserService, 32 | private roomService: RoomService, 33 | private messageService: MessageService, 34 | private subscriptionService: SubscriptionService, 35 | ) {} 36 | 37 | @SubscribeMessage('message:direct') 38 | async sendDirectMessage(@MessageBody() body: DirectMessageDto, @CurrentUser() user: User) { 39 | const userTo = await this.userService.validateUserById(body.to); 40 | 41 | const message = await this.messageService.createDirectMessage(user, userTo, body.message); 42 | 43 | this.userService.sendMessage(user, 'message:direct', message); 44 | this.userService.sendMessage(userTo, 'message:direct', message); 45 | 46 | if (userTo.id === user.id) { 47 | return true; 48 | } 49 | 50 | const url = environments.frontEndUrl; 51 | 52 | this.subscriptionService.sendNotification(userTo, { 53 | notification: { 54 | title: user.username, 55 | body: message.message, 56 | }, 57 | mobileData: { 58 | type: NotificationType.Direct, 59 | routeName: '/direct-message', 60 | username: user.username, 61 | }, 62 | webData: { 63 | onActionClick: { 64 | default: { 65 | operation: 'navigateLastFocusedOrOpen', 66 | url: `${url}/direct-message/${user.username}`, 67 | }, 68 | }, 69 | }, 70 | }); 71 | 72 | return true; 73 | } 74 | 75 | @SubscribeMessage('message:direct:typing') 76 | async sendDirectTyping( 77 | @MessageBody(new ParseObjectIdPipe()) userId: string, 78 | @CurrentUser() user: User, 79 | ) { 80 | return this.userService.sendMessage( 81 | await this.userService.validateUserById(userId), 82 | 'message:direct:typing', 83 | { user: this.userService.filterUser(user) }, 84 | ); 85 | } 86 | 87 | @SubscribeMessage('message:room') 88 | async sendRoomMessage(@MessageBody() body: RoomMessageDto, @CurrentUser() user: User) { 89 | const room = await this.roomService.validateRoom(body.roomId); 90 | 91 | const message = await this.messageService.createRoomMessage(user, room, body.message); 92 | 93 | const url = environments.frontEndUrl; 94 | 95 | for (const member of room.members) { 96 | if (member.id === user.id) { 97 | continue; 98 | } 99 | 100 | this.subscriptionService.sendNotification(member, { 101 | notification: { 102 | title: room.title, 103 | body: `${user.username}: ${message.message}`, 104 | }, 105 | mobileData: { 106 | type: NotificationType.Room, 107 | routeName: '/rooms', 108 | roomId: room.id, 109 | roomTitle: room.title, 110 | }, 111 | webData: { 112 | onActionClick: { 113 | default: { 114 | operation: 'navigateLastFocusedOrOpen', 115 | url: `${url}/room/${room._id}`, 116 | }, 117 | }, 118 | }, 119 | }); 120 | } 121 | 122 | return this.roomService.sendMessage(room, 'message:room', message); 123 | } 124 | 125 | @SubscribeMessage('message:room:typing') 126 | async sendRoomTyping( 127 | @MessageBody(new ParseObjectIdPipe()) roomId: string, 128 | @ConnectedSocket() socket: Socket, 129 | @CurrentUser() user: User, 130 | ) { 131 | const room = await this.roomService.validateRoom(roomId); 132 | 133 | return this.roomService.sendMessageExcept(socket, room, 'message:room:typing', { 134 | room, 135 | user: this.userService.filterUser(user), 136 | }); 137 | } 138 | } 139 | -------------------------------------------------------------------------------- /src/features/messages/messages.module.ts: -------------------------------------------------------------------------------- 1 | import { MessageService } from './service/message.service'; 2 | 3 | import { Module } from '@nestjs/common'; 4 | import { MongooseModule } from '@nestjs/mongoose'; 5 | import { Message, MessageSchema } from './schema/message.schema'; 6 | import { AuthModule } from '../auth/auth.module'; 7 | import { RoomModule } from '../room/room.module'; 8 | import { MessageController } from './controller/message.controller'; 9 | import { MessageGateway } from './gateway/message.gateway'; 10 | 11 | @Module({ 12 | imports: [ 13 | MongooseModule.forFeature([ 14 | { 15 | name: Message.name, 16 | schema: MessageSchema, 17 | }, 18 | ]), 19 | AuthModule, 20 | RoomModule, 21 | ], 22 | controllers: [MessageController], 23 | providers: [MessageService, MessageGateway], 24 | exports: [MessageService], 25 | }) 26 | export class MessagesModule {} 27 | -------------------------------------------------------------------------------- /src/features/messages/schema/message.schema.ts: -------------------------------------------------------------------------------- 1 | import { Prop, Schema } from '@nestjs/mongoose'; 2 | import { Document } from 'mongoose'; 3 | import { createSchemaForClassWithMethods } from '../../../shared/mongoose/create-schema'; 4 | import { ObjectId } from '../../../shared/mongoose/object-id'; 5 | import { Room } from '../../room/schema/room.schema'; 6 | import { User } from '../../user/schema/user.schema'; 7 | 8 | @Schema() 9 | export class Message extends Document { 10 | @Prop({ 11 | required: true, 12 | }) 13 | message: string; 14 | 15 | @Prop({ type: ObjectId, ref: Room.name }) 16 | room?: Room; 17 | 18 | @Prop() 19 | order: number; 20 | 21 | @Prop({ type: ObjectId, ref: User.name }) 22 | from: User; 23 | 24 | @Prop({ type: ObjectId, ref: User.name }) 25 | to?: User; 26 | 27 | @Prop({ 28 | type: Date, 29 | default: Date.now, 30 | }) 31 | createdAt: Date; 32 | 33 | @Prop({ 34 | type: Date, 35 | default: Date.now, 36 | }) 37 | updatedAt: Date; 38 | } 39 | 40 | export const MessageSchema = createSchemaForClassWithMethods(Message); 41 | -------------------------------------------------------------------------------- /src/features/messages/service/message.service.ts: -------------------------------------------------------------------------------- 1 | import { forwardRef, Inject, Injectable, NotFoundException } from '@nestjs/common'; 2 | import { InjectModel } from '@nestjs/mongoose'; 3 | import { FilterQuery, Model } from 'mongoose'; 4 | import { Room } from '../../room/schema/room.schema'; 5 | import { RoomService } from '../../room/service/room.service'; 6 | import { User } from '../../user/schema/user.schema'; 7 | import { UserService } from '../../user/service/user.service'; 8 | import { Message } from '../schema/message.schema'; 9 | 10 | @Injectable() 11 | export class MessageService { 12 | constructor( 13 | @InjectModel(Message.name) private messageModel: Model, 14 | @Inject(forwardRef(() => RoomService)) private roomService: RoomService, 15 | private userService: UserService, 16 | ) {} 17 | 18 | getMessage(id: string) { 19 | return this.messageModel.findById(id).populate('from', this.userService.unpopulatedFields); 20 | } 21 | 22 | async validateMessage(id: string) { 23 | const message = await this.getMessage(id); 24 | 25 | if (!message) { 26 | throw new NotFoundException('Message not found'); 27 | } 28 | 29 | return message; 30 | } 31 | 32 | getPopulatedMessage(id: string) { 33 | return this.messageModel 34 | .findById(id) 35 | .populate('from', this.userService.unpopulatedFields) 36 | .populate('to', this.userService.unpopulatedFields) 37 | .populate('room'); 38 | } 39 | 40 | async validatePopulatedMessage(id: string) { 41 | const message = await this.getPopulatedMessage(id); 42 | 43 | if (!message) { 44 | throw new NotFoundException('Message not found'); 45 | } 46 | 47 | return message; 48 | } 49 | 50 | getFirstRoomMessage(room: Room) { 51 | return this.messageModel 52 | .findOne({ room: room._id }) 53 | .populate('from', this.userService.unpopulatedFields); 54 | } 55 | 56 | async getRoomMessages(room: Room, limit?: number, before?: Date) { 57 | const filter: FilterQuery = { 58 | room: room._id, 59 | createdAt: { $lte: before }, 60 | }; 61 | 62 | if (!before) { 63 | delete filter.createdAt; 64 | } 65 | 66 | return this.getMessages(filter, limit); 67 | } 68 | 69 | getDirectMessages(from: User, to: User, limit = 30, before?: Date) { 70 | const filter: FilterQuery = { 71 | ...this.getDirectMessageFilter(from, to), 72 | createdAt: { $lte: before }, 73 | }; 74 | 75 | if (!before) { 76 | delete filter.createdAt; 77 | } 78 | 79 | return this.getMessages(filter, limit); 80 | } 81 | 82 | private async getMessages(filter: FilterQuery, limit: number) { 83 | return this.sortMessages( 84 | await this.messageModel 85 | .find(filter) 86 | .limit(limit) 87 | .sort({ createdAt: -1 }) 88 | .populate('from', this.userService.unpopulatedFields), 89 | ); 90 | } 91 | 92 | sortMessages(messages: Message[]) { 93 | return messages.sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime()); 94 | } 95 | 96 | getFirstDirectMessage(from: User, to: User) { 97 | return this.messageModel 98 | .findOne(this.getDirectMessageFilter(from, to)) 99 | .populate('from', this.userService.unpopulatedFields); 100 | } 101 | 102 | private getDirectMessageFilter(from: User, to: User): FilterQuery { 103 | return { 104 | $or: [ 105 | { 106 | from: from._id, 107 | to: to._id, 108 | }, 109 | { 110 | to: from._id, 111 | from: to._id, 112 | }, 113 | ], 114 | }; 115 | } 116 | 117 | async createRoomMessage(from: User, room: Room, message: string) { 118 | const object = await this.messageModel.create({ 119 | from: from._id, 120 | room: room._id, 121 | message, 122 | }); 123 | 124 | return object.populate('from', this.userService.unpopulatedFields).execPopulate(); 125 | } 126 | 127 | async deleteRoomMessages(room: Room) { 128 | this.roomService.sendMessage(room, 'room:delete_messages', room.id); 129 | 130 | return this.messageModel.deleteMany({ room: room._id }); 131 | } 132 | 133 | async createDirectMessage(from: User, to: User, message: string) { 134 | const object = await this.messageModel.create({ 135 | from: from._id, 136 | to: to._id, 137 | message, 138 | }); 139 | 140 | return object.populate('from', this.userService.unpopulatedFields).execPopulate(); 141 | } 142 | 143 | async deleteDirectMessage(message: Message) { 144 | this.userService.sendMessage(message.from, 'direct:delete_message', message._id); 145 | 146 | this.userService.sendMessage(message.to, 'direct:delete_message', message._id); 147 | 148 | return this.messageModel.findOneAndDelete({ 149 | _id: message._id, 150 | to: message.to._id, 151 | }); 152 | } 153 | 154 | async deleteRoomMessage(room: Room, messageId: string) { 155 | this.roomService.sendMessage(room, 'room:delete_message', messageId); 156 | 157 | return this.messageModel.findOneAndDelete({ 158 | _id: messageId, 159 | room: room._id, 160 | }); 161 | } 162 | 163 | async deleteDirectMessages(from: User, to: User) { 164 | this.userService.sendMessage(from, 'direct:delete_messages', to.id); 165 | this.userService.sendMessage(to, 'direct:delete_messages', from.id); 166 | 167 | return this.messageModel.findOneAndDelete({ from: from._id, to: to._id }); 168 | } 169 | } 170 | -------------------------------------------------------------------------------- /src/features/notification/api/firebase.ts: -------------------------------------------------------------------------------- 1 | import { ConfigFactory } from 'code-config'; 2 | import { credential, initializeApp, messaging } from 'firebase-admin'; 3 | import { join } from 'path'; 4 | import { PATHS } from '../../../shared/constants/paths'; 5 | 6 | const config = ConfigFactory.getConfig(join(PATHS.config, 'firebase.config.json')).init(); 7 | 8 | const object = config.toObject(); 9 | 10 | initializeApp({ 11 | credential: 12 | Object.keys(object).length > 0 ? credential.cert(object) : credential.applicationDefault(), 13 | }); 14 | 15 | export const fcm = messaging(); 16 | -------------------------------------------------------------------------------- /src/features/notification/config/notification.config.ts: -------------------------------------------------------------------------------- 1 | import { ConfigFactory } from 'code-config/dist'; 2 | import { join } from 'path'; 3 | import { PATHS } from '../../../shared/constants/paths'; 4 | 5 | const defaultValue = { 6 | vapid: { 7 | subject: 'mailto:example@example.com', 8 | privateKey: '', 9 | publicKey: '', 10 | }, 11 | }; 12 | 13 | export const notificationConfig = ConfigFactory.getConfig( 14 | join(PATHS.config, 'notification.config.json'), 15 | defaultValue, 16 | ); 17 | 18 | notificationConfig.initPrettify(); 19 | -------------------------------------------------------------------------------- /src/features/notification/controller/notification.controller.ts: -------------------------------------------------------------------------------- 1 | import { Controller, Get, UseGuards } from '@nestjs/common'; 2 | import { notificationConfig } from '../config/notification.config'; 3 | import { JwtAuthGuard } from '../../auth/guard/jwt-auth.guard'; 4 | 5 | @UseGuards(JwtAuthGuard) 6 | @Controller('notification') 7 | export class NotificationController { 8 | @Get('config') 9 | getConfig() { 10 | return { webPublicKey: notificationConfig.vapid.publicKey }; 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/features/notification/notification.module.ts: -------------------------------------------------------------------------------- 1 | import { forwardRef, Module, OnModuleInit } from '@nestjs/common'; 2 | import { NotificationController } from './controller/notification.controller'; 3 | import { MobileNotificationService } from './service/mobile-notification.service'; 4 | import { WebNotificationService } from './service/web-notification.service'; 5 | import { generateVAPIDKeys, setVapidDetails } from 'web-push'; 6 | import { notificationConfig } from './config/notification.config'; 7 | import { AuthModule } from '../auth/auth.module'; 8 | import { environments } from '../../environments/environments'; 9 | 10 | @Module({ 11 | imports: [forwardRef(() => AuthModule)], 12 | controllers: [NotificationController], 13 | providers: [MobileNotificationService, WebNotificationService], 14 | exports: [MobileNotificationService, WebNotificationService], 15 | }) 16 | export class NotificationModule implements OnModuleInit { 17 | onModuleInit() { 18 | const vapid = notificationConfig.vapid; 19 | const envVapid = environments.vapid; 20 | 21 | vapid.publicKey = envVapid.publicKey || vapid.publicKey; 22 | vapid.privateKey = envVapid.privateKey || vapid.privateKey; 23 | vapid.subject = envVapid.subject || vapid.subject; 24 | 25 | if (vapid.publicKey && vapid.privateKey) { 26 | setVapidDetails(vapid.subject, vapid.publicKey, vapid.privateKey); 27 | 28 | return; 29 | } 30 | 31 | const { privateKey, publicKey } = generateVAPIDKeys(); 32 | 33 | notificationConfig.vapid = { 34 | ...vapid, 35 | privateKey, 36 | publicKey, 37 | }; 38 | 39 | notificationConfig.save(); 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/features/notification/service/mobile-notification.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { messaging } from 'firebase-admin'; 3 | import { fcm } from '../api/firebase'; 4 | 5 | @Injectable() 6 | export class MobileNotificationService { 7 | async sendNotification(token: string | string[], payload: messaging.MessagingPayload) { 8 | return fcm.sendToDevice(token, { 9 | ...payload, 10 | 11 | data: { 12 | ...payload.data, 13 | click_action: 'FLUTTER_NOTIFICATION_CLICK', 14 | }, 15 | }); 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/features/notification/service/web-notification.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { messaging } from 'firebase-admin'; 3 | import { PushSubscription, sendNotification } from 'web-push'; 4 | 5 | @Injectable() 6 | export class WebNotificationService { 7 | sendNotification(subscription: PushSubscription, payload: messaging.WebpushConfig) { 8 | return sendNotification( 9 | subscription, 10 | JSON.stringify({ 11 | ...payload, 12 | notification: { ...payload.notification, data: payload.data }, 13 | }), 14 | ); 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/features/room/controller/room.controller.ts: -------------------------------------------------------------------------------- 1 | import { Body, Controller, Delete, Get, Param, Post, Put, UseGuards } from '@nestjs/common'; 2 | import { ParseObjectIdPipe } from '../../../shared/pipe/parse-object-id.pipe'; 3 | import { CurrentUser } from '../../auth/decorators/current-user.decorator'; 4 | import { JwtAuthGuard } from '../../auth/guard/jwt-auth.guard'; 5 | import { User } from '../../user/schema/user.schema'; 6 | import { RoomDto } from '../dto/room.dto'; 7 | import { RoomService } from '../service/room.service'; 8 | 9 | @UseGuards(JwtAuthGuard) 10 | @Controller('room') 11 | export class RoomController { 12 | constructor(private roomService: RoomService) {} 13 | 14 | @Get() 15 | getUserRooms(@CurrentUser() user: User) { 16 | return this.roomService.getRoomsByOwner(user); 17 | } 18 | 19 | @Get('id/:id') 20 | get(@Param('id', ParseObjectIdPipe) id: string) { 21 | return this.roomService.getRoom(id); 22 | } 23 | 24 | @Get('public') 25 | getPublicRooms() { 26 | return this.roomService.getPublicRooms(); 27 | } 28 | 29 | @Get('member') 30 | getRoomsByMember(@CurrentUser() user: User) { 31 | return this.roomService.getRoomsByMember(user); 32 | } 33 | 34 | @Delete('delete/:id') 35 | async delete(@Param('id', ParseObjectIdPipe) id: string, @CurrentUser() user: User) { 36 | return this.roomService.delete(await this.roomService.validateRoomByIdAndOwner(id, user), user); 37 | } 38 | 39 | @Post() 40 | async create(@Body() room: RoomDto, @CurrentUser() user: User) { 41 | return this.roomService.create(room, user); 42 | } 43 | 44 | @Put(':id') 45 | async update( 46 | @Param('id', ParseObjectIdPipe) id: string, 47 | @Body() body: RoomDto, 48 | @CurrentUser() user: User, 49 | ) { 50 | return this.roomService.update( 51 | await this.roomService.validateRoomByIdAndOwner(id, user), 52 | body, 53 | user, 54 | ); 55 | } 56 | 57 | @Post('join') 58 | async join(@Body('roomId', ParseObjectIdPipe) id: string, @CurrentUser() user: User) { 59 | return this.roomService.join(id, user); 60 | } 61 | 62 | @Delete('leave/:id') 63 | async leave(@Param('id', ParseObjectIdPipe) id: string, @CurrentUser() user: User) { 64 | return this.roomService.leave(user, await this.roomService.validateRoom(id)); 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /src/features/room/dto/room.dto.ts: -------------------------------------------------------------------------------- 1 | import { IsBoolean, IsNotEmpty, IsString } from 'class-validator'; 2 | 3 | export class RoomDto { 4 | @IsNotEmpty() 5 | @IsString() 6 | title: string; 7 | 8 | @IsNotEmpty() 9 | @IsBoolean() 10 | isPublic: boolean; 11 | } 12 | -------------------------------------------------------------------------------- /src/features/room/gateway/room.gateway.ts: -------------------------------------------------------------------------------- 1 | import { 2 | forwardRef, 3 | Inject, 4 | UseFilters, 5 | UseGuards, 6 | UsePipes, 7 | ValidationPipe, 8 | } from '@nestjs/common'; 9 | import { 10 | ConnectedSocket, 11 | MessageBody, 12 | OnGatewayDisconnect, 13 | SubscribeMessage, 14 | WebSocketGateway, 15 | WebSocketServer, 16 | } from '@nestjs/websockets'; 17 | import { Server, Socket } from 'socket.io'; 18 | import { ExceptionsFilter } from '../../../core/filter/exceptions.filter'; 19 | import { JwtAuthGuard } from '../../auth/guard/jwt-auth.guard'; 20 | import { RoomService } from '../service/room.service'; 21 | 22 | @UsePipes(new ValidationPipe()) 23 | @UseFilters(new ExceptionsFilter()) 24 | @UseGuards(JwtAuthGuard) 25 | @WebSocketGateway() 26 | export class RoomGateway implements OnGatewayDisconnect { 27 | @WebSocketServer() server: Server; 28 | 29 | constructor(@Inject(forwardRef(() => RoomService)) private roomService: RoomService) {} 30 | 31 | handleDisconnect(socket: Socket) { 32 | this.roomService.unsubscribeSocket(socket); 33 | } 34 | 35 | @SubscribeMessage('room:subscribe') 36 | async subscribe(@ConnectedSocket() client: Socket, @MessageBody() roomId: string) { 37 | return this.roomService.subscribeSocket(client, await this.roomService.validateRoom(roomId)); 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/features/room/room.module.ts: -------------------------------------------------------------------------------- 1 | import { RoomService } from './service/room.service'; 2 | import { RoomController } from './controller/room.controller'; 3 | 4 | import { forwardRef, Module } from '@nestjs/common'; 5 | import { MongooseModule } from '@nestjs/mongoose'; 6 | import { Room, RoomSchema } from './schema/room.schema'; 7 | import { AuthModule } from '../auth/auth.module'; 8 | import { RoomGateway } from './gateway/room.gateway'; 9 | import { MessagesModule } from '../messages/messages.module'; 10 | import { SharedModule } from '../../shared/shared.module'; 11 | 12 | @Module({ 13 | imports: [ 14 | AuthModule, 15 | forwardRef(() => MessagesModule), 16 | MongooseModule.forFeature([ 17 | { 18 | name: Room.name, 19 | schema: RoomSchema, 20 | }, 21 | ]), 22 | SharedModule, 23 | ], 24 | controllers: [RoomController], 25 | providers: [RoomService, RoomGateway], 26 | exports: [RoomService, RoomGateway], 27 | }) 28 | export class RoomModule {} 29 | -------------------------------------------------------------------------------- /src/features/room/schema/room.schema.ts: -------------------------------------------------------------------------------- 1 | import { Prop, Schema } from '@nestjs/mongoose'; 2 | import { Document } from 'mongoose'; 3 | import { createSchemaForClassWithMethods } from '../../../shared/mongoose/create-schema'; 4 | import { ObjectId } from '../../../shared/mongoose/object-id'; 5 | import { User } from '../../user/schema/user.schema'; 6 | 7 | @Schema() 8 | export class Room extends Document { 9 | @Prop({ 10 | required: true, 11 | }) 12 | title: string; 13 | 14 | @Prop({ type: [{ type: ObjectId, ref: User.name }] }) 15 | members: User[]; 16 | 17 | @Prop({ type: ObjectId, ref: User.name }) 18 | owner: User; 19 | 20 | @Prop({ 21 | required: true, 22 | }) 23 | isPublic: boolean; 24 | } 25 | 26 | export const RoomSchema = createSchemaForClassWithMethods(Room); 27 | -------------------------------------------------------------------------------- /src/features/room/service/room.service.ts: -------------------------------------------------------------------------------- 1 | import { forwardRef, Inject, Injectable, NotFoundException } from '@nestjs/common'; 2 | import { InjectModel } from '@nestjs/mongoose'; 3 | import { Model, UpdateQuery } from 'mongoose'; 4 | import { Socket } from 'socket.io'; 5 | import { getSocketClient } from '../../../shared/utils/get-socket-client'; 6 | import { remove } from '../../../shared/utils/remove'; 7 | import { MessageService } from '../../messages/service/message.service'; 8 | import { User } from '../../user/schema/user.schema'; 9 | import { UserService } from '../../user/service/user.service'; 10 | import { RoomDto } from '../dto/room.dto'; 11 | import { RoomGateway } from '../gateway/room.gateway'; 12 | import { Room } from '../schema/room.schema'; 13 | 14 | @Injectable() 15 | export class RoomService { 16 | constructor( 17 | @InjectModel(Room.name) private roomModel: Model, 18 | private roomGateway: RoomGateway, 19 | private userService: UserService, 20 | @Inject(forwardRef(() => MessageService)) 21 | private messageService: MessageService, 22 | ) {} 23 | 24 | async create(room: RoomDto, user: User) { 25 | const object = await this.roomModel.create({ ...room, owner: user._id }); 26 | 27 | return object.populate('owner', this.userService.unpopulatedFields).execPopulate(); 28 | } 29 | 30 | async update(room: Room, body: UpdateQuery, user: User) { 31 | this.handleUpdateRoom(room, body as Room); 32 | 33 | return this.roomModel 34 | .findOneAndUpdate({ _id: room._id, owner: user._id }, body) 35 | .populate('owner', this.userService.unpopulatedFields); 36 | } 37 | 38 | handleUpdateRoom(room: Room, body: Partial) { 39 | this.sendMessage(room, 'room:update', Object.assign(room, body)); 40 | } 41 | 42 | delete(room: Room, user: User) { 43 | this.handleDeleteRoom(room); 44 | 45 | return Promise.all([ 46 | this.roomModel.findOneAndDelete({ _id: room._id, owner: user._id }), 47 | this.messageService.deleteRoomMessages(room), 48 | ]); 49 | } 50 | 51 | handleDeleteRoom(room: Room) { 52 | this.sendMessage(room, 'room:delete', room); 53 | } 54 | 55 | getRoomByIdAndOwner(roomId: string, owner: User) { 56 | return this.roomModel 57 | .findOne({ _id: roomId, owner: owner._id }) 58 | .populate('members', this.userService.unpopulatedFields) 59 | .populate('owner', this.userService.unpopulatedFields); 60 | } 61 | 62 | async validateRoomByIdAndOwner(roomId: string, owner: User) { 63 | const room = await this.getRoomByIdAndOwner(roomId, owner); 64 | 65 | if (!room) { 66 | throw new NotFoundException('Room not found'); 67 | } 68 | 69 | return room; 70 | } 71 | 72 | getRoom(roomId: string) { 73 | return this.roomModel 74 | .findById(roomId) 75 | .populate('members', this.userService.unpopulatedFields) 76 | .populate('owner', this.userService.unpopulatedFields); 77 | } 78 | 79 | async validateRoom(roomId: string) { 80 | const room = await this.getRoom(roomId); 81 | 82 | if (!room) { 83 | throw new NotFoundException('Room not found'); 84 | } 85 | 86 | return room; 87 | } 88 | 89 | getRoomsByMember(user: User) { 90 | return this.roomModel 91 | .find({ members: { $in: user._id } }) 92 | .populate('owner', this.userService.unpopulatedFields); 93 | } 94 | 95 | getPublicRooms() { 96 | return this.roomModel 97 | .find({ isPublic: true }) 98 | .populate('owner', this.userService.unpopulatedFields); 99 | } 100 | 101 | getRoomsByOwner(user: User) { 102 | return this.roomModel.find({ owner: user._id }); 103 | } 104 | 105 | getSockets(room: Room) { 106 | return this.roomGateway.server.in(`room_${room._id}`).allSockets(); 107 | } 108 | 109 | subscribeSocket(socket: Socket, room: Room) { 110 | return socket.join(`room_${room._id}`); 111 | } 112 | 113 | unsubscribeSocket(socket: Socket) { 114 | const room = getSocketClient(socket).room; 115 | 116 | if (!room) { 117 | return; 118 | } 119 | 120 | return socket.leave(`room_${room._id}`); 121 | } 122 | 123 | sendMessage(room: Room, event: string, message?: T) { 124 | return this.roomGateway.server.to(`room_${room._id}`).emit(event, message); 125 | } 126 | 127 | sendMessageExcept(except: Socket, room: Room, event: string, message: T) { 128 | return except.broadcast.to(`room_${room._id}`).emit(event, message); 129 | } 130 | 131 | async join(roomId: string, user: User) { 132 | const room = await this.validateRoom(roomId); 133 | 134 | if (!room.members.some(member => user.id === member.id)) { 135 | room.members.push(user._id); 136 | 137 | this.handleJoinRoom(user, room); 138 | 139 | return room.save(); 140 | } 141 | 142 | return room.populate('members', this.userService.unpopulatedFields).execPopulate(); 143 | } 144 | 145 | handleJoinRoom(user: User, room: Room) { 146 | this.sendMessage(room, 'room:join', this.userService.filterUser(user)); 147 | } 148 | 149 | async leave(user: User, room: Room) { 150 | remove(room.members, member => member.id === user.id); 151 | 152 | this.handleLeaveRoom(user, room); 153 | 154 | return room.save(); 155 | } 156 | 157 | handleLeaveRoom(user: User, room: Room) { 158 | this.sendMessage(room, 'room:leave', this.userService.filterUser(user)); 159 | } 160 | } 161 | -------------------------------------------------------------------------------- /src/features/user/controller/recover.controller.ts: -------------------------------------------------------------------------------- 1 | import { MailerService } from '@nestjs-modules/mailer'; 2 | import { 3 | BadRequestException, 4 | Body, 5 | Controller, 6 | Get, 7 | InternalServerErrorException, 8 | NotFoundException, 9 | Param, 10 | Post, 11 | } from '@nestjs/common'; 12 | import { UserService } from '../service/user.service'; 13 | import { Recover } from '../schema/recover.schema'; 14 | import { RecoverService } from '../service/recover.service'; 15 | import { RecoverPasswordDto } from '../dto/recover-password.dto'; 16 | import { UpdatePasswordDto } from '../dto/update-password.dto'; 17 | import { environments } from '../../../environments/environments'; 18 | import { User } from '../schema/user.schema'; 19 | 20 | @Controller('recover') 21 | export class RecoverController { 22 | constructor( 23 | private userService: UserService, 24 | private recoverService: RecoverService, 25 | private mailerService: MailerService, 26 | ) {} 27 | 28 | @Get(':code') 29 | async validateRecoverCode(@Param('code') code: Recover['code']) { 30 | const recover = await this.validateCode(code); 31 | 32 | recover.owner = this.userService.filterUser(recover.owner) as User; 33 | 34 | return recover; 35 | } 36 | 37 | @Post() 38 | async recoverPassword(@Body() body: RecoverPasswordDto) { 39 | const user = await this.userService.validateUserByEmail(body.email); 40 | 41 | const { code, expiration } = await this.recoverService.create(user); 42 | 43 | const url = environments.frontEndUrl; 44 | 45 | try { 46 | await this.mailerService.sendMail({ 47 | to: user.email, 48 | subject: 'Recover your password', 49 | template: './recover', // This will fetch /template/recover.hbs 50 | context: { 51 | name: user.username, 52 | url, 53 | code, 54 | expiration: Math.round((expiration.getTime() - Date.now()) / 1000 / 60 / 60), 55 | }, 56 | }); 57 | } catch (e) { 58 | throw new InternalServerErrorException(`An error occurred sending email: ${e.message}`); 59 | } 60 | } 61 | 62 | @Post(':code') 63 | async changePassword(@Param('code') code: Recover['code'], @Body() body: UpdatePasswordDto) { 64 | const recover = await this.validateCode(code); 65 | 66 | if (body.password !== body.confirmPassword) { 67 | throw new BadRequestException(`Passwords does not match`); 68 | } 69 | 70 | const user = recover.owner; 71 | 72 | if (await user.validatePassword(body.password)) { 73 | throw new BadRequestException('Do not use your current password'); 74 | } 75 | 76 | user.password = body.password; 77 | 78 | await this.recoverService.delete(user); 79 | 80 | return this.userService.filterUser(await user.save()); 81 | } 82 | 83 | private async validateCode(code: string) { 84 | const recover = await this.recoverService.get(code); 85 | 86 | if (!recover) { 87 | throw new NotFoundException('Code not found'); 88 | } 89 | 90 | if (recover.expiration?.getTime() < Date.now()) { 91 | await this.recoverService.delete(recover.owner); 92 | 93 | throw new NotFoundException('Code has expired'); 94 | } 95 | 96 | return recover; 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /src/features/user/controller/settings.controller.ts: -------------------------------------------------------------------------------- 1 | import { BadRequestException, Body, Controller, Put, UseGuards } from '@nestjs/common'; 2 | import { CurrentUser } from '../../auth/decorators/current-user.decorator'; 3 | import { JwtAuthGuard } from '../../auth/guard/jwt-auth.guard'; 4 | import { UpdateEmailDto } from '../dto/update-email.dto'; 5 | import { UpdatePasswordDto } from '../dto/update-password.dto'; 6 | import { User } from '../schema/user.schema'; 7 | import { UserService } from '../service/user.service'; 8 | 9 | @Controller('settings') 10 | @UseGuards(JwtAuthGuard) 11 | export class SettingsController { 12 | constructor(private userService: UserService) {} 13 | 14 | @Put('username') 15 | async updateUsername(@CurrentUser() user: User, @Body('username') username: string) { 16 | const usernameUser = await this.userService.getUserByName(username); 17 | 18 | if (usernameUser) { 19 | throw new BadRequestException('Username already exists'); 20 | } 21 | 22 | user.username = username; 23 | 24 | return user.save(); 25 | } 26 | 27 | @Put('email') 28 | async updateEmail(@CurrentUser() user: User, @Body() body: UpdateEmailDto) { 29 | const emailUser = await this.userService.getUserByEmail(body.email); 30 | 31 | if (emailUser) { 32 | throw new BadRequestException('Email already exists'); 33 | } 34 | 35 | user.email = body.email; 36 | 37 | return user.save(); 38 | } 39 | 40 | @Put('password') 41 | async updatePassword(@CurrentUser() user: User, @Body() body: UpdatePasswordDto) { 42 | if (!user.isSocial && !(await user.validatePassword(body.currentPassword))) { 43 | throw new BadRequestException('Current password does not match'); 44 | } 45 | 46 | if (body.password !== body.confirmPassword) { 47 | throw new BadRequestException('Passwords does not match'); 48 | } 49 | 50 | if (await user.validatePassword(body.password)) { 51 | throw new BadRequestException('Do not use your current password'); 52 | } 53 | 54 | user.password = body.password; 55 | 56 | return user.save(); 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /src/features/user/controller/subscription.controller.ts: -------------------------------------------------------------------------------- 1 | import { 2 | BadRequestException, 3 | Body, 4 | Controller, 5 | Delete, 6 | Get, 7 | Post, 8 | UseGuards, 9 | } from '@nestjs/common'; 10 | import { CurrentUser } from '../../auth/decorators/current-user.decorator'; 11 | import { JwtAuthGuard } from '../../auth/guard/jwt-auth.guard'; 12 | import { SubscriptionType } from '../schema/subscription.schema'; 13 | import { User } from '../schema/user.schema'; 14 | import { SubscriptionService } from '../service/subscription.service'; 15 | 16 | @UseGuards(JwtAuthGuard) 17 | @Controller('subscription') 18 | export class SubscriptionController { 19 | constructor(private subscriptionService: SubscriptionService) {} 20 | 21 | @Get() 22 | sendTestingNotification(@CurrentUser() user: User) { 23 | return this.subscriptionService.sendNotification(user, { 24 | notification: { 25 | title: 'Testing', 26 | body: 'Testing notification', 27 | }, 28 | }); 29 | } 30 | 31 | @Post('web') 32 | createWebSubscription( 33 | @Body('subscription') body: PushSubscriptionJSON, 34 | @CurrentUser() user: User, 35 | ) { 36 | return this.createSubscription(user, SubscriptionType.Web, JSON.stringify(body)); 37 | } 38 | 39 | @Post('mobile') 40 | createMobileSubscription(@Body('subscription') body: string, @CurrentUser() user: User) { 41 | return this.createSubscription(user, SubscriptionType.Mobile, body); 42 | } 43 | 44 | private async createSubscription(user: User, type: SubscriptionType, body: string) { 45 | if (!body) { 46 | throw new BadRequestException('Subscription body empty'); 47 | } 48 | 49 | const subscription = await this.subscriptionService.get(user, type, body); 50 | 51 | return subscription || this.subscriptionService.create(user, type, body); 52 | } 53 | 54 | @Delete('web') 55 | deleteWebSubscription( 56 | @Body('subscription') body: PushSubscriptionJSON, 57 | @CurrentUser() user: User, 58 | ) { 59 | return this.deleteSubscription(user, SubscriptionType.Web, JSON.stringify(body)); 60 | } 61 | 62 | @Delete('mobile') 63 | deleteMobileSubscription(@Body('subscription') body: string, @CurrentUser() user: User) { 64 | return this.deleteSubscription(user, SubscriptionType.Mobile, body); 65 | } 66 | 67 | private async deleteSubscription(user: User, type: SubscriptionType, body: string) { 68 | if (!body) { 69 | throw new BadRequestException('Subscription body empty'); 70 | } 71 | 72 | return this.subscriptionService.delete(user, type, body); 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /src/features/user/controller/user.controller.ts: -------------------------------------------------------------------------------- 1 | import { Controller, Get, Param } from '@nestjs/common'; 2 | import { UserService } from '../service/user.service'; 3 | 4 | @Controller('user') 5 | export class UserController { 6 | constructor(private userService: UserService) {} 7 | 8 | @Get(':username') 9 | async getUser(@Param('username') username: string) { 10 | return this.userService.filterUser(await this.userService.validateUserByName(username)); 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/features/user/dto/recover-password.dto.ts: -------------------------------------------------------------------------------- 1 | import { IsEmail, IsString } from 'class-validator'; 2 | 3 | export class RecoverPasswordDto { 4 | @IsString() 5 | @IsEmail() 6 | email: string; 7 | } 8 | -------------------------------------------------------------------------------- /src/features/user/dto/update-email.dto.ts: -------------------------------------------------------------------------------- 1 | import { IsEmail } from 'class-validator'; 2 | 3 | export class UpdateEmailDto { 4 | @IsEmail() 5 | email: string; 6 | } 7 | -------------------------------------------------------------------------------- /src/features/user/dto/update-password.dto.ts: -------------------------------------------------------------------------------- 1 | import { IsNotEmpty, IsOptional, IsString, MaxLength, MinLength } from 'class-validator'; 2 | 3 | export class UpdatePasswordDto { 4 | @IsOptional() 5 | @IsString() 6 | currentPassword?: string; 7 | 8 | @IsNotEmpty() 9 | @IsString() 10 | @MinLength(6) 11 | @MaxLength(60) 12 | password: string; 13 | 14 | @IsNotEmpty() 15 | @IsString() 16 | confirmPassword: string; 17 | } 18 | -------------------------------------------------------------------------------- /src/features/user/dto/update-username.dto.ts: -------------------------------------------------------------------------------- 1 | import { IsNotEmpty, IsString, Matches } from 'class-validator'; 2 | 3 | export class UpdateUsernameDto { 4 | @IsNotEmpty() 5 | @IsString() 6 | @Matches(/[a-zA-Z0-9_-]{2,20}/, { 7 | message: 'Invalid username', 8 | }) 9 | username: string; 10 | } 11 | -------------------------------------------------------------------------------- /src/features/user/gateway/user.gateway.ts: -------------------------------------------------------------------------------- 1 | import { forwardRef, Inject, Logger, UseGuards } from '@nestjs/common'; 2 | import { 3 | WebSocketGateway, 4 | WebSocketServer, 5 | SubscribeMessage, 6 | ConnectedSocket, 7 | OnGatewayDisconnect, 8 | OnGatewayConnection, 9 | } from '@nestjs/websockets'; 10 | import { hostname } from 'os'; 11 | import { Server, Socket } from 'socket.io'; 12 | import { getSocketUser } from '../../../shared/utils/get-socket-user'; 13 | import { CurrentUser } from '../../auth/decorators/current-user.decorator'; 14 | import { JwtAuthGuard } from '../../auth/guard/jwt-auth.guard'; 15 | import { User } from '../schema/user.schema'; 16 | import { UserService } from '../service/user.service'; 17 | 18 | @UseGuards(JwtAuthGuard) 19 | @WebSocketGateway() 20 | export class UserGateway implements OnGatewayDisconnect, OnGatewayConnection { 21 | @WebSocketServer() 22 | server: Server; 23 | 24 | logger = new Logger(this.constructor.name); 25 | 26 | online = 0; 27 | 28 | constructor(@Inject(forwardRef(() => UserService)) private userService: UserService) {} 29 | 30 | handleConnection() { 31 | this.online++; 32 | } 33 | 34 | handleDisconnect(socket: Socket) { 35 | this.online--; 36 | 37 | const user = getSocketUser(socket); 38 | 39 | if (!user) { 40 | return; 41 | } 42 | 43 | this.logger.log(`User ${user.username} left the server ${hostname()}; ${this.online}`); 44 | 45 | return this.userService.unsubscribeSocket(socket, user); 46 | } 47 | 48 | @SubscribeMessage('user:subscribe') 49 | async subscribe(@ConnectedSocket() client: Socket, @CurrentUser() user: User) { 50 | this.logger.log(`User ${user.username} joined the server ${hostname()}; ${this.online}`); 51 | 52 | return this.userService.subscribeSocket(client, user); 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /src/features/user/schema/recover.schema.ts: -------------------------------------------------------------------------------- 1 | import { Prop, Schema } from '@nestjs/mongoose'; 2 | import { Document } from 'mongoose'; 3 | import { createSchemaForClassWithMethods } from '../../../shared/mongoose/create-schema'; 4 | import { ObjectId } from '../../../shared/mongoose/object-id'; 5 | import { User } from './user.schema'; 6 | 7 | @Schema() 8 | export class Recover extends Document { 9 | @Prop() 10 | code: string; 11 | 12 | @Prop({ type: ObjectId, ref: User.name }) 13 | owner: User; 14 | 15 | @Prop() 16 | expiration: Date; 17 | } 18 | 19 | export const RecoverSchema = createSchemaForClassWithMethods(Recover); 20 | -------------------------------------------------------------------------------- /src/features/user/schema/socket-connection.schema.ts: -------------------------------------------------------------------------------- 1 | import { Prop, Schema } from '@nestjs/mongoose'; 2 | import { Document } from 'mongoose'; 3 | import { createSchemaForClassWithMethods } from '../../../shared/mongoose/create-schema'; 4 | import { ObjectId } from '../../../shared/mongoose/object-id'; 5 | import { User } from './user.schema'; 6 | 7 | @Schema() 8 | export class SocketConnection extends Document { 9 | @Prop() 10 | socketId: string; 11 | 12 | @Prop() 13 | serverHostname: string; 14 | 15 | @Prop() 16 | serverPort: number; 17 | 18 | @Prop({ type: ObjectId, ref: User.name }) 19 | user: User; 20 | } 21 | 22 | export const SocketConnectionSchema = createSchemaForClassWithMethods(SocketConnection); 23 | -------------------------------------------------------------------------------- /src/features/user/schema/subscription.schema.ts: -------------------------------------------------------------------------------- 1 | import { Prop, Schema } from '@nestjs/mongoose'; 2 | import { Document } from 'mongoose'; 3 | import { createSchemaForClassWithMethods } from '../../../shared/mongoose/create-schema'; 4 | import { ObjectId } from '../../../shared/mongoose/object-id'; 5 | import { User } from './user.schema'; 6 | 7 | export enum SubscriptionType { 8 | Web = 'web', 9 | Mobile = 'mobile', 10 | } 11 | 12 | @Schema() 13 | export class Subscription extends Document { 14 | @Prop({ 15 | type: String, 16 | enum: Object.values(SubscriptionType), 17 | }) 18 | type: SubscriptionType; 19 | 20 | @Prop() 21 | subscription: string; 22 | 23 | @Prop({ type: ObjectId, ref: User.name }) 24 | user: User; 25 | } 26 | 27 | export const SubscriptionSchema = createSchemaForClassWithMethods(Subscription); 28 | -------------------------------------------------------------------------------- /src/features/user/schema/user.schema.ts: -------------------------------------------------------------------------------- 1 | import * as bcrypt from 'bcrypt'; 2 | import { Prop, Schema } from '@nestjs/mongoose'; 3 | import { Document } from 'mongoose'; 4 | import { createSchemaForClassWithMethods } from '../../../shared/mongoose/create-schema'; 5 | import { randomString } from '../../../shared/utils/random-string'; 6 | 7 | @Schema() 8 | export class User extends Document { 9 | @Prop() 10 | username: string; 11 | 12 | @Prop() 13 | email: string; 14 | 15 | @Prop() 16 | sessionToken: string; 17 | 18 | @Prop({ default: false }) 19 | online: boolean; 20 | 21 | @Prop() 22 | password?: string; 23 | 24 | @Prop() 25 | facebookId?: string; 26 | 27 | @Prop() 28 | googleId?: string; 29 | 30 | @Prop() 31 | appleId?: string; 32 | 33 | get isSocial(): boolean { 34 | return !!(this.facebookId || this.googleId || this.appleId); 35 | } 36 | 37 | generateSessionToken() { 38 | this.sessionToken = randomString(60); 39 | } 40 | 41 | validatePassword(password: string): Promise { 42 | return bcrypt.compare(password, this.password || ''); 43 | } 44 | } 45 | 46 | export const UserSchema = createSchemaForClassWithMethods(User); 47 | 48 | // Update password into a hashed one. 49 | UserSchema.pre('save', async function(next) { 50 | const user: User = this as any; 51 | 52 | if (!user.password || user.password.startsWith('$')) { 53 | return next(); 54 | } 55 | 56 | try { 57 | const salt = await bcrypt.genSalt(); 58 | 59 | user.password = await bcrypt.hash(user.password, salt); 60 | 61 | next(); 62 | } catch (e) { 63 | next(e); 64 | } 65 | }); 66 | -------------------------------------------------------------------------------- /src/features/user/service/recover.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { InjectModel } from '@nestjs/mongoose'; 3 | import { Model } from 'mongoose'; 4 | import { randomString } from '../../../shared/utils/random-string'; 5 | import { User } from '../schema/user.schema'; 6 | import { Recover } from '../schema/recover.schema'; 7 | import { environments } from '../../../environments/environments'; 8 | 9 | @Injectable() 10 | export class RecoverService { 11 | constructor(@InjectModel(Recover.name) private recoveryModel: Model) {} 12 | 13 | async create(user: User) { 14 | await this.delete(user); 15 | 16 | return this.recoveryModel.create({ 17 | code: randomString(50), 18 | owner: user._id, 19 | expiration: new Date(Date.now() + environments.recoverCodeExpiration * 1000), 20 | }); 21 | } 22 | 23 | get(code: Recover['code']) { 24 | return this.recoveryModel.findOne({ code }).populate('owner'); 25 | } 26 | 27 | delete(user: User) { 28 | return this.recoveryModel.deleteMany({ owner: user._id }); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/features/user/service/socket-connection.service.ts: -------------------------------------------------------------------------------- 1 | import { forwardRef, Inject, Injectable } from '@nestjs/common'; 2 | import { InjectModel } from '@nestjs/mongoose'; 3 | import { Model } from 'mongoose'; 4 | import { hostname } from 'os'; 5 | import { Socket } from 'socket.io'; 6 | import { environments } from '../../../environments/environments'; 7 | import { SocketConnection } from '../schema/socket-connection.schema'; 8 | import { User } from '../schema/user.schema'; 9 | import { UserService } from './user.service'; 10 | 11 | @Injectable() 12 | export class SocketConnectionService { 13 | constructor( 14 | @InjectModel(SocketConnection.name) 15 | private socketConnectionModel: Model, 16 | @Inject(forwardRef(() => UserService)) private userService: UserService, 17 | ) {} 18 | 19 | async create(socket: Socket, user: User) { 20 | const connection = await this.socketConnectionModel.create({ 21 | user: user._id, 22 | socketId: socket.id, 23 | serverHostname: hostname(), 24 | serverPort: environments.port, 25 | }); 26 | 27 | if (!user.online) { 28 | user.online = true; 29 | 30 | await user.save(); 31 | } 32 | 33 | return connection.populate('user').execPopulate(); 34 | } 35 | 36 | getAll(user: User) { 37 | return this.socketConnectionModel.find({ user: user._id }); 38 | } 39 | 40 | getById(id: string) { 41 | return this.socketConnectionModel.findById(id).populate('user'); 42 | } 43 | 44 | getBySocket(socket: Socket) { 45 | return this.socketConnectionModel.findOne({ socketId: socket.id }).populate('user'); 46 | } 47 | 48 | async deleteAllConnections() { 49 | await this.socketConnectionModel.deleteMany({ 50 | serverHostname: hostname(), 51 | serverPort: environments.port, 52 | }); 53 | 54 | const users = await this.userService.getOnlineUsers(); 55 | 56 | for (const user of users) { 57 | const connections = await this.getAll(user); 58 | 59 | if (connections.length === 0) { 60 | user.online = false; 61 | 62 | await user.save(); 63 | } 64 | } 65 | } 66 | 67 | async delete(socket: Socket) { 68 | const connection = await this.getBySocket(socket); 69 | 70 | if (!connection) { 71 | return; 72 | } 73 | 74 | await this.socketConnectionModel.findByIdAndDelete(connection._id); 75 | 76 | const user = connection.user; 77 | 78 | const connections = await this.getAll(user); 79 | 80 | if (connections.length === 0) { 81 | user.online = false; 82 | 83 | await user.save(); 84 | } 85 | 86 | return connection; 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /src/features/user/service/subscription.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable, Logger } from '@nestjs/common'; 2 | import { InjectModel } from '@nestjs/mongoose'; 3 | import { Model } from 'mongoose'; 4 | import { User } from '../schema/user.schema'; 5 | import { SubscriptionType, Subscription } from '../schema/subscription.schema'; 6 | import { MobileNotificationService } from '../../notification/service/mobile-notification.service'; 7 | import { WebNotificationService } from '../../notification/service/web-notification.service'; 8 | import { messaging } from 'firebase-admin'; 9 | import { Dictionary } from 'code-config/dist'; 10 | 11 | export interface NotificationPayload { 12 | notification: messaging.NotificationMessagePayload; 13 | webData: Dictionary; 14 | mobileData: Dictionary; 15 | } 16 | 17 | export enum NotificationType { 18 | Room = 'room', 19 | Direct = 'direct', 20 | } 21 | 22 | @Injectable() 23 | export class SubscriptionService { 24 | private logger = new Logger(this.constructor.name); 25 | 26 | constructor( 27 | private webNotificationService: WebNotificationService, 28 | private mobileNotificationService: MobileNotificationService, 29 | @InjectModel(Subscription.name) 30 | private subscriptionModel: Model, 31 | ) {} 32 | 33 | getAll(user: User) { 34 | return this.subscriptionModel.find({ user: user._id }); 35 | } 36 | 37 | get(user: User, type: SubscriptionType, subscription: string) { 38 | return this.subscriptionModel.findOne({ 39 | user: user._id, 40 | type, 41 | subscription, 42 | }); 43 | } 44 | 45 | create(user: User, type: SubscriptionType, subscription: string) { 46 | return this.subscriptionModel.create({ 47 | user: user._id, 48 | type, 49 | subscription, 50 | }); 51 | } 52 | 53 | delete(user: User, type: SubscriptionType, subscription: string) { 54 | return this.subscriptionModel.findOneAndDelete({ 55 | user: user._id, 56 | type, 57 | subscription, 58 | }); 59 | } 60 | 61 | deleteAll(user: User) { 62 | return this.subscriptionModel.deleteMany({ user: user._id }); 63 | } 64 | 65 | async sendNotification(user: User, payload: Partial) { 66 | const subscriptions = await this.getAll(user); 67 | 68 | for (const subscription of subscriptions) { 69 | switch (subscription.type) { 70 | case SubscriptionType.Web: 71 | this.webNotificationService 72 | .sendNotification(JSON.parse(subscription.subscription), { 73 | notification: payload.notification, 74 | data: payload.webData, 75 | }) 76 | .catch(e => this.logger.debug(`${subscription.type} ${e}`)); 77 | break; 78 | case SubscriptionType.Mobile: 79 | this.mobileNotificationService 80 | .sendNotification(subscription.subscription, { 81 | notification: payload.notification, 82 | data: payload.mobileData, 83 | }) 84 | .catch(e => this.logger.debug(`${subscription.type} ${e}`)); 85 | break; 86 | default: 87 | break; 88 | } 89 | } 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /src/features/user/service/user.service.ts: -------------------------------------------------------------------------------- 1 | import { forwardRef, Inject, Injectable, NotFoundException } from '@nestjs/common'; 2 | import { InjectModel } from '@nestjs/mongoose'; 3 | import { FilterQuery, Model, UpdateQuery } from 'mongoose'; 4 | import { ObjectId } from 'mongodb'; 5 | import { User } from '../schema/user.schema'; 6 | import { randomString } from '../../../shared/utils/random-string'; 7 | import { UserGateway } from '../gateway/user.gateway'; 8 | import { Socket } from 'socket.io'; 9 | import { SocketConnectionService } from './socket-connection.service'; 10 | 11 | @Injectable() 12 | export class UserService { 13 | private blockedFields: (keyof User)[] = [ 14 | 'password', 15 | 'sessionToken', 16 | 'email', 17 | 'facebookId', 18 | 'googleId', 19 | 'appleId', 20 | ]; 21 | 22 | unpopulatedFields = '-' + this.blockedFields.join(' -'); 23 | 24 | constructor( 25 | @InjectModel(User.name) private userModel: Model, 26 | @Inject(forwardRef(() => UserGateway)) private userGateway: UserGateway, 27 | private socketConnectionService: SocketConnectionService, 28 | ) {} 29 | 30 | getUserByName(name: string) { 31 | const username = { $regex: new RegExp(`^${name}$`, 'i') }; 32 | 33 | return this.userModel.findOne({ username }); 34 | } 35 | 36 | async validateUserByName(username: string) { 37 | const user = await this.getUserByName(username); 38 | 39 | if (!user) { 40 | throw new NotFoundException('User not found'); 41 | } 42 | 43 | return user; 44 | } 45 | 46 | getUserByEmail(mail: string) { 47 | const email = { $regex: new RegExp(`^${mail}$`, 'i') }; 48 | 49 | return this.userModel.findOne({ email }); 50 | } 51 | 52 | async validateUserByEmail(email: string) { 53 | const user = await this.getUserByEmail(email); 54 | 55 | if (!user) { 56 | throw new NotFoundException('User not found'); 57 | } 58 | 59 | return user; 60 | } 61 | 62 | getUserBy(filter: FilterQuery) { 63 | return this.userModel.findOne(filter); 64 | } 65 | 66 | getUserByGoogleId(id: string) { 67 | return this.userModel.findOne({ googleId: id }); 68 | } 69 | 70 | getUserById(id: ObjectId | string) { 71 | return this.userModel.findById(id); 72 | } 73 | 74 | async validateUserById(id: string) { 75 | const user = await this.getUserById(id); 76 | 77 | if (!user) { 78 | throw new NotFoundException('User not found'); 79 | } 80 | 81 | return user; 82 | } 83 | 84 | getOnlineUsers() { 85 | return this.userModel.find({ online: true }); 86 | } 87 | 88 | async subscribeSocket(socket: Socket, user: User) { 89 | await this.socketConnectionService.create(socket, user); 90 | 91 | return socket.join(`user_${user._id}`); 92 | } 93 | 94 | async unsubscribeSocket(socket: Socket, user: User) { 95 | await this.socketConnectionService.delete(socket); 96 | 97 | return socket.leave(`user_${user._id}`); 98 | } 99 | 100 | sendMessage(user: User, event: string, message?: T) { 101 | return this.userGateway.server.to(`user_${user._id}`).emit(event, message); 102 | } 103 | 104 | sendMessageExcept(except: Socket, user: User, event: string, message: T) { 105 | return except.broadcast.to(`user_${user._id}`).emit(event, message); 106 | } 107 | 108 | async generateUsername(base: string) { 109 | const name = base.replace(/\s/g, ''); 110 | 111 | if (!(await this.getUserByName(name))) { 112 | return name; 113 | } 114 | 115 | return this.generateUsername(name + randomString(1)); 116 | } 117 | 118 | async getUser(username: string) { 119 | return (await this.getUserByName(username)) ?? (await this.getUserByEmail(username)); 120 | } 121 | 122 | filterUser(user: User, allowedFields: (keyof User)[] = []) { 123 | const userObject = user.toObject({ virtuals: true }); 124 | 125 | for (const field of this.blockedFields) { 126 | if (allowedFields.includes(field)) { 127 | continue; 128 | } 129 | 130 | delete userObject[field]; 131 | } 132 | 133 | return userObject; 134 | } 135 | 136 | async create(body: Partial) { 137 | const user = await this.userModel.create(body); 138 | 139 | user.generateSessionToken(); 140 | 141 | return user.save(); 142 | } 143 | } 144 | -------------------------------------------------------------------------------- /src/features/user/user.module.ts: -------------------------------------------------------------------------------- 1 | import { UserController } from './controller/user.controller'; 2 | import { SettingsController } from './controller/settings.controller'; 3 | import { forwardRef, Module, OnModuleDestroy, OnModuleInit } from '@nestjs/common'; 4 | import { AuthModule } from '../auth/auth.module'; 5 | import { UserService } from './service/user.service'; 6 | import { UserGateway } from './gateway/user.gateway'; 7 | import { MongooseModule } from '@nestjs/mongoose'; 8 | import { User, UserSchema } from './schema/user.schema'; 9 | import { SubscriptionService } from './service/subscription.service'; 10 | import { Subscription, SubscriptionSchema } from './schema/subscription.schema'; 11 | import { NotificationModule } from '../notification/notification.module'; 12 | import { SubscriptionController } from './controller/subscription.controller'; 13 | import { SocketConnection, SocketConnectionSchema } from './schema/socket-connection.schema'; 14 | import { SocketConnectionService } from './service/socket-connection.service'; 15 | import { Recover, RecoverSchema } from './schema/recover.schema'; 16 | import { RecoverController } from './controller/recover.controller'; 17 | import { RecoverService } from './service/recover.service'; 18 | 19 | @Module({ 20 | imports: [ 21 | MongooseModule.forFeature([ 22 | { 23 | name: User.name, 24 | schema: UserSchema, 25 | }, 26 | { 27 | name: Recover.name, 28 | schema: RecoverSchema, 29 | }, 30 | { 31 | name: Subscription.name, 32 | schema: SubscriptionSchema, 33 | }, 34 | { 35 | name: SocketConnection.name, 36 | schema: SocketConnectionSchema, 37 | }, 38 | ]), 39 | forwardRef(() => AuthModule), 40 | forwardRef(() => NotificationModule), 41 | ], 42 | controllers: [UserController, SettingsController, SubscriptionController, RecoverController], 43 | providers: [ 44 | UserService, 45 | UserGateway, 46 | SubscriptionService, 47 | SocketConnectionService, 48 | RecoverService, 49 | ], 50 | exports: [ 51 | UserService, 52 | UserGateway, 53 | SubscriptionService, 54 | NotificationModule, 55 | SocketConnectionService, 56 | ], 57 | }) 58 | export class UserModule implements OnModuleInit, OnModuleDestroy { 59 | constructor(private socketConnectionService: SocketConnectionService) {} 60 | 61 | onModuleInit() { 62 | return this.deleteConnections(); 63 | } 64 | 65 | onModuleDestroy() { 66 | return this.deleteConnections(); 67 | } 68 | 69 | private deleteConnections() { 70 | return this.socketConnectionService.deleteAllConnections(); 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | import { Logger } from '@nestjs/common'; 2 | import { NestFactory } from '@nestjs/core'; 3 | import { AppModule } from './app.module'; 4 | import { RedisIoAdapter } from './core/adapter/redis-io.adapter'; 5 | import { NestExpressApplication } from '@nestjs/platform-express'; 6 | import { environments } from './environments/environments'; 7 | import { CustomSocketIoAdapter } from './core/adapter/custom-socket-io.adapter'; 8 | 9 | const redis = environments.redis; 10 | 11 | async function bootstrap() { 12 | const app = await NestFactory.create(AppModule); 13 | 14 | app.enableCors(); 15 | app.enableShutdownHooks(); 16 | app.set('trust proxy', environments.proxyEnabled); 17 | 18 | if (redis.enabled) { 19 | app.useWebSocketAdapter(new RedisIoAdapter(redis.host, redis.port, app)); 20 | } else { 21 | app.useWebSocketAdapter(new CustomSocketIoAdapter(app)); 22 | } 23 | 24 | const port = environments.port; 25 | const logger = new Logger('NestApplication'); 26 | 27 | await app.listen(port, () => logger.log(`Server initialized on port ${port}`)); 28 | } 29 | 30 | bootstrap(); 31 | -------------------------------------------------------------------------------- /src/shared/constants/paths.ts: -------------------------------------------------------------------------------- 1 | import { join } from 'path'; 2 | 3 | export const PATHS = { 4 | config: join(__dirname, '../../../config/'), 5 | secrets: join(__dirname, '../../../secrets/'), 6 | }; 7 | -------------------------------------------------------------------------------- /src/shared/mongoose/create-schema.ts: -------------------------------------------------------------------------------- 1 | import { SchemaFactory } from '@nestjs/mongoose'; 2 | 3 | /** 4 | * Allow the schema to contain defined methods. 5 | * @param target 6 | * @returns 7 | */ 8 | export function createSchemaForClassWithMethods(target: new () => T) { 9 | const schema = SchemaFactory.createForClass(target); 10 | const proto = target.prototype; 11 | const descriptors = Object.getOwnPropertyDescriptors(proto); 12 | 13 | for (const name in descriptors) { 14 | if (name != 'constructor' && typeof proto[name] === 'function') { 15 | schema.methods[name] = proto[name]; 16 | } 17 | 18 | if (descriptors[name].get || descriptors[name].set) { 19 | schema 20 | .virtual(name, { 21 | toObject: { virtuals: true }, 22 | toJSON: { virtuals: true }, 23 | }) 24 | .get(descriptors[name].get) 25 | .set(descriptors[name].set); 26 | } 27 | } 28 | 29 | return schema; 30 | } 31 | -------------------------------------------------------------------------------- /src/shared/mongoose/object-id.ts: -------------------------------------------------------------------------------- 1 | import { Schema } from 'mongoose'; 2 | 3 | export class ObjectId extends Schema.Types.ObjectId {} 4 | -------------------------------------------------------------------------------- /src/shared/pipe/parse-object-id.pipe.ts: -------------------------------------------------------------------------------- 1 | import { ArgumentMetadata, BadRequestException, Injectable, PipeTransform } from '@nestjs/common'; 2 | import { ObjectId } from 'mongodb'; 3 | 4 | @Injectable() 5 | export class ParseObjectIdPipe implements PipeTransform { 6 | transform(value: any, metadata: ArgumentMetadata) { 7 | if (!ObjectId.isValid(value)) { 8 | throw new BadRequestException(`${metadata.data} must be an ObjectId`); 9 | } 10 | 11 | return ObjectId.createFromHexString(value); 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/shared/shared.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { ParseObjectIdPipe } from './pipe/parse-object-id.pipe'; 3 | 4 | @Module({ 5 | imports: [], 6 | controllers: [], 7 | providers: [ParseObjectIdPipe], 8 | exports: [ParseObjectIdPipe], 9 | }) 10 | export class SharedModule {} 11 | -------------------------------------------------------------------------------- /src/shared/utils/get-address.ts: -------------------------------------------------------------------------------- 1 | import { Dictionary } from 'code-config/dist'; 2 | import { config } from 'dotenv'; 3 | import { Request } from 'express'; 4 | import { Socket } from 'socket.io'; 5 | import { Client } from './get-client'; 6 | 7 | config(); 8 | 9 | const getAddressFrom = (ip: string, headers: Client['headers']) => { 10 | const isProxy = process.env.PROXY_ENABLED === 'true'; 11 | 12 | return (!isProxy && ip) || headers['x-forwarded-for'] || headers['x-real-ip'] || ip; 13 | }; 14 | 15 | export const getAddress = (client: Socket | Request): string => { 16 | if (client instanceof Socket) { 17 | return getAddressFrom(client.handshake.address, client.handshake.headers as Dictionary); 18 | } 19 | 20 | return getAddressFrom(client.ip, client.headers as Dictionary); 21 | }; 22 | -------------------------------------------------------------------------------- /src/shared/utils/get-client.ts: -------------------------------------------------------------------------------- 1 | import { ExecutionContext } from '@nestjs/common'; 2 | import { Dictionary } from 'code-config'; 3 | import { Room } from '../../features/room/schema/room.schema'; 4 | import { User } from '../../features/user/schema/user.schema'; 5 | 6 | export interface Client { 7 | headers: Dictionary; 8 | user: User; 9 | room?: Room; 10 | } 11 | 12 | export const getClient = (ctx: ExecutionContext): T => { 13 | switch (ctx.getType()) { 14 | case 'ws': 15 | return ctx.switchToWs().getClient().handshake; 16 | case 'http': 17 | return ctx.switchToHttp().getRequest(); 18 | default: 19 | return undefined; 20 | } 21 | }; 22 | -------------------------------------------------------------------------------- /src/shared/utils/get-request.ts: -------------------------------------------------------------------------------- 1 | import { ArgumentsHost, ExecutionContext } from '@nestjs/common'; 2 | 3 | export const getRequest = (ctx: ExecutionContext | ArgumentsHost): T => { 4 | switch (ctx.getType()) { 5 | case 'ws': 6 | return ctx.switchToWs().getClient(); 7 | case 'http': 8 | return ctx.switchToHttp().getRequest(); 9 | default: 10 | return undefined; 11 | } 12 | }; 13 | -------------------------------------------------------------------------------- /src/shared/utils/get-socket-client.ts: -------------------------------------------------------------------------------- 1 | import { Socket } from 'socket.io'; 2 | import { Client } from './get-client'; 3 | 4 | export const getSocketClient = (socket: Socket) => (socket.handshake as unknown) as Client; 5 | -------------------------------------------------------------------------------- /src/shared/utils/get-socket-user.ts: -------------------------------------------------------------------------------- 1 | import { Socket } from 'socket.io'; 2 | import { getSocketClient } from './get-socket-client'; 3 | 4 | export const getSocketUser = (socket: Socket) => getSocketClient(socket).user; 5 | -------------------------------------------------------------------------------- /src/shared/utils/get-url.ts: -------------------------------------------------------------------------------- 1 | import { Request } from 'express'; 2 | 3 | export const getURL = (request: Request) => { 4 | return request.protocol + '://' + request.get('host'); 5 | }; 6 | -------------------------------------------------------------------------------- /src/shared/utils/random-string.ts: -------------------------------------------------------------------------------- 1 | export const randomString = (length = 60) => { 2 | let output = ''; 3 | 4 | const characters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; 5 | 6 | for (let i = 0; i < length; i++) { 7 | output += characters[Math.floor(Math.random() * length)]; 8 | } 9 | 10 | return output; 11 | }; 12 | -------------------------------------------------------------------------------- /src/shared/utils/remove.ts: -------------------------------------------------------------------------------- 1 | export const remove = (arr: T[], predicate: (item: T) => boolean) => { 2 | const results = arr.filter(predicate); 3 | 4 | for (const result of results) { 5 | arr.splice(arr.indexOf(result), 1); 6 | } 7 | 8 | return results; 9 | }; 10 | -------------------------------------------------------------------------------- /tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "exclude": ["node_modules", "test", "dist", "**/*spec.ts"] 4 | } 5 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "commonjs", 4 | "declaration": true, 5 | "removeComments": true, 6 | "emitDecoratorMetadata": true, 7 | "experimentalDecorators": true, 8 | "allowSyntheticDefaultImports": true, 9 | "target": "es2017", 10 | "sourceMap": true, 11 | "outDir": "./dist", 12 | "baseUrl": "./", 13 | "incremental": true 14 | }, 15 | "ts-node": { 16 | "transpileOnly": true, 17 | "files": true 18 | } 19 | } 20 | --------------------------------------------------------------------------------