vvandenb 2 years ago
commit
eb85d99ac2
  1. 30
      .env_sample
  2. 6
      .gitattributes
  3. 2
      .gitignore
  4. 23
      Makefile
  5. 23
      Makesudo
  6. 85
      README.md
  7. 5
      back.dockerfile
  8. 13
      back/.eslintrc.js
  9. 133
      back/.gitignore
  10. 5
      back/nest-cli.json
  11. 11471
      back/package-lock.json
  12. 94
      back/package.json
  13. 32
      back/src/app.module.ts
  14. 25
      back/src/auth/42-auth.guard.ts
  15. 9
      back/src/auth/42.decorator.ts
  16. 48
      back/src/auth/42.strategy.ts
  17. 83
      back/src/auth/auth.controller.ts
  18. 54
      back/src/auth/auth.module.ts
  19. 53
      back/src/auth/auth.service.ts
  20. 15
      back/src/auth/mails/confirm.hbs
  21. 12
      back/src/auth/mails/confirmed.hbs
  22. 20
      back/src/auth/session.serializer.ts
  23. 249
      back/src/chat/chat.controller.ts
  24. 164
      back/src/chat/chat.gateway.ts
  25. 25
      back/src/chat/chat.module.ts
  26. 214
      back/src/chat/chat.service.ts
  27. 17
      back/src/chat/dto/connection.dto.ts
  28. 31
      back/src/chat/dto/create-channel.dto.ts
  29. 12
      back/src/chat/dto/create-message.dto.ts
  30. 12
      back/src/chat/dto/kickUser.dto.ts
  31. 30
      back/src/chat/dto/update-channel.dto.ts
  32. 25
      back/src/chat/dto/updateUser.dto.ts
  33. 52
      back/src/chat/entity/channel.entity.ts
  34. 13
      back/src/chat/entity/connection.entity.ts
  35. 15
      back/src/chat/entity/dm.entity.ts
  36. 31
      back/src/chat/entity/message.entity.ts
  37. 46
      back/src/chat/message.service.ts
  38. 49
      back/src/main.ts
  39. 38
      back/src/pong/dtos/GameCreationDtoValidated.ts
  40. 13
      back/src/pong/dtos/GameInfo.ts
  41. 8
      back/src/pong/dtos/GameUpdate.ts
  42. 23
      back/src/pong/dtos/MapDtoValidated.ts
  43. 3
      back/src/pong/dtos/MatchmakingDto.ts
  44. 7
      back/src/pong/dtos/MatchmakingDtoValidated.ts
  45. 10
      back/src/pong/dtos/PointDtoValidated.ts
  46. 14
      back/src/pong/dtos/RectDtoValidated.ts
  47. 3
      back/src/pong/dtos/StringDto.ts
  48. 7
      back/src/pong/dtos/StringDtoValidated.ts
  49. 12
      back/src/pong/dtos/UserDto.ts
  50. 27
      back/src/pong/entity/result.entity.ts
  51. 114
      back/src/pong/game/Ball.ts
  52. 197
      back/src/pong/game/Game.ts
  53. 119
      back/src/pong/game/Games.ts
  54. 64
      back/src/pong/game/MatchmakingQueue.ts
  55. 32
      back/src/pong/game/Paddle.ts
  56. 36
      back/src/pong/game/Player.ts
  57. 22
      back/src/pong/game/constants.ts
  58. 92
      back/src/pong/game/utils.ts
  59. 33
      back/src/pong/pong.controller.ts
  60. 239
      back/src/pong/pong.gateway.ts
  61. 15
      back/src/pong/pong.module.ts
  62. 88
      back/src/pong/pong.service.ts
  63. 8
      back/src/types.d.ts
  64. 37
      back/src/users/dto/user.dto.ts
  65. 78
      back/src/users/entity/user.entity.ts
  66. 222
      back/src/users/users.controller.ts
  67. 14
      back/src/users/users.module.ts
  68. 192
      back/src/users/users.service.ts
  69. 4
      back/tsconfig.build.json
  70. 19
      back/tsconfig.json
  71. 37
      docker-compose.yml
  72. 5
      front.dockerfile
  73. 132
      front/.gitignore
  74. 18
      front/index.html
  75. 1596
      front/package-lock.json
  76. 28
      front/package.json
  77. BIN
      front/public/audio/edge_hit.wav
  78. BIN
      front/public/audio/paddle_hit.wav
  79. BIN
      front/public/audio/score.wav
  80. 77
      front/public/global.css
  81. BIN
      front/public/img/chat.png
  82. BIN
      front/public/img/close.png
  83. BIN
      front/public/img/pog.jpg
  84. BIN
      front/public/img/pong.png
  85. BIN
      front/public/img/send.png
  86. 1
      front/public/vite.svg
  87. 311
      front/src/App.svelte
  88. 47
      front/src/Auth.ts
  89. 4
      front/src/app.d.ts
  90. 77
      front/src/components/Alert/Alert.svelte
  91. 30
      front/src/components/Alert/content.ts
  92. 394
      front/src/components/Channels.svelte
  93. 485
      front/src/components/Chat.svelte
  94. 190
      front/src/components/Friends.svelte
  95. 102
      front/src/components/Leaderboard.svelte
  96. 167
      front/src/components/MatchHistory.svelte
  97. 161
      front/src/components/NavBar.svelte
  98. 16
      front/src/components/Pong/Ball.ts
  99. 8
      front/src/components/Pong/ColorPicker.svelte
  100. 172
      front/src/components/Pong/Game.ts

30
.env_sample

@ -0,0 +1,30 @@
### BACK ###
HOST=localhost
FRONT_PORT=80
BACK_PORT=3001
HASH_SALT=10
### FRONT ###
VITE_HOST=localhost
VITE_BACK_PORT=3001
### GAME ###
VITE_FRONT_FPS=144
### 2FA ###
MAIL_USER=vaganiwast@gmail.com
MAIL_PASSWORD=
### AUTH ###
FT_OAUTH_CLIENT_ID=
FT_OAUTH_CLIENT_SECRET=
FT_OAUTH_CALLBACK_URL=http://localhost:3001/log/inReturn
JWT_SECRET=
JWT_EXPIRATION_TIME=900
### DB ###
POSTGRES_HOST=postgres
POSTGRES_PORT=5432
POSTGRES_USER=postgres_usr
POSTGRES_PASSWORD=postgres_pw
POSTGRES_DB=transcendence

6
.gitattributes

@ -0,0 +1,6 @@
*.ts text=auto eol=lf
*.svelte text=auto eol=lf
*.yml text=auto eol=lf
*.json text=auto eol=lf
*.html text=auto eol=lf
*.xml text=auto eol=lf

2
.gitignore

@ -0,0 +1,2 @@
.env
postgres

23
Makefile

@ -0,0 +1,23 @@
NAME = transcendence
USER = gavaniwast
all: clean dev
dev:
docker-compose up --build
stop:
docker-compose down
clean: stop
docker system prune -f
fclean: stop
rm -rf postgres
rm -rf back/volumes/avatars
rm -rf */node_modules
docker system prune -af --volumes
re: fclean dev
.PHONY: all dev stop clean fclean re

23
Makesudo

@ -0,0 +1,23 @@
NAME = transcendence
USER = gavaniwast
all: clean dev
dev:
sudo docker-compose up --build
stop:
sudo docker-compose down
clean: stop
sudo docker system prune -f
fclean: stop
sudo rm -rf postgres
sudo rm -rf back/avatars
sudo rm -rf */node_modules
sudo docker system prune -af --volumes
re: fclean dev
.PHONY: all dev stop clean fclean re

85
README.md

@ -0,0 +1,85 @@
# Transcendence
[![code style: prettier](https://img.shields.io/badge/code_style-prettier-ff69b4.svg?style=flat-square)](https://github.com/prettier/prettier)[![js-standard-style](https://cdn.rawgit.com/standard/standard/master/badge.svg)](http://standardjs.com)
## Instructions:
If you not use rootless docker, either rename Makesudo as Makefile or call `make` with `-f Makesudo`.
### Rules:
- prod: build and lauch client and server on builded resources.
- dev: launch client and server without build using nest and vite.
- check: format and lint back and check front.
- debug: launch back with debug flags.
### Setting:
rename .env_sample to .env and customize it to your needs and credentials.
## Back endpoints:
|Method|endpoint|description|account securised?|
|:---:|:---:|:---:|:---:|
|GET |/log/in |the login using 42 api.|☑|
|GET |/log/inReturn |the 42 api callback.|☑|
|GET |/log/profile |get connected user 42's datas.|☑|
|GET |/log/out |log out user.|☑|
|GET |/all |return all users publics datas.|☒|
|GET |/online |return all online users's public datas.|☒|
|GET |/friends |return users which are friends.|☑|
|GET |/invits |return users which invited user to be friend.|☑|
|GET |/leader |return the global leaderboard|☑|
|GET |/leader/:id |return the user(id) place in leaderboard|☑|
|GET |/history |return the matchs results sorted by date|☑|
|GET |/history/:id |return the last user(id)'s results sorted by date|☑|
|POST|/avatar |set a user() avatar with multipart post upload.|☑|
|GET |/avatar |return the user() avatar|☒|
|GET |/user/:name |return the user(name)|☒|
|POST|/invit/:id |user() invit user(id) as friend.|☑|
|GET |/avatar/:id |return the user(id)'s avatar|☒|
|GET |/:id |return user(id) public datas|☒|
|POST|/:id |update/create user(id)|☑|
|GET |/ |return user()' public datas|☑|
|POST|/ |update/create user()|☑|
## Dependencies:
### Front:
- [@svelte/vite-plugin-svelte](https://www.npmjs.com/package/@sveltejs/vite-plugin-svelte)
- [@tsconfig/svelte](https://www.npmjs.com/package/@tsconfig/svelte)
- [svelte](https://www.npmjs.com/package/svelte)
- [svelte-check](https://www.npmjs.com/package/svelte-check)
- [tslib](https://www.npmjs.com/package/tslib)
- [typescript](https://www.npmjs.com/package/typescript)
- [vite](https://www.npmjs.com/package/vite)
### Back:
- [@nestjs/cli](https://www.npmjs.com/package/@nestjs/cli)
- [@nestjs/common](https://www.npmjs.com/package/@nestjs/common)
- [@nestjs/core](https://www.npmjs.com/package/@nestjs/core)
- [@nestjs/platform-express](https://www.npmjs.com/package/@nestjs/platform-express)
- [@nestjs/platform-ws](https://www.npmjs.com/package/@nestjs/platform-ws)
- [@nestjs/schematics](https://www.npmjs.com/package/@nestjs/schematics)
- [@nestjs/testing](https://www.npmjs.com/package/@nestjs/testing)
- [@nestjs/websockets](https://www.npmjs.com/package/@nestjs/websockets)
- [@types/express](https://www.npmjs.com/package/@types/express)
- [@types/jest](https://www.npmjs.com/package/@types/jest)
- [@types/node](https://www.npmjs.com/package/@types/node)
- [@types/supertest](https://www.npmjs.com/package/@types/supertest)
- [@typescript-eslint/eslint-plugin](https://www.npmjs.com/package/@typescript-eslint/eslint-plugin)
- [@typescript-eslint/parser](https://www.npmjs.com/package/@typescript-eslint/parser)
- [eslint](https://www.npmjs.com/package/eslint)
- [eslint-config-prettier](https://www.npmjs.com/package/eslint-config-prettier)
- [eslint-plugin-prettier](https://www.npmjs.com/package/eslint-plugin-prettier)
- [jest](https://www.npmjs.com/package/jest)
- [prettier](https://www.npmjs.com/package/prettier)
- [reflect-metadata](https://www.npmjs.com/package/reflect-metadata)
- [rimraf](https://www.npmjs.com/package/rimraf)
- [rxjs](https://www.npmjs.com/package/rxjs)
- [source-map-support](https://www.npmjs.com/package/source-map-support)
- [supertest](https://www.npmjs.com/package/supertest)
- [ts-jest](https://www.npmjs.com/package/ts-jest)
- [ts-loader](https://www.npmjs.com/package/ts-loader)
- [ts-node](https://www.npmjs.com/package/ts-node)
- [tsconfig-paths](https://www.npmjs.com/package/tsconfig-paths)
- [typescript](https://www.npmjs.com/package/typescript)
- [ws](https://www.npmjs.com/package/ws)

5
back.dockerfile

@ -0,0 +1,5 @@
FROM alpine:3.15
RUN apk update && apk upgrade && apk add npm && npm install -g @nestjs/cli
WORKDIR /var/www/html
ENTRYPOINT npm install && npm run dev

13
back/.eslintrc.js

@ -0,0 +1,13 @@
module.exports = {
env: {
browser: true,
es2021: true
},
extends: 'standard-with-typescript',
parserOptions: {
ecmaVersion: 'latest',
sourceType: 'module',
project: ['./tsconfig.json'],
tsconfigRootDir: __dirname
}
}

133
back/.gitignore

@ -0,0 +1,133 @@
avatars/
.env
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
.pnpm-debug.log*
# Diagnostic reports (https://nodejs.org/api/report.html)
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov
# Coverage directory used by tools like istanbul
coverage
*.lcov
# nyc test coverage
.nyc_output
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
.grunt
# Bower dependency directory (https://bower.io/)
bower_components
# node-waf configuration
.lock-wscript
# Compiled binary addons (https://nodejs.org/api/addons.html)
build/Release
# Dependency directories
node_modules/
jspm_packages/
# Snowpack dependency directory (https://snowpack.dev/)
web_modules/
# TypeScript cache
*.tsbuildinfo
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Optional stylelint cache
.stylelintcache
# Microbundle cache
.rpt2_cache/
.rts2_cache_cjs/
.rts2_cache_es/
.rts2_cache_umd/
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarn-integrity
# dotenv environment variable files
.env
.env.development.local
.env.test.local
.env.production.local
.env.local
# parcel-bundler cache (https://parceljs.org/)
.cache
.parcel-cache
# Next.js build output
.next
out
# Nuxt.js build / generate output
.nuxt
dist
# Gatsby files
.cache/
# Comment in the public line in if your project uses Gatsby and not Next.js
# https://nextjs.org/blog/next-9-1#public-directory-support
# public
# vuepress build output
.vuepress/dist
# vuepress v2.x temp and cache directory
.temp
.cache
# Docusaurus cache and generated files
.docusaurus
# Serverless directories
.serverless/
# FuseBox cache
.fusebox/
# DynamoDB Local files
.dynamodb/
# TernJS port file
.tern-port
# Stores VSCode versions used for testing VSCode extensions
.vscode-test
# yarn v2
.yarn/cache
.yarn/unplugged
.yarn/build-state.yml
.yarn/install-state.gz
.pnp.*

5
back/nest-cli.json

@ -0,0 +1,5 @@
{
"$schema": "https://json.schemastore.org/nest-cli",
"collection": "@nestjs/schematics",
"sourceRoot": "src"
}

11471
back/package-lock.json

File diff suppressed because it is too large

94
back/package.json

@ -0,0 +1,94 @@
{
"name": "pong_server",
"version": "0.0.1",
"description": "",
"author": "",
"private": true,
"license": "UNLICENSED",
"scripts": {
"prebuild": "rimraf dist",
"build": "nest build",
"format": "prettier --write \".eslintrc.js\" \"src/**/*.ts\"",
"start": "nest start",
"dev": "nest start --watch",
"start:debug": "nest start --debug --watch",
"start:prod": "node dist/main",
"lint": "eslint \".eslintrc.js\" \"{src,apps,libs,test}/**/*.ts\" --fix",
"test": "jest",
"test:watch": "jest --watch",
"test:cov": "jest --coverage",
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
"test:e2e": "jest --config ./test/jest-e2e.json"
},
"dependencies": {
"@nestjs-modules/mailer": "^1.8.1",
"@nestjs/common": "^9.0.0",
"@nestjs/config": "^2.3.1",
"@nestjs/core": "^9.0.0",
"@nestjs/jwt": "^10.0.2",
"@nestjs/mapped-types": "^1.2.2",
"@nestjs/passport": "^9.0.3",
"@nestjs/platform-express": "^9.0.0",
"@nestjs/platform-socket.io": "^9.3.9",
"@nestjs/schedule": "^2.2.0",
"@nestjs/swagger": "^6.2.1",
"@nestjs/testing": "^9.0.0",
"@nestjs/typeorm": "^9.0.1",
"@nestjs/websockets": "^9.2.0",
"@types/bcrypt": "^5.0.0",
"@types/cookie-parser": "^1.4.3",
"@types/express": "^4.17.13",
"@types/express-session": "^1.17.6",
"@types/multer": "^1.4.7",
"@types/node": "^16.18.14",
"@types/passport": "^1.0.12",
"bcrypt": "^5.1.0",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.0",
"cookie-parser": "^1.4.6",
"express": "^4.18.2",
"express-session": "^1.17.3",
"multer": "^1.4.5-lts.1",
"nestjs-paginate": "^4.13.0",
"nodemailer": "^6.9.1",
"passport": "^0.6.0",
"passport-42": "^1.2.6",
"reflect-metadata": "^0.1.13",
"rimraf": "^3.0.2",
"rxjs": "^7.8.0",
"socket.io": "^4.6.1",
"source-map-support": "^0.5.21",
"typeorm": "^0.3.12"
},
"devDependencies": {
"@typescript-eslint/eslint-plugin": "^5.53.0",
"@typescript-eslint/parser": "^5.0.0",
"eslint": "^8.34.0",
"eslint-config-standard-with-typescript": "^34.0.0",
"eslint-plugin-import": "^2.27.5",
"eslint-plugin-n": "^15.6.1",
"eslint-plugin-promise": "^6.1.1",
"jest": "28.1.3",
"prettier": "^2.3.2",
"ts-jest": "28.0.8",
"ts-node": "^10.0.0",
"typescript": "^4.9.5"
},
"jest": {
"moduleFileExtensions": [
"js",
"json",
"ts"
],
"rootDir": "src",
"testRegex": ".*\\.spec\\.ts$",
"transform": {
"^.+\\.(t|j)s$": "ts-jest"
},
"collectCoverageFrom": [
"**/*.(t|j)s"
],
"coverageDirectory": "../coverage",
"testEnvironment": "node"
}
}

32
back/src/app.module.ts

@ -0,0 +1,32 @@
import { Module } from '@nestjs/common'
import { TypeOrmModule } from '@nestjs/typeorm'
import { ScheduleModule } from '@nestjs/schedule'
import { AuthModule } from './auth/auth.module'
import { ChatModule } from './chat/chat.module'
import { PongModule } from './pong/pong.module'
import { UsersModule } from './users/users.module'
@Module({
imports: [
ScheduleModule.forRoot(),
TypeOrmModule.forRootAsync({
useFactory: () => ({
type: 'postgres',
host: process.env.POSTGRES_HOST || 'localhost',
port: (process.env.POSTGRES_PORT || 5432) as number,
username: process.env.POSTGRES_USER || 'postgres',
password: process.env.POSTGRES_PASSWORD || 'postgres',
database: process.env.POSTGRES_DB || 'postgres',
jwt_secret: process.env.JWT_SECRET || 'secret',
autoLoadEntities: true,
synchronize: true
})
}),
AuthModule,
ChatModule,
PongModule,
UsersModule
]
})
export class AppModule {}

25
back/src/auth/42-auth.guard.ts

@ -0,0 +1,25 @@
import {
type ExecutionContext,
Injectable,
type CanActivate
} from '@nestjs/common'
import { AuthGuard } from '@nestjs/passport'
import { type Request } from 'express'
@Injectable()
export class FtOauthGuard extends AuthGuard('42') {
async canActivate (context: ExecutionContext): Promise<boolean> {
const activate: boolean = (await super.canActivate(context)) as boolean
const request: Request = context.switchToHttp().getRequest()
await super.logIn(request)
return activate
}
}
@Injectable()
export class AuthenticatedGuard implements CanActivate {
async canActivate (context: ExecutionContext): Promise<boolean> {
const req: Request = context.switchToHttp().getRequest()
return req.isAuthenticated()
}
}

9
back/src/auth/42.decorator.ts

@ -0,0 +1,9 @@
import { createParamDecorator, type ExecutionContext } from '@nestjs/common'
import { type Profile } from 'passport-42'
export const Profile42 = createParamDecorator(
(data: unknown, ctx: ExecutionContext): Profile => {
const request = ctx.switchToHttp().getRequest()
return request.user
}
)

48
back/src/auth/42.strategy.ts

@ -0,0 +1,48 @@
import { Injectable } from '@nestjs/common'
import { ConfigService } from '@nestjs/config'
import { PassportStrategy } from '@nestjs/passport'
import { Strategy, type Profile, type VerifyCallback } from 'passport-42'
import { get } from 'https'
import { createWriteStream } from 'fs'
import { UsersService } from 'src/users/users.service'
import { User } from 'src/users/entity/user.entity'
@Injectable()
export class FtStrategy extends PassportStrategy(Strategy, '42') {
constructor (
private readonly usersService: UsersService
) {
super({
clientID: process.env.FT_OAUTH_CLIENT_ID,
clientSecret: process.env.FT_OAUTH_CLIENT_SECRET,
callbackURL: process.env.FT_OAUTH_CALLBACK_URL,
passReqToCallback: true
})
}
async validate (
request: { session: { accessToken: string } },
accessToken: string,
refreshToken: string,
profile: Profile,
cb: VerifyCallback
): Promise<VerifyCallback> {
request.session.accessToken = accessToken
const ftId = profile.id as number
console.log('Validated ', profile.username)
if ((await this.usersService.findUser(ftId)) === null) {
const newUser = new User()
newUser.ftId = profile.id as number
newUser.username = profile.username
newUser.avatar = `${ftId}.jpg`
newUser.email = profile.emails[0].value
void this.usersService.create(newUser)
const file = createWriteStream(`avatars/${ftId}.jpg`)
get(profile._json.image.versions.small, function (response) {
response.pipe(file)
})
}
return cb(null, profile)
}
}

83
back/src/auth/auth.controller.ts

@ -0,0 +1,83 @@
import {
Controller,
Get,
Redirect,
UseGuards,
Res,
Req,
Post,
Body,
BadRequestException,
Param
} from '@nestjs/common'
import { Response, Request } from 'express'
import { FtOauthGuard, AuthenticatedGuard } from './42-auth.guard'
import { Profile } from 'passport-42'
import { Profile42 } from './42.decorator'
import { AuthService } from './auth.service'
import { UsersService } from 'src/users/users.service'
import { EmailDto } from 'src/chat/dto/updateUser.dto'
import type User from 'src/users/entity/user.entity'
const frontHost =
process.env.HOST !== undefined && process.env.HOST !== ''
? process.env.HOST
: 'localhost'
const frontPort =
process.env.PORT !== undefined && process.env.HOST !== ''
? process.env.PORT
: '80'
@Controller('log')
export class AuthController {
constructor (
private readonly authService: AuthService,
private readonly usersService: UsersService
) {}
@Get('in')
@UseGuards(FtOauthGuard)
ftAuth (): void {}
@Get('inReturn')
@UseGuards(FtOauthGuard)
@Redirect(`http://${frontHost}:${frontPort}/profile`)
ftAuthCallback (
@Res({ passthrough: true }) response: Response,
@Req() request: Request
): any {
console.log('cookie:', request.cookies['connect.sid'])
response.cookie('connect.sid', request.cookies['connect.sid'])
}
@Get('/verify')
@UseGuards(AuthenticatedGuard)
async VerifyEmail (@Profile42() profile: Profile): Promise<void> {
const ftId: number = profile.id
const user = await this.usersService.findUser(ftId)
if (user == null) throw new BadRequestException('User not found')
await this.authService.sendConfirmationEmail(user)
}
@Get('/verify/:code')
@Redirect(`http://${frontHost}:${frontPort}`)
async Verify (@Param("code") code: string): Promise<void> {
await this.authService.verifyAccount(code)
}
@Get('profile')
@UseGuards(AuthenticatedGuard)
profile (@Profile42() user: Profile): any {
return { user }
}
@Get('out')
@Redirect(`http://${frontHost}:${frontPort}`)
logOut (@Req() req: Request): any {
req.logOut(function (err) {
if (err != null) return err
})
}
}

54
back/src/auth/auth.module.ts

@ -0,0 +1,54 @@
import { Module } from '@nestjs/common'
import { UsersModule } from 'src/users/users.module'
import { PassportModule } from '@nestjs/passport'
import { ConfigModule, ConfigService } from '@nestjs/config'
import { AuthController } from './auth.controller'
import { FtStrategy } from './42.strategy'
import { SessionSerializer } from './session.serializer'
import { JwtModule } from '@nestjs/jwt'
import { MailerModule } from '@nestjs-modules/mailer'
import { AuthService } from './auth.service'
import { HandlebarsAdapter } from '@nestjs-modules/mailer/dist/adapters/handlebars.adapter'
const mailUser =
process.env.MAIL_USER !== null && process.env.MAIL_USER !== ''
? process.env.MAIL_USER
: ''
const mailPass =
process.env.MAIL_PASSWORD !== null && process.env.MAIL_PASSWORD !== ''
? process.env.MAIL_PASSWORD
: ''
@Module({
imports: [
UsersModule,
PassportModule,
ConfigModule.forRoot(),
JwtModule.register({
secret: process.env.JWT_SECRET,
signOptions: { expiresIn: '60s' }
}),
MailerModule.forRoot({
transport: {
service: 'gmail',
auth: {
user: mailUser,
pass: mailPass
}
},
template: {
dir: 'src/auth/mails',
adapter: new HandlebarsAdapter(),
options: {
strict: true
}
},
defaults: {
from: '"No Reply" vaganiwast@gmail.com'
}
})
],
providers: [ConfigService, FtStrategy, SessionSerializer, AuthService],
controllers: [AuthController]
})
export class AuthModule {}

53
back/src/auth/auth.service.ts

@ -0,0 +1,53 @@
import { BadRequestException, Injectable } from '@nestjs/common'
import { type User } from 'src/users/entity/user.entity'
import { UsersService } from 'src/users/users.service'
import { MailerService } from '@nestjs-modules/mailer'
@Injectable()
export class AuthService {
constructor (
private readonly usersService: UsersService,
private readonly mailerService: MailerService
) {}
async sendConfirmedEmail (user: User): Promise<void> {
const { email, username } = user
await this.mailerService.sendMail({
to: email,
subject: 'Welcome to ft_transcendence! Email Confirmed',
template: 'confirmed',
context: {
username,
email
}
})
}
async sendConfirmationEmail (user: User): Promise<void> {
user.authToken = Math.floor(10000 + Math.random() * 90000).toString()
await this.usersService.save(user)
try {
await this.mailerService.sendMail({
to: user.email,
subject: 'Welcome to ft_transcendence! Confirm Email',
template: 'confirm',
context: {
username: user.username,
code: user.authToken
}
})
} catch {
throw new BadRequestException("Email doesnt't seem to be valid")
}
console.log(`email sent to ${user.email}`)
}
async verifyAccount (code: string): Promise<boolean> {
const user = await this.usersService.findByCode(code)
user.authToken = ''
user.isVerified = true
await this.usersService.save(user)
await this.sendConfirmedEmail(user)
return true
}
}

15
back/src/auth/mails/confirm.hbs

@ -0,0 +1,15 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta http-equiv="x-ua-compatible" content="ie=edge">
<title>Transcendence confirmation mail</title>
</head>
<body>
<h2>Hello {{username}}! </h2>
<p> Once you clicked on the next verify button, you will have access to the app</p>
<a href="http://localhost:3001/log/verify/{{code}}">
<button>VERIFY</button>
</a>
</body>
</html>

12
back/src/auth/mails/confirmed.hbs

@ -0,0 +1,12 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta http-equiv="x-ua-compatible" content="ie=edge">
<title>Welcome to transcendence</title>
</head>
<body>
<h2>Hello {{username}}! </h2>
<p>You well verified your account for this session.</p>
</body>
</html>

20
back/src/auth/session.serializer.ts

@ -0,0 +1,20 @@
import { Injectable } from '@nestjs/common'
import { PassportSerializer } from '@nestjs/passport'
import { type Profile } from 'passport-42'
@Injectable()
export class SessionSerializer extends PassportSerializer {
serializeUser (
user: Profile,
done: (err: Error | null, user: Profile) => void
): any {
done(null, user)
}
deserializeUser (
payload: Profile,
done: (err: Error | null, user: Profile) => void
): any {
done(null, payload)
}
}

249
back/src/chat/chat.controller.ts

@ -0,0 +1,249 @@
import {
BadRequestException,
Body,
Controller,
Delete,
Get,
NotFoundException,
Param,
ParseIntPipe,
Post,
UseGuards
} from '@nestjs/common'
import { AuthenticatedGuard } from 'src/auth/42-auth.guard'
import { UsersService } from 'src/users/users.service'
import { ChatService } from './chat.service'
import { CreateChannelDto } from './dto/create-channel.dto'
import { IdDto, PasswordDto, MuteDto } from './dto/updateUser.dto'
import type User from 'src/users/entity/user.entity'
import type Channel from './entity/channel.entity'
import { Profile42 } from 'src/auth/42.decorator'
import { Profile } from 'passport-42'
@Controller('channels')
@UseGuards(AuthenticatedGuard)
export class ChatController {
constructor (
private readonly channelService: ChatService,
private readonly usersService: UsersService
) {}
@Get('dms/:otherName')
async getDMsForUser (
@Profile42() profile: Profile,
@Param('otherName') otherName: string
): Promise<Channel[]> {
const user = await this.usersService.findUser(+profile.id)
const other = await this.usersService.findUserByName(otherName)
const channels = await this.channelService.getChannelsForUser(+profile.id)
if (user === null || other === null) {
throw new BadRequestException('User not found')
}
const dms = channels.filter((channel: Channel) => {
return (
(channel.name === `${user.ftId}&${other.ftId}` ||
channel.name === `${other.ftId}&${user.ftId}`)
)
})
if (dms.length === 0) {
throw new BadRequestException('No DMS found')
}
return dms
}
@Post(':id/invite')
async addUser (
@Param('id', ParseIntPipe) id: number,
@Body() target: IdDto,
@Profile42() profile: Profile
): Promise<void> {
const channel = await this.channelService.getFullChannel(id)
const user: User | null = await this.usersService.getFullUser(target.id)
if (user == null || target === undefined) {
throw new NotFoundException(`User #${target.id} not found`)
}
if (!(await this.channelService.isUser(channel.id, +profile.id))) {
throw new BadRequestException(
'You are not allowed to invite users to this channel'
)
}
if (await this.channelService.isUser(channel.id, target.id)) {
throw new BadRequestException('User is already in this channel')
}
if (await this.channelService.isBanned(channel.id, target.id)) {
throw new BadRequestException('User is banned from this channel')
}
channel.users.push(user)
await this.channelService.save(channel)
}
@Get(':id/users')
async getUsersOfChannel (
@Param('id', ParseIntPipe) id: number
): Promise<User[]> {
const users = (await this.channelService.getFullChannel(id)).users
users.forEach((u) => (u.socketKey = ''))
return users
}
@Post(':id/admin')
async addAdmin (
@Param('id', ParseIntPipe) id: number,
@Body() target: IdDto,
@Profile42() profile: Profile
): Promise<void> {
const channel = await this.channelService.getFullChannel(id)
const user: User | null = await this.usersService.findUser(target.id)
if (user == null) {
throw new NotFoundException(`User #${target.id} not found`)
}
if (!(await this.channelService.isOwner(channel.id, +profile.id))) {
throw new BadRequestException('You are not the owner of this channel')
}
if (!(await this.channelService.isUser(channel.id, target.id))) {
throw new BadRequestException('User is not in this channel')
}
if (await this.channelService.isAdmin(channel.id, target.id)) {
throw new BadRequestException('User is already an admin of this channel')
}
channel.admins.push(user)
await this.channelService.save(channel)
}
@Delete(':id/admin')
async removeAdmin (
@Param('id', ParseIntPipe) id: number,
@Body() target: IdDto,
@Profile42() profile: Profile
): Promise<void> {
const channel = await this.channelService.getFullChannel(id)
if (await this.channelService.isOwner(channel.id, target.id)) {
throw new BadRequestException('The owner cannot be demoted')
}
if (!(await this.channelService.isOwner(channel.id, +profile.id))) {
throw new BadRequestException('You are not the owner of this channel')
}
if (!(await this.channelService.isAdmin(channel.id, target.id))) {
throw new BadRequestException('User is not an admin of this channel')
}
channel.admins = channel.admins.filter((usr: User) => {
return usr.ftId !== target.id
})
await this.channelService.save(channel)
}
@Post(':id/ban')
async addBan (
@Param('id', ParseIntPipe) id: number,
@Body() target: MuteDto,
@Profile42() profile: Profile
): Promise<void> {
const channel = await this.channelService.getFullChannel(id)
const user: User | null = await this.usersService.findUser(+target.data[0])
if (isNaN(+target.data[1])) {
throw new BadRequestException('Invalid duration')
}
if (user == null) {
throw new NotFoundException(`User #${+target.data[0]} not found`)
}
if (!(await this.channelService.isAdmin(channel.id, +profile.id))) {
throw new BadRequestException(
'You are not allowed to ban users from this channel'
)
}
if (await this.channelService.isOwner(channel.id, +target.data[0])) {
throw new BadRequestException('You cannot ban the owner of the channel')
}
if (await this.channelService.isBanned(channel.id, +target.data[0])) {
throw new BadRequestException('User is already banned from this channel')
}
channel.banned.push([+target.data[0], Date.now() + +target.data[1] * 1000])
await this.channelService.save(channel)
}
@Post(':id/mute')
async addMute (
@Param('id', ParseIntPipe) id: number,
@Body() mute: MuteDto, // [userId, duration]
@Profile42() profile: Profile
): Promise<void> {
const channel = await this.channelService.getFullChannel(id)
const user: User | null = await this.usersService.findUser(+mute.data[0])
if (isNaN(+mute.data[1])) {
throw new BadRequestException('Invalid duration')
}
if (user == null) {
throw new NotFoundException(`User #${+mute.data[0]} not found`)
}
if (!(await this.channelService.isAdmin(channel.id, +profile.id))) {
throw new BadRequestException(
'You are not allowed to mute users from this channel'
)
}
if (await this.channelService.isOwner(channel.id, +mute.data[0])) {
throw new BadRequestException('You cannot mute the owner of the channel')
}
if (await this.channelService.isMuted(channel.id, +mute.data[0])) {
throw new BadRequestException('User is already muted from this channel')
}
const newMute: number[] = [+mute.data[0], Date.now() + +mute.data[1] * 1000]
channel.muted.push(newMute)
await this.channelService.save(channel)
}
@Delete(':id')
async deleteChannel (
@Profile42() profile: Profile,
@Param('id', ParseIntPipe) id: number
): Promise<void> {
if (!(await this.channelService.isOwner(id, +profile.id))) {
throw new BadRequestException('You are not the owner of this channel')
}
await this.channelService.removeChannel(id)
}
@Post(':id/password')
async updatePassword (
@Profile42() profile: Profile,
@Param('id', ParseIntPipe) id: number,
@Body() data: PasswordDto
): Promise<void> {
if (!(await this.channelService.isOwner(id, +profile.id))) {
throw new BadRequestException('You are not the owner of this channel')
}
let channel = (await this.channelService.getChannel(id)) as Channel
if (channel.isDM) throw new BadRequestException('You cannot set a password on a DM channel')
channel.password = await this.channelService.hash(data.password)
this.channelService.update(channel)
}
@Get(':id')
async getChannel (@Param('id', ParseIntPipe) id: number): Promise<Channel> {
const chan = await this.channelService.getFullChannel(id)
if (chan == null) {
throw new NotFoundException(`Channel #${id} not found`)
}
chan.users.forEach((u) => (u.socketKey = ''))
chan.admins.forEach((u) => (u.socketKey = ''))
chan.owner.socketKey = ''
return chan
}
@Get()
async getChannelsForUser (@Profile42() profile: Profile): Promise<Channel[]> {
const chan = await this.channelService.getChannelsForUser(+profile.id)
return chan
}
@Post()
async createChannel (@Body() channel: CreateChannelDto): Promise<Channel> {
const chan = await this.channelService.createChannel(channel)
chan.users.forEach((u) => (u.socketKey = ''))
chan.admins.forEach((u) => (u.socketKey = ''))
chan.owner.socketKey = ''
return chan
}
}

164
back/src/chat/chat.gateway.ts

@ -0,0 +1,164 @@
import {
type OnGatewayConnection,
type OnGatewayDisconnect,
SubscribeMessage,
WebSocketGateway,
WebSocketServer,
WsException,
} from '@nestjs/websockets';
import { Socket, Server } from 'socket.io';
// import { User } from 'users/user.entity';
import { UsersService } from 'src/users/users.service';
import { ChatService } from './chat.service';
import type Message from './entity/message.entity';
import * as bcrypt from 'bcrypt';
import { MessageService } from './message.service';
import { CreateMessageDto } from './dto/create-message.dto';
import { ConnectionDto } from './dto/connection.dto';
import { kickUserDto } from './dto/kickUser.dto';
import ConnectedUser from './entity/connection.entity';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import type User from 'src/users/entity/user.entity';
import Channel from './entity/channel.entity';
@WebSocketGateway({
cors: {
origin: new RegExp(
`^(http|ws)://${process.env.HOST ?? 'localhost'}(:\\d+)?$`
),
},
})
export class ChatGateway implements OnGatewayConnection, OnGatewayDisconnect {
@WebSocketServer()
server: Server;
constructor(
private readonly userService: UsersService,
private readonly messageService: MessageService,
private readonly chatService: ChatService,
@InjectRepository(ConnectedUser)
private readonly connectedUserRepository: Repository<ConnectedUser>
) {}
async handleConnection(socket: Socket): Promise<void> {}
async handleDisconnect(socket: Socket): Promise<void> {
const connect = await this.connectedUserRepository.findOneBy({
socket: socket.id
});
if (connect) {
console.log('socket %s has disconnected', socket.id)
await this.connectedUserRepository.delete({ user: connect.user })
}
}
@SubscribeMessage('joinChannel')
async onJoinChannel(socket: Socket, connect: ConnectionDto): Promise<void> {
await this.connectedUserRepository.delete({ user: connect.UserId })
const channel = await this.chatService.getFullChannel(connect.ChannelId);
if (channel.banned.some((ban) => +ban[0] === +connect.UserId)) {
this.server
.to(socket.id)
.emit('failedJoin', 'You are banned from this channel');
throw new WsException('You are banned from this channel');
}
const user = await this.userService.getFullUser(connect.UserId);
if (connect.socketKey !== user.socketKey) {
this.server.to(socket.id).emit('failedJoin', 'Wrong socket key');
throw new WsException('Wrong socket key');
}
user.socketKey = '';
if (channel.password && channel.password !== '') {
if (
!connect.pwd ||
!(await bcrypt.compare(connect.pwd, channel.password))
) {
this.server.to(socket.id).emit('failedJoin', 'Wrong password');
throw new WsException('Wrong password');
}
}
await this.chatService.addUserToChannel(channel, user);
const messages = await this.messageService.findMessagesInChannelForUser(
channel,
user
);
const conUser = new ConnectedUser();
conUser.user = user.ftId;
conUser.channel = channel.id;
conUser.socket = socket.id;
await this.connectedUserRepository.save(conUser);
await socket.join(channel.id.toString());
this.server.to(socket.id).emit('messages', messages);
}
@SubscribeMessage('leaveChannel')
async onLeaveChannel(socket: Socket): Promise<boolean> {
const connect = await this.connectedUserRepository.findOneBy({
socket: socket.id,
});
if (connect == null) return false;
const channel = await this.chatService.getFullChannel(connect.channel);
if (connect.user === channel.owner.ftId) {
this.server.in(channel.id.toString()).emit('deleted');
await this.chatService.removeChannel(channel.id);
} else {
channel.users = channel.users.filter((usr: User) => usr.ftId !== connect.user);
channel.admins = channel.admins.filter((usr: User) => usr.ftId !== connect.user);
await this.chatService.save(channel);
}
await this.connectedUserRepository.delete({ socket: socket.id });
console.log('socket %s has left channel', socket.id)
return true;
}
@SubscribeMessage('addMessage')
async onAddMessage (socket: Socket, message: CreateMessageDto): Promise<void> {
const connect = await this.connectedUserRepository.findOneBy({
socket: socket.id,
});
if (connect == null) throw new WsException('You must be connected to the channel');
let channel: Channel | null = null
channel = await this.chatService.getChannel(message.ChannelId).catch(() => { return null })
if (channel == null) {
this.server.to(socket.id).emit('deleted')
throw new WsException('Channel has been deleted');
}
if (await this.chatService.isMuted(channel.id, message.UserId)) {
throw new WsException('You are muted');
}
const createdMessage: Message = await this.messageService.createMessage(
message
);
this.server.to(channel.id.toString()).emit('newMessage', createdMessage);
}
@SubscribeMessage('kickUser')
async onKickUser(socket: Socket, kick: kickUserDto): Promise<void> {
let connect = (await this.connectedUserRepository.findOneBy({
socket: socket.id
}))
if (connect === null)
throw new WsException('You must be connected to the channel')
const channel = await this.chatService.getFullChannel(kick.chan);
if (channel.owner.ftId === kick.to) {
throw new WsException('You cannot kick the owner of a channel');
}
if (
channel.owner.ftId !== kick.from &&
!channel.admins.some((usr) => +usr.ftId === kick.from)
) {
throw new WsException('You do not have the required privileges')
}
const target = (await this.userService.findUser(kick.to)) as User
connect = (await this.connectedUserRepository.findOneBy({
user: target.ftId
}))
if (connect !== null) {
console.log(`kicking ${target.username} from ${channel.name} with socket ${connect.socket}`)
this.server.to(connect.socket).emit('kicked')
} else {
throw new WsException('Target is not connected to the channel')
}
}
}

25
back/src/chat/chat.module.ts

@ -0,0 +1,25 @@
import { Module } from '@nestjs/common'
import { TypeOrmModule } from '@nestjs/typeorm'
import { AuthModule } from 'src/auth/auth.module'
import { UsersModule } from 'src/users/users.module'
import { ChatGateway } from './chat.gateway'
import { ChatController } from './chat.controller'
import { ChatService } from './chat.service'
import { MessageService } from './message.service'
import Channel from './entity/channel.entity'
import Message from './entity/message.entity'
import ConnectedUser from './entity/connection.entity'
@Module({
imports: [
UsersModule,
AuthModule,
TypeOrmModule.forFeature([Channel, Message, ConnectedUser])
],
controllers: [ChatController],
providers: [ChatService, ChatGateway, MessageService],
exports: [ChatService]
})
export class ChatModule {}

214
back/src/chat/chat.service.ts

@ -0,0 +1,214 @@
import { BadRequestException, Injectable } from '@nestjs/common'
import { InjectRepository } from '@nestjs/typeorm'
import { Repository } from 'typeorm'
import { Cron } from '@nestjs/schedule'
import * as bcrypt from 'bcrypt'
import { type CreateChannelDto } from './dto/create-channel.dto'
import { UsersService } from 'src/users/users.service'
import type User from 'src/users/entity/user.entity'
import Channel from './entity/channel.entity'
@Injectable()
export class ChatService {
constructor (
@InjectRepository(Channel)
private readonly ChannelRepository: Repository<Channel>,
private readonly usersService: UsersService
) {}
async createChannel (channel: CreateChannelDto): Promise<Channel> {
const user: User | null = await this.usersService.getFullUser(channel.owner)
if (user == null) {
throw new BadRequestException(`User #${channel.owner} not found`)
}
let newChannel: Channel
if (channel.isDM) {
if (channel.otherDMedUsername === undefined || channel.otherDMedUsername === null) {
throw new BadRequestException('No other user specified')
}
const otherUser: User | null = await this.usersService.findUserByName(
channel.otherDMedUsername
)
if (otherUser == null) throw new BadRequestException(`User #${channel.otherDMedUsername} not found`)
if (user.blocked.some((usr: User) => usr.ftId === otherUser.ftId)) {
throw new BadRequestException(`User ${otherUser.username} is blocked`)
}
if (otherUser.id === user.id) throw new BadRequestException('Cannot DM yourself')
const channels = await this.getChannelsForUser(user.id)
const dmAlreadyExists = channels.find((channel: Channel) => {
return (
(channel.name === `${user.ftId}&${otherUser.ftId}` ||
channel.name === `${otherUser.ftId}&${user.ftId}`)
)
})
if (dmAlreadyExists !== undefined) {
throw new BadRequestException('DM already exists')
}
newChannel = this.createDM(user, otherUser)
} else {
newChannel = new Channel()
newChannel.owner = user
newChannel.users = [user]
newChannel.admins = [user]
newChannel.name = channel.name
newChannel.isPrivate = channel.isPrivate
newChannel.password = await this.hash(channel.password)
newChannel.isDM = false
console.log('New channel: ', JSON.stringify(newChannel))
}
return await this.ChannelRepository.save(newChannel)
}
createDM (user: User, otherUser: User): Channel {
const newDM = new Channel()
newDM.isPrivate = true
newDM.password = ''
newDM.owner = user
newDM.users = [user, otherUser]
newDM.admins = [user]
newDM.name = `${user.ftId}&${otherUser.ftId}`
newDM.isDM = true
return newDM
}
async hash(password: string): Promise<string> {
if (!password) return ''
password = await bcrypt.hash(
password,
Number(process.env.HASH_SALT)
)
return password
}
async getChannelsForUser (ftId: number): Promise<Channel[]> {
let rooms: Channel[] = []
rooms = [
...(await this.ChannelRepository.createQueryBuilder('room')
.where('room.isPrivate = false')
.orderBy('room.id', 'DESC')
.getMany())
]
rooms = [
...rooms,
...(await this.ChannelRepository.createQueryBuilder('room')
.innerJoin('room.users', 'users')
.where('room.isPrivate = true')
.andWhere('users.ftId = :ftId', { ftId })
.getMany())
]
return rooms
}
@Cron('*/10 * * * * *')
async updateBanlists (): Promise<void> {
const channels = await this.ChannelRepository.find({})
for (const channel of channels) {
channel.banned = channel.banned.filter((data) => {
return Date.now() - data[1] < 0
})
channel.muted = channel.muted.filter((data) => {
return Date.now() - data[1] < 0
})
void this.update(channel)
}
}
async addUserToChannel (channel: Channel, user: User): Promise<Channel> {
channel.users.push(user)
await this.save(channel)
return channel
}
async getChannel (id: number): Promise<Channel> {
const channel = await this.ChannelRepository.findOneBy({ id })
if (channel == null) {
throw new BadRequestException(`Channel #${id} not found`)
}
return channel
}
// Warning: those channels users contains socketKey.
// they have to be hidden before returned from a route
// but not save them without the key.
async getFullChannel (id: number): Promise<Channel> {
const channel = await this.ChannelRepository.findOne({
where: { id },
relations: ['users', 'admins', 'owner']
})
if (channel == null) {
throw new BadRequestException(`Channel #${id} not found`)
}
return channel
}
async update (channel: Channel): Promise<void> {
await this.ChannelRepository.update(channel.id, channel)
}
async save (channel: Channel): Promise<void> {
await this.ChannelRepository.save(channel)
}
async removeChannel (channelId: number): Promise<void> {
await this.ChannelRepository.remove(await this.getFullChannel(channelId))
}
async isOwner (id: number, userId: number): Promise<boolean> {
const channel = await this.ChannelRepository.findOne({
where: { id },
relations: { owner: true }
})
if (channel === null) {
throw new BadRequestException(`Channel #${id} not found`)
}
return channel.owner.ftId === userId
}
async isAdmin (id: number, userId: number): Promise<boolean> {
const channel = await this.ChannelRepository.findOne({
where: { id },
relations: { admins: true }
})
if (channel === null) {
throw new BadRequestException(`Channel #${id} not found`)
}
return channel.admins.some((user) => user.ftId === userId)
}
async isUser (id: number, userId: number): Promise<boolean> {
const channel = await this.ChannelRepository.findOne({
where: { id },
relations: { users: true }
})
if (channel === null) {
throw new BadRequestException(`Channel #${id} not found`)
}
return channel.users.some((user) => user.ftId === userId)
}
async isBanned (id: number, userId: number): Promise<boolean> {
const channel = await this.ChannelRepository.findOne({
where: { id }
})
if (channel === null) {
throw new BadRequestException(`Channel #${id} not found`)
}
return channel.banned.some((ban) => +ban[0] === userId)
}
async isMuted (id: number, userId: number): Promise<boolean> {
const channel = await this.ChannelRepository.findOne({
where: { id }
})
if (channel === null) {
throw new BadRequestException(`Channel #${id} not found`)
}
return channel.muted.some((mute) => +mute[0] === userId)
}
}

17
back/src/chat/dto/connection.dto.ts

@ -0,0 +1,17 @@
import { IsNotEmpty, IsNumber, IsOptional, IsString } from 'class-validator'
export class ConnectionDto {
@IsString()
@IsNotEmpty()
socketKey: string
@IsNumber()
UserId: number
@IsNumber()
ChannelId: number
@IsString()
@IsOptional()
pwd: string
}

31
back/src/chat/dto/create-channel.dto.ts

@ -0,0 +1,31 @@
import { Transform } from 'class-transformer'
import {
IsPositive,
IsString,
IsOptional,
IsNumber,
IsBoolean
} from 'class-validator'
export class CreateChannelDto {
@IsString()
name: string
@IsNumber()
owner: number
@IsOptional()
password: string
@IsBoolean()
@Transform(({ value }) => value === 'true')
isPrivate: boolean
@IsBoolean()
@IsOptional()
isDM: boolean
@IsString()
@IsOptional()
otherDMedUsername: string
}

12
back/src/chat/dto/create-message.dto.ts

@ -0,0 +1,12 @@
import { IsNumber, IsString } from 'class-validator'
export class CreateMessageDto {
@IsString()
text: string
@IsNumber()
UserId: number
@IsNumber()
ChannelId: number
}

12
back/src/chat/dto/kickUser.dto.ts

@ -0,0 +1,12 @@
import { IsNumber } from 'class-validator'
export class kickUserDto {
@IsNumber()
chan: number
@IsNumber()
from: number
@IsNumber()
to: number
}

30
back/src/chat/dto/update-channel.dto.ts

@ -0,0 +1,30 @@
import { PartialType } from '@nestjs/mapped-types'
import { CreateChannelDto } from './create-channel.dto'
import { IsNumber, IsOptional, IsString } from 'class-validator'
export class UpdateChannelDto extends PartialType(CreateChannelDto) {
id: number
@IsOptional()
@IsNumber()
users: [number]
@IsOptional()
@IsNumber()
messages: [number]
@IsOptional()
@IsNumber()
owners: [number] // user id
@IsOptional()
@IsNumber()
banned: [number] // user id
@IsOptional()
@IsNumber()
muted: [number] // user id
@IsString()
@IsOptional()
password: string
}

25
back/src/chat/dto/updateUser.dto.ts

@ -0,0 +1,25 @@
import { Type } from 'class-transformer'
import { IsArray, IsEmail, IsNumber, IsPositive, IsString } from 'class-validator'
export class IdDto {
@IsNumber()
id: number
}
export class PasswordDto {
@IsString()
password: string
}
export class MuteDto {
@Type(() => Number)
@IsArray()
@IsNumber({}, { each: true })
@IsPositive({ each: true })
data: number[]
}
export class EmailDto {
@IsEmail()
email: string
}

52
back/src/chat/entity/channel.entity.ts

@ -0,0 +1,52 @@
import {
BeforeInsert,
Column,
Entity,
JoinColumn,
JoinTable,
ManyToMany,
ManyToOne,
OneToMany,
PrimaryGeneratedColumn
} from 'typeorm'
import User from 'src/users/entity/user.entity'
import Message from './message.entity'
@Entity()
export default class Channel {
@PrimaryGeneratedColumn()
id: number
@Column()
name: string
@Column({ default: false })
isPrivate: boolean
@Column({ default: '' })
password: string
@ManyToMany(() => User)
@JoinTable()
users: User[]
@OneToMany(() => Message, (message: Message) => message.channel, { cascade: true })
messages: Message[]
@ManyToOne(() => User)
@JoinColumn()
owner: User
@ManyToMany(() => User)
@JoinTable()
admins: User[]
@Column('text', { array: true, default: [] })
banned: number[][]
@Column('text', { array: true, default: [] })
muted: number[][]
@Column({ default: false })
isDM: boolean
}

13
back/src/chat/entity/connection.entity.ts

@ -0,0 +1,13 @@
import { Column, Entity, JoinColumn, OneToOne, PrimaryColumn } from 'typeorm'
@Entity()
export default class ConnectedUser {
@Column()
user: number
@Column()
channel: number
@PrimaryColumn()
socket: string
}

15
back/src/chat/entity/dm.entity.ts

@ -0,0 +1,15 @@
import { Column, Entity, OneToMany, PrimaryGeneratedColumn } from 'typeorm'
import Message from './message.entity'
import type User from 'src/users/entity/user.entity'
@Entity()
export class Channel {
@PrimaryGeneratedColumn()
id: number
@Column()
users: User[]
@OneToMany(() => Message, (message) => message.channel)
messages: Message[]
}

31
back/src/chat/entity/message.entity.ts

@ -0,0 +1,31 @@
import {
Column,
CreateDateColumn,
Entity,
JoinColumn,
JoinTable,
ManyToOne,
PrimaryGeneratedColumn
} from 'typeorm'
import User from 'src/users/entity/user.entity'
import Channel from './channel.entity'
@Entity()
export default class Message {
@PrimaryGeneratedColumn()
id: number
@Column()
text: string
@ManyToOne(() => User)
@JoinColumn()
author: User
@ManyToOne(() => Channel, (channel) => channel.messages, { onDelete: 'CASCADE' })
@JoinTable()
channel: Channel
@CreateDateColumn()
created_at: Date
}

46
back/src/chat/message.service.ts

@ -0,0 +1,46 @@
import { Injectable } from '@nestjs/common'
import { InjectRepository } from '@nestjs/typeorm'
import { Repository } from 'typeorm'
import { ChatService } from './chat.service'
import { UsersService } from 'src/users/users.service'
import { type CreateMessageDto } from './dto/create-message.dto'
import type User from 'src/users/entity/user.entity'
import type Channel from './entity/channel.entity'
import Message from './entity/message.entity'
@Injectable()
export class MessageService {
constructor (
@InjectRepository(Message)
private readonly MessageRepository: Repository<Message>,
private readonly channelService: ChatService,
private readonly usersService: UsersService
) {}
async createMessage (message: CreateMessageDto): Promise<Message> {
const msg = new Message()
msg.text = message.text
msg.channel = await this.channelService.getChannel(message.ChannelId)
msg.author = (await this.usersService.findUser(message.UserId)) as User
msg.author.socketKey = ''
return await this.MessageRepository.save(msg)
}
async findMessagesInChannelForUser (
channel: Channel,
user: User
): Promise<Message[]> {
const blockeds = user.blocked.map((u) => +u.ftId)
const messages = await this.MessageRepository.createQueryBuilder('message')
.innerJoin('message.channel', 'channel')
.where('channel.id = :chanId', { chanId: channel.id })
.leftJoinAndSelect('message.author', 'author')
.orderBy('message.created_at', 'ASC')
.getMany()
messages.forEach((msg) => {
msg.author.socketKey = ''
})
return messages
}
}

49
back/src/main.ts

@ -0,0 +1,49 @@
import { Logger, ValidationPipe } from '@nestjs/common'
import { NestFactory } from '@nestjs/core'
import { AppModule } from './app.module'
import * as session from 'express-session'
import * as passport from 'passport'
import { type NestExpressApplication } from '@nestjs/platform-express'
import * as cookieParser from 'cookie-parser'
import { IoAdapter } from '@nestjs/platform-socket.io'
async function bootstrap (): Promise<void> {
const logger = new Logger()
const app = await NestFactory.create<NestExpressApplication>(AppModule)
const port =
process.env.BACK_PORT !== undefined && process.env.BACK_PORT !== ''
? +process.env.BACK_PORT
: 3001
const cors = {
origin: new RegExp(
`^(http|ws)://${process.env.HOST ?? 'localhost'}(:\\d+)?$`
),
methods: 'GET, HEAD, PUT, PATCH, POST, DELETE, OPTIONS',
preflightContinue: false,
optionsSuccessStatus: 204,
credentials: true,
allowedHeaders:
['Accept', 'Content-Type', 'Authorization']
}
app.use(
session({
resave: false,
saveUninitialized: false,
secret:
process.env.JWT_SECRET !== undefined && process.env.JWT_SECRET !== ''
? process.env.JWT_SECRET
: 'secret'
})
)
app.use(cookieParser())
app.use(passport.initialize())
app.use(passport.session())
app.enableCors(cors)
app.useWebSocketAdapter(new IoAdapter(app))
app.useGlobalPipes(new ValidationPipe())
await app.listen(port)
logger.log(`Application listening on port ${port}`)
}
bootstrap().catch((e) => {
console.log('Error!')
})

38
back/src/pong/dtos/GameCreationDtoValidated.ts

@ -0,0 +1,38 @@
import { Type } from 'class-transformer'
import {
ArrayMaxSize,
ArrayMinSize,
IsNotEmptyObject,
IsNumber,
IsString,
Max,
Min,
ValidateNested
} from 'class-validator'
import {
DEFAULT_BALL_INITIAL_SPEED,
DEFAULT_MAX_BALL_SPEED
} from '../game/constants'
import { MapDtoValidated } from './MapDtoValidated'
export class GameCreationDtoValidated {
@IsString({ each: true })
@ArrayMaxSize(2)
@ArrayMinSize(2)
playerNames!: string[]
@IsNotEmptyObject()
@ValidateNested()
@Type(() => MapDtoValidated)
map!: MapDtoValidated
@IsNumber()
@Min(DEFAULT_BALL_INITIAL_SPEED.x)
@Max(DEFAULT_MAX_BALL_SPEED.x)
initialBallSpeedX!: number
@IsNumber()
@Min(DEFAULT_BALL_INITIAL_SPEED.y)
@Max(DEFAULT_MAX_BALL_SPEED.y)
initialBallSpeedY!: number
}

13
back/src/pong/dtos/GameInfo.ts

@ -0,0 +1,13 @@
import { type Point, type Rect } from '../game/utils'
export class GameInfo {
mapSize!: Point
yourPaddleIndex!: number
gameId!: string
walls!: Rect[]
paddleSize!: Point
ballSize!: Point
winScore!: number
ranked!: boolean
playerNames!: string[]
}

8
back/src/pong/dtos/GameUpdate.ts

@ -0,0 +1,8 @@
import { type Point } from '../game/utils'
export class GameUpdate {
paddlesPositions!: Point[]
ballSpeed!: Point
ballPosition!: Point
scores!: number[]
}

23
back/src/pong/dtos/MapDtoValidated.ts

@ -0,0 +1,23 @@
import { Type } from 'class-transformer'
import {
ArrayMaxSize,
IsArray,
IsDefined,
IsObject,
ValidateNested
} from 'class-validator'
import { PointDtoValidated } from './PointDtoValidated'
import { RectDtoValidated } from './RectDtoValidated'
export class MapDtoValidated {
@IsObject()
@IsDefined()
@Type(() => PointDtoValidated)
size!: PointDtoValidated
@IsArray()
@ArrayMaxSize(5)
@ValidateNested({ each: true })
@Type(() => RectDtoValidated)
walls!: RectDtoValidated[]
}

3
back/src/pong/dtos/MatchmakingDto.ts

@ -0,0 +1,3 @@
export class MatchmakingDto {
matchmaking!: boolean
}

7
back/src/pong/dtos/MatchmakingDtoValidated.ts

@ -0,0 +1,7 @@
import { IsBoolean } from 'class-validator'
import { MatchmakingDto } from './MatchmakingDto'
export class MatchmakingDtoValidated extends MatchmakingDto {
@IsBoolean()
matchmaking!: boolean
}

10
back/src/pong/dtos/PointDtoValidated.ts

@ -0,0 +1,10 @@
import { IsNumber } from 'class-validator'
import { Point } from '../game/utils'
export class PointDtoValidated extends Point {
@IsNumber()
x!: number
@IsNumber()
y!: number
}

14
back/src/pong/dtos/RectDtoValidated.ts

@ -0,0 +1,14 @@
import { Type } from 'class-transformer'
import { ValidateNested } from 'class-validator'
import { Rect } from '../game/utils'
import { PointDtoValidated } from './PointDtoValidated'
export class RectDtoValidated extends Rect {
@ValidateNested()
@Type(() => PointDtoValidated)
center!: PointDtoValidated
@ValidateNested()
@Type(() => PointDtoValidated)
size!: PointDtoValidated
}

3
back/src/pong/dtos/StringDto.ts

@ -0,0 +1,3 @@
export class StringDto {
value!: string
}

7
back/src/pong/dtos/StringDtoValidated.ts

@ -0,0 +1,7 @@
import { IsString } from 'class-validator'
import { StringDto } from './StringDto'
export class StringDtoValidated extends StringDto {
@IsString()
value!: string
}

12
back/src/pong/dtos/UserDto.ts

@ -0,0 +1,12 @@
import { IsString } from 'class-validator'
export class UserDto {
@IsString()
username!: string
@IsString()
avatar!: string
@IsString()
status!: string
}

27
back/src/pong/entity/result.entity.ts

@ -0,0 +1,27 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
ManyToMany,
CreateDateColumn
} from 'typeorm'
import User from 'src/users/entity/user.entity'
@Entity()
export default class Result {
@PrimaryGeneratedColumn()
id: number
@Column({ default: false })
ranked: boolean
@ManyToMany(() => User, (player: User) => player.results, { cascade: true })
players: Array<User | null>
@Column('text', { array: true })
public score: number[]
@CreateDateColumn()
date: Date
}

114
back/src/pong/game/Ball.ts

@ -0,0 +1,114 @@
import { type Paddle } from './Paddle'
import { type Point, Rect } from './utils'
import { type MapDtoValidated } from '../dtos/MapDtoValidated'
import {
DEFAULT_BALL_SIZE,
GAME_TICKS,
DEFAULT_BALL_SPEED_INCREMENT,
DEFAULT_MAX_BALL_SPEED
} from './constants'
export class Ball {
rect: Rect
initial_speed: Point
speed: Point
spawn: Point
indexPlayerScored: number
timeoutTime: number
constructor (
spawn: Point,
initialSpeed: Point,
size: Point = DEFAULT_BALL_SIZE.clone()
) {
this.rect = new Rect(spawn, size)
this.speed = initialSpeed.clone()
this.initial_speed = initialSpeed.clone()
this.spawn = spawn.clone()
this.indexPlayerScored = -1
this.timeoutTime = 0
}
getIndexPlayerScored (): number {
return this.indexPlayerScored
}
update (canvasRect: Rect, paddles: Paddle[], map: MapDtoValidated): void {
if (!canvasRect.contains_x(this.rect)) {
this.indexPlayerScored = this.playerScored()
this.timeoutTime = 2000
} else {
this.indexPlayerScored = -1
if (this.timeoutTime <= 0) {
this.move(canvasRect, paddles, map)
} else {
this.timeoutTime -= 1000 / GAME_TICKS
}
}
}
move (canvasRect: Rect, paddles: Paddle[], map: MapDtoValidated): void {
for (const paddle of paddles) {
if (paddle.rect.collides(this.rect)) {
if (this.speed.x < 0) {
this.rect.center.x = paddle.rect.center.x + paddle.rect.size.x
} else this.rect.center.x = paddle.rect.center.x - paddle.rect.size.x
this.speed.x = this.speed.x * -1
this.speed.y =
((this.rect.center.y - paddle.rect.center.y) / paddle.rect.size.y) *
20
break
}
}
for (const wall of map.walls) {
if (wall.collides(this.rect)) {
if (this.speed.x < 0) {
this.rect.center.x = wall.center.x + wall.size.x
} else this.rect.center.x = wall.center.x - wall.size.x
this.speed.x = this.speed.x * -1
this.speed.y =
((this.rect.center.y - wall.center.y) / wall.size.y) * 20
break
}
}
if (!canvasRect.contains_y(this.rect)) this.speed.y = this.speed.y * -1
if (this.speed.x > 0 && this.speed.x < DEFAULT_MAX_BALL_SPEED.x) {
this.speed.x += DEFAULT_BALL_SPEED_INCREMENT.x
}
if (this.speed.x < 0 && this.speed.x > -DEFAULT_MAX_BALL_SPEED.x) {
this.speed.x -= DEFAULT_BALL_SPEED_INCREMENT.x
}
if (this.speed.y > 0 && this.speed.y > DEFAULT_MAX_BALL_SPEED.y) {
this.speed.y += DEFAULT_MAX_BALL_SPEED.y
}
if (this.speed.y < 0 && this.speed.y < -DEFAULT_MAX_BALL_SPEED.y) {
this.speed.y -= DEFAULT_MAX_BALL_SPEED.y
}
this.rect.center.add_inplace(this.speed)
}
playerScored (): number {
let indexPlayerScored: number
if (this.rect.center.x <= this.spawn.x) {
indexPlayerScored = 1
this.speed.x = this.initial_speed.x
} else {
indexPlayerScored = 0
this.speed.x = -this.initial_speed.x
}
if (this.speed.y < 0) {
this.speed.y = this.initial_speed.y
} else {
this.speed.y = -this.initial_speed.y
}
this.rect.center = this.spawn.clone()
return indexPlayerScored
}
}

197
back/src/pong/game/Game.ts

@ -0,0 +1,197 @@
import { Ball } from './Ball'
import { type Socket } from 'socket.io'
import { Point, Rect } from './utils'
import { Player } from './Player'
import {
DEFAULT_BALL_INITIAL_SPEED,
DEFAULT_BALL_SIZE,
DEFAULT_PADDLE_SIZE,
DEFAULT_WIN_SCORE,
GAME_EVENTS,
GAME_TICKS
} from './constants'
import { randomUUID } from 'crypto'
import { type MapDtoValidated } from '../dtos/MapDtoValidated'
import { type GameUpdate } from '../dtos/GameUpdate'
import { type GameInfo } from '../dtos/GameInfo'
import { type PongService } from '../pong.service'
export class Game {
id: string
timer: NodeJS.Timer | null
map: MapDtoValidated
ball: Ball
players: Player[] = []
ranked: boolean
initialBallSpeed: Point
waitingForTimeout: boolean
gameStoppedCallback: (name: string) => void
constructor (
sockets: Socket[],
uuids: string[],
names: string[],
map: MapDtoValidated,
gameStoppedCallback: (name: string) => void,
private readonly pongService: PongService,
ranked: boolean,
initialBallSpeed: Point = DEFAULT_BALL_INITIAL_SPEED.clone()
) {
this.id = randomUUID()
this.timer = null
this.ranked = ranked
this.waitingForTimeout = false
this.map = map
this.gameStoppedCallback = gameStoppedCallback
this.initialBallSpeed = initialBallSpeed
this.ball = new Ball(
new Point(this.map.size.x / 2, this.map.size.y / 2),
initialBallSpeed
)
for (let i = 0; i < uuids.length; i++) {
this.addPlayer(sockets[i], uuids[i], names[i])
}
}
getGameInfo (name: string): GameInfo {
const yourPaddleIndex = this.players.findIndex((p) => p.name === name)
return {
mapSize: this.map.size,
yourPaddleIndex,
gameId: this.id,
walls: this.map.walls,
paddleSize: DEFAULT_PADDLE_SIZE,
ballSize: DEFAULT_BALL_SIZE,
winScore: DEFAULT_WIN_SCORE,
ranked: this.ranked,
playerNames: this.players.map((p) => p.name)
}
}
private addPlayer (socket: Socket, uuid: string, name: string): void {
let paddleCoords = new Point(
DEFAULT_PADDLE_SIZE.x / 2,
this.map.size.y / 2
)
if (this.players.length === 1) {
paddleCoords = new Point(
this.map.size.x - DEFAULT_PADDLE_SIZE.x / 2,
this.map.size.y / 2
)
}
this.players.push(
new Player(socket, uuid, name, paddleCoords, this.map.size)
)
if (this.ranked) {
this.ready(name)
}
}
ready (name: string): void {
const playerIndex: number = this.players.findIndex((p) => p.name === name)
if (playerIndex !== -1 && !this.players[playerIndex].ready) {
this.players[playerIndex].ready = true
console.log(`${this.players[playerIndex].name} is ready`)
if (this.players.length === 2 && this.players.every((p) => p.ready)) {
this.start()
}
}
}
private start (): void {
if (this.timer === null && this.players.length === 2) {
this.ball = new Ball(
new Point(this.map.size.x / 2, this.map.size.y / 2),
this.initialBallSpeed
)
this.players.forEach((p) => {
void this.pongService.setInGame(p.name)
p.newGame()
})
this.broadcastGame(GAME_EVENTS.START_GAME)
this.timer = setInterval(this.gameLoop.bind(this), 1000 / GAME_TICKS)
console.log(`Game ${this.id} starting in 3 seconds`)
this.waitingForTimeout = true
new Promise((resolve) => setTimeout(resolve, 3000))
.then(() => (this.waitingForTimeout = false))
.catch(() => {})
}
}
stop (nameWhoLeft?: string): void {
if (this.timer !== null) {
clearInterval(this.timer)
}
let nameWhoWon: string
if (this.players[0].score > this.players[1].score) {
nameWhoWon = this.players[0].name
} else {
nameWhoWon = this.players[1].name
}
if (nameWhoLeft !== undefined) {
this.players.forEach((p) => {
if (p.name !== nameWhoLeft) {
nameWhoWon = p.name
}
})
}
this.timer = null
this.pongService
.saveResult(this.players, this.ranked, nameWhoWon)
.then(() => {
this.gameStoppedCallback(this.players[0].name)
this.players = []
})
.catch(() => {
this.gameStoppedCallback(this.players[0].name)
this.players = []
})
}
movePaddle (name: string | undefined, position: Point): void {
const playerIndex: number = this.players.findIndex((p) => p.name === name)
if (this.timer !== null && playerIndex !== -1) {
this.players[playerIndex].paddle.move(position.y)
}
}
private broadcastGame (event: string, data?: any): void {
this.players.forEach((p) => {
p.socket.emit(event, data)
})
}
private gameLoop (): void {
if (this.waitingForTimeout) {
return
}
const canvasRect: Rect = new Rect(
new Point(this.map.size.x / 2, this.map.size.y / 2),
new Point(this.map.size.x, this.map.size.y)
)
this.ball.update(
canvasRect,
this.players.map((p) => p.paddle),
this.map
)
const indexPlayerScored: number = this.ball.getIndexPlayerScored()
if (indexPlayerScored !== -1) {
this.players[indexPlayerScored].score += 1
if (this.players[indexPlayerScored].score >= DEFAULT_WIN_SCORE) {
console.log(`${this.players[indexPlayerScored].name} won`)
this.stop()
}
}
const data: GameUpdate = {
paddlesPositions: this.players.map((p) => p.paddle.rect.center),
ballSpeed: this.ball.speed,
ballPosition: this.ball.rect.center,
scores: this.players.map((p) => p.score)
}
this.broadcastGame(GAME_EVENTS.GAME_TICK, data)
}
}

119
back/src/pong/game/Games.ts

@ -0,0 +1,119 @@
import { type Socket } from 'socket.io'
import { Game } from './Game'
import { Point } from './utils'
import { type MapDtoValidated as GameMap } from '../dtos/MapDtoValidated'
import { type GameCreationDtoValidated } from '../dtos/GameCreationDtoValidated'
import { type GameInfo } from '../dtos/GameInfo'
import { type PongService } from '../pong.service'
import {
DEFAULT_BALL_SIZE,
DEFAULT_MAP_SIZE,
DEFAULT_PADDLE_SIZE,
DEFAULT_WIN_SCORE
} from './constants'
export class Games {
constructor (private readonly pongService: PongService) {}
private readonly playerNameToGameIndex = new Map<string, number>()
private readonly games = new Array<Game>()
newGame (
sockets: Socket[],
uuids: string[],
gameCreationDto: GameCreationDtoValidated,
ranked: boolean
): void {
const names: string[] = gameCreationDto.playerNames
const map: GameMap = {
size: DEFAULT_MAP_SIZE,
walls: gameCreationDto.map.walls
}
if (!this.isInAGame(names[0]) && !this.isInAGame(names[1])) {
this.games.push(
new Game(
sockets,
uuids,
names,
map,
this.deleteGame.bind(this, names[0]),
this.pongService,
ranked,
new Point(
gameCreationDto.initialBallSpeedX,
gameCreationDto.initialBallSpeedY
)
)
)
this.playerNameToGameIndex.set(names[0], this.games.length - 1)
this.playerNameToGameIndex.set(names[1], this.games.length - 1)
console.log(
`Created game ${names[0]} vs ${names[1]} (${
this.games[this.games.length - 1].id
})`
)
}
}
ready (name: string): void {
const game: Game | undefined = this.playerGame(name)
if (game !== undefined) {
game.ready(name)
}
}
private deleteGame (name: string): void {
const game: Game | undefined = this.playerGame(name)
if (game !== undefined) {
this.games.splice(this.games.indexOf(game), 1)
game.players.forEach((player) => {
this.playerNameToGameIndex.delete(player.name)
})
console.log(`Game stopped: ${game.id}`)
}
}
getGameInfo (name: string): GameInfo {
const game: Game | undefined = this.playerGame(name)
if (game !== undefined) {
return game.getGameInfo(name)
}
return {
yourPaddleIndex: -2,
gameId: '',
mapSize: new Point(0, 0),
walls: [],
paddleSize: DEFAULT_PADDLE_SIZE,
ballSize: DEFAULT_BALL_SIZE,
winScore: DEFAULT_WIN_SCORE,
ranked: false,
playerNames: []
}
}
movePlayer (name: string | undefined, position: Point): void {
const game: Game | undefined = this.playerGame(name)
if (game !== undefined) {
game.movePaddle(name, position)
}
}
isInAGame (name: string | undefined): boolean {
if (name === undefined) return false
return this.playerNameToGameIndex.get(name) !== undefined
}
playerGame (name: string | undefined): Game | undefined {
const game: Game | undefined = this.games.find((game) =>
game.players.some((player) => player.name === name)
)
return game
}
async leaveGame (name: string): Promise<void> {
const game: Game | undefined = this.playerGame(name)
if (game !== undefined && !game.ranked) {
game.stop()
this.deleteGame(name)
}
}
}

64
back/src/pong/game/MatchmakingQueue.ts

@ -0,0 +1,64 @@
import { type Socket } from 'socket.io'
import { type GameCreationDtoValidated } from '../dtos/GameCreationDtoValidated'
import { DEFAULT_BALL_INITIAL_SPEED, DEFAULT_MAP_SIZE } from './constants'
import { type Games } from './Games'
export class MatchmakingQueue {
games: Games
queue: Array<{ name: string, socket: Socket, uuid: string }>
constructor (games: Games) {
this.games = games
this.queue = []
}
addPlayer (name: string, socket: Socket, uuid: string): void {
if (!this.isInQueue(name)) {
console.log('Adding player to queue: ', name)
this.queue.push({ name, socket, uuid })
if (this.canCreateGame()) {
this.createGame()
}
}
}
removePlayer (name: string): void {
if (this.isInQueue(name)) {
console.log('Removing player from queue: ', name)
this.queue = this.queue.filter((player) => player.name !== name)
}
}
isInQueue (name: string): boolean {
return this.queue.some((player) => player.name === name)
}
canCreateGame (): boolean {
return this.queue.length >= 2
}
createGame (): void {
const player1 = this.queue.shift()
const player2 = this.queue.shift()
if (player1 === undefined || player2 === undefined) {
return
}
const gameCreationDto: GameCreationDtoValidated = {
playerNames: [player1.name, player2.name],
map: {
size: DEFAULT_MAP_SIZE,
walls: []
},
initialBallSpeedX: DEFAULT_BALL_INITIAL_SPEED.x,
initialBallSpeedY: DEFAULT_BALL_INITIAL_SPEED.y
}
const ranked = true
this.games.newGame(
[player1.socket, player2.socket],
[player1.uuid, player2.uuid],
gameCreationDto,
ranked
)
}
}

32
back/src/pong/game/Paddle.ts

@ -0,0 +1,32 @@
import { DEFAULT_PADDLE_SIZE } from './constants'
import { type Point, Rect } from './utils'
export class Paddle {
rect: Rect
color: string | CanvasGradient | CanvasPattern = 'white'
mapSize: Point
constructor (
spawn: Point,
gameSize: Point,
size: Point = DEFAULT_PADDLE_SIZE
) {
this.rect = new Rect(spawn, size)
this.mapSize = gameSize
}
draw (context: CanvasRenderingContext2D): void {
this.rect.draw(context, this.color)
}
move (newY: number): void {
const offset: number = this.rect.size.y / 2
if (newY - offset < 0) {
this.rect.center.y = offset
} else if (newY + offset > this.mapSize.y) {
this.rect.center.y = this.mapSize.y - offset
} else {
this.rect.center.y = newY
}
}
}

36
back/src/pong/game/Player.ts

@ -0,0 +1,36 @@
import { type Socket } from 'socket.io'
import { Paddle } from './Paddle'
import { type Point } from './utils'
export class Player {
socket: Socket
uuid: string
name: string
ready: boolean
paddle: Paddle
paddleCoords: Point
mapSize: Point
score: number
constructor (
socket: Socket,
uuid: string,
name: string,
paddleCoords: Point,
mapSize: Point
) {
this.socket = socket
this.uuid = uuid
this.name = name
this.ready = false
this.paddle = new Paddle(paddleCoords, mapSize)
this.paddleCoords = paddleCoords
this.mapSize = mapSize
this.score = 0
}
newGame (): void {
this.score = 0
this.paddle = new Paddle(this.paddleCoords, this.mapSize)
}
}

22
back/src/pong/game/constants.ts

@ -0,0 +1,22 @@
import { Point } from './utils'
export const GAME_EVENTS = {
START_GAME: 'START_GAME',
READY: 'READY',
GAME_TICK: 'GAME_TICK',
PLAYER_MOVE: 'PLAYER_MOVE',
GET_GAME_INFO: 'GET_GAME_INFO',
CREATE_GAME: 'CREATE_GAME',
REGISTER_PLAYER: 'REGISTER_PLAYER',
MATCHMAKING: 'MATCHMAKING',
LEAVE_GAME: 'LEAVE_GAME'
}
export const DEFAULT_MAP_SIZE = new Point(500, 400)
export const DEFAULT_PADDLE_SIZE = new Point(30, 50)
export const DEFAULT_BALL_SIZE = new Point(10, 10)
export const DEFAULT_BALL_INITIAL_SPEED = new Point(10, 2)
export const DEFAULT_MAX_BALL_SPEED = new Point(20, 20)
export const DEFAULT_BALL_SPEED_INCREMENT = new Point(0.05, 0)
export const DEFAULT_WIN_SCORE = 5
export const GAME_TICKS = 30

92
back/src/pong/game/utils.ts

@ -0,0 +1,92 @@
export class Point {
x: number
y: number
constructor (x: number, y: number) {
this.x = x
this.y = y
}
// Returns a new point
add (other: Point): Point {
return new Point(this.x + other.x, this.y + other.y)
}
// Modifies `this` point
add_inplace (other: Point): void {
this.x += other.x
this.y += other.y
}
clone (): Point {
return new Point(this.x, this.y)
}
}
export class Rect {
center: Point
size: Point
constructor (center: Point, size: Point) {
this.center = center
this.size = size
}
draw (
context: CanvasRenderingContext2D,
color: string | CanvasGradient | CanvasPattern
): void {
const offset: Point = new Point(this.size.x / 2, this.size.y / 2)
context.fillStyle = color
context.fillRect(
this.center.x - offset.x,
this.center.y - offset.y,
this.size.x,
this.size.y
)
}
// True if `this` rect contains `other` rect in the x-axis
contains_x (other: Rect): boolean {
const offset: number = this.size.x / 2
const offsetOther: number = other.size.x / 2
if (
this.center.x - offset <= other.center.x - offsetOther &&
this.center.x + offset >= other.center.x + offsetOther
) {
return true
}
return false
}
// True if `this` rect contains `other` rect in the y-axis
contains_y (other: Rect): boolean {
const offset: number = this.size.y / 2
const offsetOther: number = other.size.y / 2
if (
this.center.y - offset <= other.center.y - offsetOther &&
this.center.y + offset >= other.center.y + offsetOther
) {
return true
}
return false
}
collides (other: Rect): boolean {
const offset: Point = new Point(this.size.x / 2, this.size.y / 2)
const offsetOther: Point = new Point(other.size.x / 2, other.size.y / 2)
if (
this.center.x - offset.x < other.center.x + offsetOther.x &&
this.center.x + offset.x > other.center.x - offsetOther.x &&
this.center.y - offset.y < other.center.y + offsetOther.y &&
this.center.y + offset.y > other.center.y - offsetOther.y
) {
return true
}
return false
}
}

33
back/src/pong/pong.controller.ts

@ -0,0 +1,33 @@
import {
Controller,
Get,
Param,
ParseIntPipe,
UseGuards
} from '@nestjs/common'
import { Paginate, type Paginated, PaginateQuery } from 'nestjs-paginate'
import { AuthenticatedGuard } from 'src/auth/42-auth.guard'
import type Result from './entity/result.entity'
import { PongService } from './pong.service'
@Controller('results')
export class PongController {
constructor (private readonly pongService: PongService) {}
@Get('global')
@UseGuards(AuthenticatedGuard)
async getGlobalHistory (
@Paginate() query: PaginateQuery
): Promise<Paginated<Result>> {
return await this.pongService.getHistory(query, 0)
}
@Get(':id')
@UseGuards(AuthenticatedGuard)
async getHistoryById (
@Param('id', ParseIntPipe) id: number,
@Paginate() query: PaginateQuery
): Promise<Paginated<Result>> {
return await this.pongService.getHistory(query, id)
}
}

239
back/src/pong/pong.gateway.ts

@ -0,0 +1,239 @@
import { UsePipes, ValidationPipe } from '@nestjs/common'
import { Socket } from 'socket.io'
import {
ConnectedSocket,
MessageBody,
type OnGatewayConnection,
type OnGatewayDisconnect,
SubscribeMessage,
WebSocketGateway
} from '@nestjs/websockets'
import { Games } from './game/Games'
import { GAME_EVENTS } from './game/constants'
import { GameCreationDtoValidated } from './dtos/GameCreationDtoValidated'
import { type Game } from './game/Game'
import { plainToClass } from 'class-transformer'
import { PointDtoValidated } from './dtos/PointDtoValidated'
import { StringDtoValidated } from './dtos/StringDtoValidated'
import { MatchmakingQueue } from './game/MatchmakingQueue'
import { MatchmakingDtoValidated } from './dtos/MatchmakingDtoValidated'
import { PongService } from './pong.service'
import { UsersService } from 'src/users/users.service'
import type User from 'src/users/entity/user.entity'
@WebSocketGateway({
cors: { origin: new RegExp(`^(http|ws)://${process.env.HOST ?? 'localhost'}(:\\d+)?$`) }
})
export class PongGateway implements OnGatewayConnection, OnGatewayDisconnect {
constructor (
private readonly pongService: PongService,
private readonly usersService: UsersService
) {}
private readonly games: Games = new Games(this.pongService)
private readonly socketToPlayerName = new Map<Socket, string>()
private readonly matchmakingQueue = new MatchmakingQueue(this.games)
playerIsRegistered (name: string): boolean {
return Array.from(this.socketToPlayerName.values()).includes(name)
}
handleConnection (): void {}
handleDisconnect (
@ConnectedSocket()
client: Socket
): void {
const name: string | undefined = this.socketToPlayerName.get(client)
const game: Game | undefined = this.games.playerGame(name)
if (name !== undefined) {
if (game !== undefined) {
game.stop(name)
}
console.log('Disconnected ', this.socketToPlayerName.get(client))
this.matchmakingQueue.removePlayer(name)
this.socketToPlayerName.delete(client)
}
}
@UsePipes(new ValidationPipe({ whitelist: true }))
@SubscribeMessage(GAME_EVENTS.REGISTER_PLAYER)
async registerPlayer (
@ConnectedSocket()
client: Socket,
@MessageBody('playerName') playerName: StringDtoValidated,
@MessageBody('socketKey') socketKey: StringDtoValidated
): Promise<{ event: string, data: boolean }> {
let succeeded: boolean = false
let user: User | null = null
try {
user = await this.usersService.findUserByName(playerName.value)
} catch (e) {
console.log('Failed to register player', playerName.value)
}
// Check that socket key is not already registered
for (const [socket, name] of this.socketToPlayerName) {
try {
const _user: User = await this.usersService.findUserByName(name)
if (_user.socketKey === socketKey.value) {
console.log('Failed to register player', playerName.value, '(socket key already registered)')
}
} catch (e) {
// User does not exist anymore, unregister it
console.log('Disconnected player', name)
this.socketToPlayerName.delete(socket)
const game: Game | undefined = this.games.playerGame(name)
if (game !== undefined) {
game.stop(name)
}
this.matchmakingQueue.removePlayer(name)
this.socketToPlayerName.delete(client)
}
}
if (
user !== null &&
user.socketKey === socketKey.value &&
!this.playerIsRegistered(playerName.value)
) {
this.socketToPlayerName.set(client, playerName.value)
succeeded = true
console.log('Registered player', playerName.value)
} else {
console.log('Failed to register player', playerName.value)
}
return { event: GAME_EVENTS.REGISTER_PLAYER, data: succeeded }
}
@SubscribeMessage(GAME_EVENTS.GET_GAME_INFO)
getPlayerCount (@ConnectedSocket() client: Socket): void {
const name: string | undefined = this.socketToPlayerName.get(client)
if (name !== undefined) {
client.emit(GAME_EVENTS.GET_GAME_INFO, this.games.getGameInfo(name))
}
}
@UsePipes(new ValidationPipe({ whitelist: true }))
@SubscribeMessage(GAME_EVENTS.PLAYER_MOVE)
movePlayer (
@ConnectedSocket()
client: Socket,
@MessageBody() position: PointDtoValidated
): void {
const realPosition: PointDtoValidated = plainToClass(
PointDtoValidated,
position
)
const name: string | undefined = this.socketToPlayerName.get(client)
this.games.movePlayer(name, realPosition)
}
@UsePipes(new ValidationPipe({ whitelist: true }))
@SubscribeMessage(GAME_EVENTS.CREATE_GAME)
createGame (
@ConnectedSocket()
client: Socket,
@MessageBody() gameCreationDto: GameCreationDtoValidated
): { event: string, data: boolean } {
const realGameCreationDto: GameCreationDtoValidated = plainToClass(
GameCreationDtoValidated,
gameCreationDto
)
if (this.socketToPlayerName.size >= 2) {
const player1Socket: Socket | undefined = Array.from(
this.socketToPlayerName.keys()
).find(
(key) =>
this.socketToPlayerName.get(key) ===
realGameCreationDto.playerNames[0]
)
const player1game: Game | undefined = this.games.playerGame(
realGameCreationDto.playerNames[0]
)
const player2Socket: Socket | undefined = Array.from(
this.socketToPlayerName.keys()
).find(
(key) =>
this.socketToPlayerName.get(key) ===
realGameCreationDto.playerNames[1]
)
const player2game: Game | undefined = this.games.playerGame(
realGameCreationDto.playerNames[1]
)
if (
player1Socket !== undefined &&
player2Socket !== undefined &&
player1game === undefined &&
player2game === undefined &&
(client.id === player1Socket.id || client.id === player2Socket.id) &&
player1Socket.id !== player2Socket.id
) {
this.matchmakingQueue.removePlayer(realGameCreationDto.playerNames[0])
this.matchmakingQueue.removePlayer(realGameCreationDto.playerNames[1])
const ranked = false
this.games.newGame(
[player1Socket, player2Socket],
[player1Socket.id, player2Socket.id],
realGameCreationDto,
ranked
)
return { event: GAME_EVENTS.CREATE_GAME, data: true }
}
}
return { event: GAME_EVENTS.CREATE_GAME, data: false }
}
@SubscribeMessage(GAME_EVENTS.READY)
ready (
@ConnectedSocket()
client: Socket
): { event: string, data: boolean } {
let succeeded: boolean = false
const name: string | undefined = this.socketToPlayerName.get(client)
if (name !== undefined) {
this.games.ready(name)
succeeded = true
}
return { event: GAME_EVENTS.READY, data: succeeded }
}
@UsePipes(new ValidationPipe({ whitelist: true }))
@SubscribeMessage(GAME_EVENTS.MATCHMAKING)
updateMatchmaking (
@ConnectedSocket()
client: Socket,
@MessageBody() matchmakingUpdateData: MatchmakingDtoValidated
): { event: string, data: MatchmakingDtoValidated } {
let matchmaking: boolean = false
const name: string | undefined = this.socketToPlayerName.get(client)
if (name !== undefined) {
if (matchmakingUpdateData.matchmaking && !this.games.isInAGame(name)) {
this.matchmakingQueue.addPlayer(name, client, client.id)
} else {
this.matchmakingQueue.removePlayer(name)
}
matchmaking = this.matchmakingQueue.isInQueue(name)
}
return {
event: GAME_EVENTS.MATCHMAKING,
data: { matchmaking }
}
}
@UsePipes(new ValidationPipe({ whitelist: true }))
@SubscribeMessage(GAME_EVENTS.LEAVE_GAME)
leaveGame (
@ConnectedSocket()
client: Socket
): void {
const name: string | undefined = this.socketToPlayerName.get(client)
if (name !== undefined) {
void this.games.leaveGame(name)
}
}
}

15
back/src/pong/pong.module.ts

@ -0,0 +1,15 @@
import { forwardRef, Module } from '@nestjs/common'
import { PongGateway } from './pong.gateway'
import Result from './entity/result.entity'
import { TypeOrmModule } from '@nestjs/typeorm'
import { PongService } from './pong.service'
import { UsersModule } from 'src/users/users.module'
import { PongController } from './pong.controller'
@Module({
imports: [forwardRef(() => UsersModule), TypeOrmModule.forFeature([Result])],
providers: [PongGateway, PongService],
controllers: [PongController],
exports: [PongService]
})
export class PongModule {}

88
back/src/pong/pong.service.ts

@ -0,0 +1,88 @@
import { Injectable } from '@nestjs/common'
import { InjectRepository } from '@nestjs/typeorm'
import { Repository } from 'typeorm'
import { UsersService } from 'src/users/users.service'
import Result from './entity/result.entity'
import type User from 'src/users/entity/user.entity'
import { type Player } from './game/Player'
import { type PaginateQuery, paginate, type Paginated } from 'nestjs-paginate'
@Injectable()
export class PongService {
constructor (
@InjectRepository(Result)
private readonly resultsRepository: Repository<Result>,
private readonly usersService: UsersService
) {}
async updateStats (
player: User,
nameWhoWon: string
): Promise<void> {
player.matchs++
if (player.username === nameWhoWon) player.wins++
else player.looses++
player.winrate = (100 * player.wins) / player.matchs
}
async updatePlayer (
i: number,
result: Result,
nameWhoWon: string
): Promise<void> {
const player: User | null = result.players[i]
if (player == null) return
if (result.ranked) await this.updateStats(player, nameWhoWon)
player.results.push(result)
player.status = 'online'
await this.usersService.save(player)
}
async setInGame (playerName: string): Promise<void> {
const player = await this.usersService.findUserByName(playerName)
player.status = 'in-game'
await this.usersService.save(player)
}
async saveResult (
players: Player[],
ranked: boolean,
nameWhoWon: string
): Promise<void> {
const result = new Result()
const ply = new Array<User | null>()
ply.push(await this.usersService.findUserByName(players[0].name))
ply.push(await this.usersService.findUserByName(players[1].name))
result.ranked = ranked
result.players = ply
result.score = [players[0].score, players[1].score]
await this.resultsRepository.save(result)
await this.updatePlayer(0, result, nameWhoWon)
await this.updatePlayer(1, result, nameWhoWon)
await this.usersService.getLeaderboard()
}
async getHistory (
query: PaginateQuery,
ftId: number
): Promise<Paginated<Result>> {
let queryBuilder
if (ftId !== 0) {
queryBuilder = this.resultsRepository
.createQueryBuilder('result')
.innerJoin('result.players', 'player', 'player.ftId = :ftId', { ftId })
} else {
queryBuilder = this.resultsRepository
.createQueryBuilder('result')
.where('result.ranked = :ranked', { ranked: true })
}
return await paginate(query, queryBuilder, {
nullSort: 'last',
relations: ['players'],
defaultSortBy: [['date', 'DESC']],
sortableColumns: ['date'],
maxLimit: 10
})
}
}

8
back/src/types.d.ts

@ -0,0 +1,8 @@
declare module 'passport-42' {
export type Profile = any
export type VerifyCallback = any
export class Strategy {
constructor (options: any, verify: any)
authenticate (req: any, options: any): any
}
}

37
back/src/users/dto/user.dto.ts

@ -0,0 +1,37 @@
import { IsNotEmpty, IsPositive, IsOptional, IsEmail, NotContains, MaxLength, IsAlphanumeric } from 'class-validator'
import { ApiProperty } from '@nestjs/swagger'
import { Express } from 'express'
export class UserDto {
@IsPositive()
@IsOptional()
readonly ftId: number
@IsNotEmpty()
@NotContains(' ')
@IsAlphanumeric()
@MaxLength(15)
readonly username: string
@IsEmail()
@IsNotEmpty()
readonly email: string
@IsOptional()
readonly status: string
@IsOptional()
readonly avatar: string
@IsOptional()
readonly authToken: string
@IsOptional()
readonly isVerified: boolean
}
export class AvatarUploadDto {
@ApiProperty({ type: 'string', format: 'binary' })
file: Express.Multer.File
}

78
back/src/users/entity/user.entity.ts

@ -0,0 +1,78 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
ManyToMany,
JoinTable
} from 'typeorm'
import Result from 'src/pong/entity/result.entity'
@Entity()
export class User {
@PrimaryGeneratedColumn()
id: number
@Column({ type: 'bigint', default: Date.now() })
lastAccess: number
@Column({ unique: true })
ftId: number
@Column({ nullable: true })
email: string
@Column({ select: false, nullable: true })
authToken: string
@Column({ default: false })
twoFA: boolean
@Column({ default: false, nullable: true })
isVerified: boolean
@Column('uuid', { nullable: true, unique: true })
socketKey: string
@Column({ unique: true })
username: string
@Column({ default: 'online' })
status: string
@Column({ name: 'avatar' })
avatar: string
@Column({ default: 0 })
wins: number
@Column({ default: 0 })
looses: number
@Column({ default: 0 })
matchs: number
@Column({ default: 0 })
rank: number
@Column({ default: 0, type: 'double precision' })
winrate: number
@ManyToMany(() => Result, (result: Result) => result.players)
@JoinTable()
results: Result[]
@ManyToMany(() => User)
@JoinTable()
blocked: User[]
@ManyToMany(() => User)
@JoinTable()
followers: User[]
@ManyToMany(() => User)
@JoinTable()
friends: User[]
}
export default User

222
back/src/users/users.controller.ts

@ -0,0 +1,222 @@
import {
Controller,
Get,
Post,
Body,
Param,
ParseIntPipe,
UploadedFile,
UseGuards,
UseInterceptors,
Res,
StreamableFile,
BadRequestException,
Redirect,
Delete
} from '@nestjs/common'
import { FileInterceptor } from '@nestjs/platform-express'
import { diskStorage } from 'multer'
import { type User } from './entity/user.entity'
import { UsersService } from './users.service'
import { UserDto, AvatarUploadDto } from './dto/user.dto'
import { AuthenticatedGuard } from 'src/auth/42-auth.guard'
import { Profile42 } from 'src/auth/42.decorator'
import { Profile } from 'passport-42'
import { ApiBody, ApiConsumes } from '@nestjs/swagger'
import { type Request, Response } from 'express'
import { createReadStream } from 'fs'
import { join } from 'path'
@Controller('users')
export class UsersController {
constructor (private readonly usersService: UsersService) {}
@Get('blocked')
@UseGuards(AuthenticatedGuard)
async getBlockedUsers (@Profile42() profile: Profile): Promise<User[]> {
const user = await this.usersService.getFullUser(+profile.id)
if (user === null) throw new BadRequestException('User not found')
user.socketKey = ''
return user.blocked
}
@Get('block/:id')
@UseGuards(AuthenticatedGuard)
async blockUser (
@Profile42() profile: Profile,
@Param('id', ParseIntPipe) id: number
): Promise<void> {
const user = await this.usersService.getFullUser(+profile.id)
const target = await this.usersService.findUser(id)
if (user === null || target === null) {
throw new BadRequestException('User not found')
}
if (user.ftId === id) throw new BadRequestException('Cannot block yourself')
user.blocked.push(target)
console.log('user', JSON.stringify(user))
console.log('user', JSON.stringify(target))
await this.usersService.save(user)
}
@Delete('block/:id')
@UseGuards(AuthenticatedGuard)
async unblockUser (
@Profile42() profile: Profile,
@Param('id', ParseIntPipe) id: number
): Promise<void> {
const user = await this.usersService.getFullUser(+profile.id)
if (user === null) throw new BadRequestException('User not found')
const lenBefore = user.blocked.length
user.blocked = user.blocked.filter((usr: User) => {
return usr.ftId !== id
})
if (lenBefore === user.blocked.length) throw new BadRequestException('User not blocked')
await this.usersService.save(user)
}
@Get('all')
async getAllUsers (): Promise<User[]> {
return await this.usersService.findUsers()
}
@Get('online')
async getOnlineUsers (): Promise<User[]> {
return await this.usersService.findOnlineUsers()
}
@Get('friends')
@UseGuards(AuthenticatedGuard)
async getFriends (@Profile42() profile: Profile): Promise<User[]> {
return await this.usersService.getFriends(+profile.id)
}
@Get('invits')
@UseGuards(AuthenticatedGuard)
async getInvits (@Profile42() profile: Profile): Promise<User[]> {
return await this.usersService.getInvits(+profile.id)
}
@Get('leaderboard')
@UseGuards(AuthenticatedGuard)
async getLeaderboard (): Promise<User[]> {
return await this.usersService.getLeaderboard()
}
@Post('avatar')
@UseGuards(AuthenticatedGuard)
@Redirect(`http://${process.env.HOST ?? 'localhost'}`)
@UseInterceptors(
FileInterceptor('avatar', {
storage: diskStorage({
destination: 'avatars/'
}),
fileFilter: (request: Request, file: Express.Multer.File, callback) => {
if (!file.mimetype.includes('image')) {
callback(null, false)
return
}
callback(null, true)
}
})
)
@ApiConsumes('multipart/form-data')
@ApiBody({
description: 'A new avatar for the user',
type: AvatarUploadDto
})
async changeAvatar (
@Profile42() profile: Profile,
@UploadedFile() file: Express.Multer.File
): Promise<void> {
if (file === undefined) return
await this.usersService.addAvatar(+profile.id, file.filename)
}
@Get('avatar')
@UseGuards(AuthenticatedGuard)
async getAvatar (
@Profile42() profile: Profile,
@Res({ passthrough: true }) response: Response
): Promise<StreamableFile> {
return await this.getAvatarById(+profile.id, response)
}
@Get(':name/byname')
async getUserByName (@Param('name') username: string): Promise<User> {
const user = await this.usersService.findUserByName(username)
user.socketKey = ''
return user
}
@Get('invit/:username')
@UseGuards(AuthenticatedGuard)
async invitUser (
@Profile42() profile: Profile,
@Param('username') username: string
): Promise<void> {
const target: User | null = await this.usersService.findUserByName(
username
)
if (target === null) {
throw new BadRequestException(`User ${username} not found.`)
}
if (+profile.id === +target.ftId) {
throw new BadRequestException("You can't invite yourself.")
}
const ret: string = await this.usersService.invit(+profile.id, target.ftId)
if (ret !== 'OK') throw new BadRequestException(ret)
}
@Get(':id/avatar')
async getAvatarById (
@Param('id', ParseIntPipe) ftId: number,
@Res({ passthrough: true }) response: Response
): Promise<StreamableFile> {
const user: User | null = await this.usersService.findUser(ftId)
if (user === null) throw new BadRequestException('User unknown.')
const filename = user.avatar
const stream = createReadStream(join(process.cwd(), 'avatars/' + filename))
response.set({
'Content-Diposition': `inline; filename='${filename}'`,
'Content-Type': 'image/jpg'
})
return new StreamableFile(stream)
}
@Get(':id')
async getUserById (
@Param('id', ParseIntPipe) ftId: number
): Promise<User | null> {
const user = await this.usersService.findUser(ftId)
if (user == null) throw new BadRequestException('User unknown.')
user.socketKey = ''
return user
}
@Get()
@UseGuards(AuthenticatedGuard)
async getUser (@Profile42() profile: Profile): Promise<User | null> {
return await this.usersService.findUser(+profile.id)
}
@Post()
@UseGuards(AuthenticatedGuard)
async updateUser (
@Body() payload: UserDto,
@Profile42() profile: Profile
): Promise<User> {
const user = await this.usersService.findUser(+profile.id)
if (user == null) throw new BadRequestException('User not found.')
if (payload.username !== undefined) {
const user2: User | null = await this.usersService.findUserByName(payload.username).catch(() => null)
const user2ftId = user2?.ftId
if (user2 !== null && user2ftId !== +profile.id) throw new BadRequestException('Username already taken.')
}
await this.usersService.update(user, payload)
return user
}
}

14
back/src/users/users.module.ts

@ -0,0 +1,14 @@
import { forwardRef, Module } from '@nestjs/common'
import { TypeOrmModule } from '@nestjs/typeorm'
import { User } from './entity/user.entity'
import { UsersController } from './users.controller'
import { UsersService } from './users.service'
import { PongModule } from 'src/pong/pong.module'
@Module({
imports: [forwardRef(() => PongModule), TypeOrmModule.forFeature([User])],
controllers: [UsersController],
providers: [UsersService],
exports: [UsersService]
})
export class UsersModule {}

192
back/src/users/users.service.ts

@ -0,0 +1,192 @@
import { BadRequestException, Catch, Injectable } from '@nestjs/common'
import { InjectRepository } from '@nestjs/typeorm'
import { EntityNotFoundError, QueryFailedError, Repository } from 'typeorm'
import { Cron } from '@nestjs/schedule'
import { randomUUID } from 'crypto'
import { type UserDto } from './dto/user.dto'
import type Channel from 'src/chat/entity/channel.entity'
import User from './entity/user.entity'
@Injectable()
@Catch(QueryFailedError, EntityNotFoundError)
export class UsersService {
constructor (
@InjectRepository(User) private readonly usersRepository: Repository<User>
) {}
async save (user: User): Promise<void> {
await this.usersRepository.save(user)
}
async update (user: User, changes: UserDto): Promise<void> {
await this.usersRepository.update({ id: user.id }, changes)
}
async findUsers (): Promise<User[]> {
const users = await this.usersRepository.find({})
users.forEach((usr) => (usr.socketKey = ''))
return users
}
// WARNING: socketKey isn't removed here. it must be done before
// any return from it in a route.
async findUserByName (username: string): Promise<User> {
if (username === undefined || username === null) throw new BadRequestException('No username specified.')
const user = await this.usersRepository.findOne({
where: { username },
relations: { results: true }
})
if (user == null) throw new BadRequestException('User not found.')
return user
}
@Cron('*/30 * * * * *')
async updateStatus (): Promise<void> {
const users = await this.usersRepository.find({})
users.forEach((usr) => {
if (Date.now() - usr.lastAccess > 60000) {
usr.isVerified = false
usr.status = 'offline'
this.update(usr, usr).catch((err) => {
console.log(err)
})
}
})
await this.getLeaderboard()
}
async findUser (ftId: number): Promise<User | null> {
const user = await this.usersRepository.findOneBy({ ftId })
if (user == null) return null
user.lastAccess = Date.now()
if (user.status === 'offline') user.status = 'online'
await this.update(user, user)
return user
}
async getFullUser (ftId: number): Promise<User> {
const user = await this.usersRepository.findOne({
where: { ftId },
relations: ['results', 'blocked', 'friends']
})
if (user === null) throw new BadRequestException('User not found.')
return user
}
async findOnlineUsers (): Promise<User[]> {
const users = await this.usersRepository.find({
where: { status: 'online' }
})
users.forEach((usr) => (usr.socketKey = ''))
return users
}
async create (userData: UserDto): Promise<User | null> {
try {
const newUser = this.usersRepository.create(userData)
newUser.socketKey = randomUUID()
return await this.usersRepository.save(newUser)
} catch (err) {
throw new BadRequestException('User already exists.')
}
}
async findOnlineInChannel (channel: Channel): Promise<User[]> {
return await this.usersRepository
.createQueryBuilder('user')
.where('user.channel = :chan', { chan: channel })
.andWhere('user.status := status)', { status: 'online' })
.getMany()
}
async addAvatar (ftId: number, filename: string): Promise<void> {
await this.usersRepository.update({ ftId }, { avatar: filename })
}
async getFriends (ftId: number): Promise<User[]> {
const user = await this.usersRepository.findOne({
where: { ftId },
relations: { friends: true }
})
if (user == null) throw new BadRequestException('User not found.')
user.friends.forEach((friend) => (friend.socketKey = ''))
return user.friends
}
async getInvits (ftId: number): Promise<User[]> {
const user = await this.usersRepository.findOne({
where: { ftId },
relations: {
followers: true
}
})
if (user == null) throw new BadRequestException('User not found.')
user.followers.forEach((follower) => (follower.socketKey = ''))
return user.followers
}
async getLeaderboard (): Promise<User[]> {
const leaderboard = await this.usersRepository.find({
order: {
winrate: 'DESC'
}
})
let r = 1
const ret: User[] = []
for (const usr of leaderboard.filter((user) => user.matchs !== 0)) {
usr.rank = r++
await this.usersRepository.save(usr)
ret.push(usr)
usr.socketKey = ''
}
return ret
}
async invit (ftId: number, targetFtId: number): Promise<string> {
const user: User | null = await this.usersRepository.findOne({
where: { ftId },
relations: {
followers: true,
friends: true
}
})
if (user === null) throw new BadRequestException('User not found.')
if (user.friends.some((friend) => friend.ftId === targetFtId)) {
return 'You are already friends.'
}
const target: User | null = await this.usersRepository.findOne({
where: { ftId: targetFtId },
relations: {
followers: true,
friends: true
}
})
if (target == null) return 'Target not found.'
const id = user.followers.findIndex(
(follower) => follower.ftId === targetFtId
)
if (target.followers.some((follower) => follower.ftId === user.ftId)) {
return 'Invitation already sent.'
} else if (
user.followers.some((follower) => follower.ftId === targetFtId)
) {
user.friends.push(target)
target.friends.push(user)
user.followers.splice(id, 1)
await this.usersRepository.save(user)
} else target.followers.push(user)
await this.usersRepository.save(target)
return 'OK'
}
async findByCode (code: string): Promise<User> {
const user = await this.usersRepository.findOneBy({ authToken: code })
if (user == null) throw new BadRequestException('User not found')
return user
}
async turnOnTwoFactorAuthentication (ftId: number): Promise<void> {
await this.usersRepository.update({ ftId }, { twoFA: true })
}
}

4
back/tsconfig.build.json

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

19
back/tsconfig.json

@ -0,0 +1,19 @@
{
"compilerOptions": {
"module": "commonjs",
"declaration": true,
"removeComments": true,
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"allowSyntheticDefaultImports": true,
"target": "es2017",
"sourceMap": true,
"outDir": "./dist",
"baseUrl": "./",
"incremental": true,
"skipLibCheck": true,
"alwaysStrict": true,
"noImplicitAny": true,
"strictNullChecks": true
}
}

37
docker-compose.yml

@ -0,0 +1,37 @@
version: "3.8"
networks:
transcendence:
services:
front:
container_name: front
build:
context: ./
dockerfile: front.dockerfile
env_file: .env
depends_on: [postgres, back]
ports: [80:80]
volumes: [./front:/var/www/html]
networks: [transcendence]
restart: on-failure
back:
container_name: back
build:
context: ./
dockerfile: back.dockerfile
env_file: .env
depends_on: [postgres]
ports: [3001:3001]
networks: [transcendence]
volumes: [./back:/var/www/html]
restart: on-failure
postgres:
container_name: postgres
image: postgres
ports: [5432:5432]
volumes:
- ./postgres:/var/lib/postgresql/data
networks: [transcendence]
restart: always
env_file: .env

5
front.dockerfile

@ -0,0 +1,5 @@
FROM alpine:3.15
RUN apk update && apk upgrade && apk add npm
WORKDIR /var/www/html
ENTRYPOINT npm install && npm run dev

132
front/.gitignore

@ -0,0 +1,132 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
.pnpm-debug.log*
# Diagnostic reports (https://nodejs.org/api/report.html)
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov
# Coverage directory used by tools like istanbul
coverage
*.lcov
# nyc test coverage
.nyc_output
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
.grunt
# Bower dependency directory (https://bower.io/)
bower_components
# node-waf configuration
.lock-wscript
# Compiled binary addons (https://nodejs.org/api/addons.html)
build/Release
# Dependency directories
node_modules/
jspm_packages/
# Snowpack dependency directory (https://snowpack.dev/)
web_modules/
# TypeScript cache
*.tsbuildinfo
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Optional stylelint cache
.stylelintcache
# Microbundle cache
.rpt2_cache/
.rts2_cache_cjs/
.rts2_cache_es/
.rts2_cache_umd/
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarn-integrity
# dotenv environment variable files
.env
.env.development.local
.env.test.local
.env.production.local
.env.local
# parcel-bundler cache (https://parceljs.org/)
.cache
.parcel-cache
# Next.js build output
.next
out
# Nuxt.js build / generate output
.nuxt
dist
# Gatsby files
.cache/
# Comment in the public line in if your project uses Gatsby and not Next.js
# https://nextjs.org/blog/next-9-1#public-directory-support
# public
# vuepress build output
.vuepress/dist
# vuepress v2.x temp and cache directory
.temp
.cache
# Docusaurus cache and generated files
.docusaurus
# Serverless directories
.serverless/
# FuseBox cache
.fusebox/
# DynamoDB Local files
.dynamodb/
# TernJS port file
.tern-port
# Stores VSCode versions used for testing VSCode extensions
.vscode-test
# yarn v2
.yarn/cache
.yarn/unplugged
.yarn/build-state.yml
.yarn/install-state.gz
.pnp.*

18
front/index.html

@ -0,0 +1,18 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset='utf-8'>
<meta name='viewport' content='width=device-width,initial-scale=1'>
<title>Pong</title>
<link rel="stylesheet" href="https://fonts.googleapis.com/css?family=Press+Start+2P&display=swap" />
<link rel='icon' type='image/png' href='/img/pog.jpg'>
<link rel='stylesheet' href='/global.css'>
<script type="module" src="src/main.ts"></script>
</head>
<body>
</body>
</html>

1596
front/package-lock.json

File diff suppressed because it is too large

28
front/package.json

@ -0,0 +1,28 @@
{
"name": "Transcendence",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite --host",
"build": "vite build",
"preview": "vite preview --host",
"check": "svelte-check --tsconfig ./tsconfig.json",
"format": "prettier --write --plugin-search-dir=. 'src/**/*.{js,ts,html,svelte}'"
},
"devDependencies": {
"prettier": "^2.8.4",
"svelte-check": "^2.10.3",
"svelte-select": "^5.5.2",
"svelte-simple-modal": "^1.5.2"
},
"dependencies": {
"@sveltejs/vite-plugin-svelte": "^2.0.2",
"@tsconfig/svelte": "^3.0.0",
"@types/node": "^18.15.0",
"prettier-plugin-svelte": "^2.9.0",
"socket.io-client": "^4.6.1",
"svelte": "^3.55.1",
"vite": "^4.1.0"
}
}

BIN
front/public/audio/edge_hit.wav

Binary file not shown.

BIN
front/public/audio/paddle_hit.wav

Binary file not shown.

BIN
front/public/audio/score.wav

Binary file not shown.

77
front/public/global.css

@ -0,0 +1,77 @@
html, body {
position: fixed;
overflow: hidden;
width: 100%;
height: 100%;
}
body, input, button {
font-family: 'Press Start 2P', cursive;
font-size: 16px;
}
body {
color: #e8e6e3;
margin: 0;
padding: 8px;
box-sizing: border-box;
background-color: #212529;
}
a {
color: #198754;
text-decoration: none;
}
a:hover {
text-decoration: underline;
}
a:visited {
color: #157347;
}
label {
display: block;
}
input, button, select, textarea {
font-family: inherit;
font-size: inherit;
padding: 0.4em 0;
margin: 0 0 0.5em 0;
box-sizing: border-box;
border: 1px solid #495057;
border-radius: 4px;
background-color: #343a40;
color: #e8e6e3;
}
input:disabled {
color: #6c757d;
}
button {
color: #e8e6e3;
background-color: #198754;
border: none;
cursor: pointer;
padding: 0.5rem 1rem;
border-radius: 4px;
transition: background-color 0.2s ease-in-out;
font-size: 1rem;
outline: none;
}
button:disabled {
color: #aab3bb;
cursor: not-allowed;
}
button:not(:disabled):active {
background-color: #157347;
}
button:focus {
box-shadow: 0 0 0 2px rgba(25, 135, 84, 0.25);
}

BIN
front/public/img/chat.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 724 B

BIN
front/public/img/close.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 342 B

BIN
front/public/img/pog.jpg

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.1 KiB

BIN
front/public/img/pong.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

BIN
front/public/img/send.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 858 B

1
front/public/vite.svg

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

311
front/src/App.svelte

@ -0,0 +1,311 @@
<script lang="ts" context="module">
export enum APPSTATE {
HOME = "/",
PROFILE = "/profile",
HISTORY = "/history",
FRIENDS = "/friends",
CHANNELS = "/channels",
LEADERBOARD = "/leaderboard",
CREATE_GAME = "/create-game",
MATCHMAKING = "/matchmaking",
}
</script>
<script lang="ts">
import { onMount } from "svelte";
import Navbar from "./components/NavBar.svelte";
import Modal from "svelte-simple-modal";
import Profile from "./components/Profile.svelte";
import MatchHistory from "./components/MatchHistory.svelte";
import Friends, { addFriend } from "./components/Friends.svelte";
import Chat from "./components/Chat.svelte";
import Channels, { formatChannelNames, getDMs } from "./components/Channels.svelte";
import Leaderboard from "./components/Leaderboard.svelte";
import { popup, show_popup } from "./components/Alert/content";
import Pong from "./components/Pong/Pong.svelte";
import type { ChannelsType } from "./components/Channels.svelte";
import { store, getUser, login, verify, API_URL } from "./Auth";
import { get } from "svelte/store";
import type { CreateChannelDto } from "./components/dtos/create-channel.dto";
async function openDirectChat(event: CustomEvent<string>) {
const DMUsername = event.detail;
let DMChannel: Array<ChannelsType> = [];
const res = await getDMs(DMUsername)
if (res && res.ok) {
DMChannel = await res.json();
if (DMChannel.length != 0)
await formatChannelNames(DMChannel)
setAppState(APPSTATE.CHANNELS + "#" + DMChannel[0].name)
} else {
console.log("Creating DMChannel: " + get(store).username + "&" + DMUsername)
const body: CreateChannelDto = {
name: "none",
owner: get(store).ftId,
password: "",
isPrivate: true,
isDM: true,
otherDMedUsername: DMUsername
}
fetch(API_URL + "/channels", {
credentials: "include",
method: "POST",
mode: "cors",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(body),
}).then(async () => {
const response = await getDMs(DMUsername)
if (response && response.ok) {
DMChannel = await response.json();
if (DMChannel.length != 0) {
await formatChannelNames(DMChannel)
setAppState(APPSTATE.CHANNELS + "#" + DMChannel[0].name)
} else {
show_popup("Error: Couldn't create DM.", false)
}
} else {
show_popup("Error: Couldn't create DM.", false)
}
}).catch(() => {
show_popup("Error: Couldn't create DM.", false)
})
}
}
// Single Page Application config
let appState: string = APPSTATE.HOME;
function setAppState(newState: APPSTATE | string) {
if (newState === appState) return;
history.pushState({ appState: newState, prevState: appState }, "", newState);
appState = newState;
}
function resetAppState() {
setAppState(APPSTATE.HOME);
}
onMount(() => {
if (window.location.pathname === "/profile") {
appState = APPSTATE.PROFILE;
history.replaceState({ appState: "/profile" }, "", "/profile");
}
else
history.replaceState({ appState: "" }, "", "/");
window.onpopstate = (e: PopStateEvent) => {
if (e.state) {
appState = e.state.appState;
}
};
getUser();
});
setInterval(() => {
getUser();
}, 15000);
function clickProfile() {
setAppState(APPSTATE.PROFILE);
}
async function openIdProfile(event: CustomEvent<string>) {
setAppState(APPSTATE.PROFILE + "#" + event.detail);
}
async function openIdHistory(event: CustomEvent<string>) {
setAppState(APPSTATE.HISTORY + "#" + event.detail);
}
async function clickHistory() {
setAppState(APPSTATE.HISTORY);
}
async function clickFriends() {
setAppState(APPSTATE.FRIENDS);
}
async function clickLeaderboard() {
setAppState(APPSTATE.LEADERBOARD);
}
function clickChannels() {
setAppState(APPSTATE.CHANNELS);
}
let selectedChannel: ChannelsType;
const handleSelectChannel = (channel: ChannelsType) => {
selectedChannel = channel;
setAppState(APPSTATE.CHANNELS + "#" + channel.name);
};
// GAME
let pong: Pong;
let gamePlaying: boolean = false;
let failedGameLogIn: boolean = false;
let resetGameConnection: () => void;
</script>
<div>
<Modal show={$popup} transitionWindowProps={
{
duration: 200,
easing: (t) => t * (2 - t),
}
}
>
{#if $store === null}
<div class="login-div">
<h3 class="test">Please log in with 42 api to access the website.</h3>
<img
class="img-42"
src="https://translate.intra.42.fr/assets/42_logo-7dfc9110a5319a308863b96bda33cea995046d1731cebb735e41b16255106c12.svg"
alt="logo_42"
/>
<button class="login-button" type="button" on:click={login}>Log In</button
>
</div>
{:else if $store.twoFA === true && $store.isVerified === false}
<div class="login-div">
<button class="login-button" type="button" style="width:100%;height:100%;font-size:xx-large;" on:click={verify}>Verify</button
>
</div>
{:else}
{#if !failedGameLogIn && !gamePlaying}
<Navbar
{clickProfile}
{clickHistory}
{clickFriends}
{clickChannels}
{clickLeaderboard}
{failedGameLogIn}
{gamePlaying}
/>
{#if appState.includes(`${APPSTATE.CHANNELS}#`)}
{#key appState}
<div
on:click={() => setAppState(APPSTATE.CHANNELS)}
on:keydown={() => setAppState(APPSTATE.CHANNELS)}
>
<Chat
{appState}
{setAppState}
bind:channel={selectedChannel}
on:view-profile={openIdProfile}
on:add-friend={addFriend}
on:invite-to-game={pong.inviteToGame}
on:return-home={resetAppState}
/>
</div>
{/key}
{/if}
{#if appState.includes(APPSTATE.CHANNELS)}
<div
class="{appState !== APPSTATE.CHANNELS ? 'hidden' : ''}"
on:click={resetAppState}
on:keydown={resetAppState}
>
<Channels onSelectChannel={handleSelectChannel} />
</div>
{/if}
{#if appState === APPSTATE.LEADERBOARD}
<div on:click={resetAppState} on:keydown={resetAppState}>
<Leaderboard />
</div>
{/if}
{#if appState == APPSTATE.FRIENDS}
<div on:click={resetAppState} on:keydown={resetAppState}>
<Friends
on:view-profile={openIdProfile}
on:invite-to-game={pong.inviteToGame}
/>
</div>
{/if}
{#if appState === APPSTATE.HISTORY}
<div on:click={resetAppState} on:keydown={resetAppState}>
<MatchHistory {appState} />
</div>
{/if}
{#if appState.includes(`${APPSTATE.HISTORY}#`)}
<div
on:click={() => setAppState(APPSTATE.PROFILE)}
on:keydown={() => setAppState(APPSTATE.PROFILE)}
>
<MatchHistory {appState} />
</div>
{/if}
{#if appState === APPSTATE.PROFILE}
<div on:click={resetAppState} on:keydown={resetAppState}>
<Profile {appState} {resetGameConnection} {gamePlaying} on:view-history={openIdHistory} />
</div>
{/if}
{#if appState.includes(`${APPSTATE.PROFILE}#`)}
<div
on:click={() => setAppState(history.state.prevState)}
on:keydown={() => setAppState(history.state.prevState)}
>
<Profile
{appState}
{gamePlaying}
on:send-message={openDirectChat}
on:view-history={openIdHistory}
on:add-friend={addFriend}
on:invite-to-game={pong.inviteToGame}
/>
</div>
{/if}
{/if}
<Pong bind:gamePlaying={gamePlaying} bind:this={pong} bind:failedGameLogIn={failedGameLogIn} {appState} {setAppState} bind:resetGameConnection={resetGameConnection} />
{/if}
</Modal>
</div>
<style>
:global(body) {
background-color: #212529;
color: #e8e6e3;
margin: 0;
padding: 0;
}
.login-div {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100vh;
gap: 1rem;
}
.img-42 {
-webkit-filter: invert(100%);
filter: invert(100%);
width: 64px;
height: 64px;
}
.login-button {
display: inline-block;
background-color: #198754;
border: none;
color: #fff;
padding: 0.5rem 1rem;
border-radius: 4px;
cursor: pointer;
font-size: 1rem;
transition: background-color 0.2s ease-in-out;
}
.login-button:hover {
background-color: #157347;
}
.login-button:focus {
outline: none;
box-shadow: 0 0 0 2px rgba(25, 135, 84, 0.25);
}
.hidden {
display: none;
}
</style>

47
front/src/Auth.ts

@ -0,0 +1,47 @@
import { writable } from "svelte/store";
import { content, show_popup } from "./components/Alert/content";
import {get} from 'svelte/store'
import type { EmailDto } from "./components/dtos/updateUser.dto";
let _user = localStorage.getItem("user");
export const store = writable(_user ? JSON.parse(_user) : null);
store.subscribe((value) => {
if (value) localStorage.setItem("user", JSON.stringify(value));
else localStorage.removeItem("user");
});
export const API_URL = `http://${import.meta.env.VITE_HOST}:${
import.meta.env.VITE_BACK_PORT
}`;
export async function getUser() {
const res = await fetch(API_URL + "/users", {
method: "get",
mode: "cors",
cache: "no-cache",
credentials: "include",
redirect: "follow",
referrerPolicy: "no-referrer",
});
let user = await res.json();
if (user.username) store.set(user);
else store.set(null);
}
export function login() {
window.location.replace(API_URL + "/log/in");
}
export async function verify() {
const response = await fetch(API_URL + "/log/verify", {
method: "GET",
mode: "cors",
credentials: "include",
});
if (response.ok)
await show_popup(`We have sent you an email to verify your account. email: ${get(store).email} If you can't acces this mailbox, you still can contact us at vaganiwast@gmail.com to start unlocking your account.`, false);
}
export function logout() {
window.location.replace(API_URL + "/log/out");
store.set(null);
}

4
front/src/app.d.ts

@ -0,0 +1,4 @@
declare global {
namespace App {}
}
export {};

77
front/src/components/Alert/Alert.svelte

@ -0,0 +1,77 @@
<script lang="ts">
import { onMount, onDestroy } from "svelte";
import { content, popup } from "./content";
export let message: string;
export let form = true;
export let passwordInput = false;
export let onCancel = () => {};
export let onOkay = () => {};
let value = "";
onMount(() => {
$content = "";
});
onDestroy(() => {
popup.set(null);
});
function _onCancel() {
onCancel();
$content = "";
popup.set(null);
}
function _onOkay() {
onOkay();
if (form) $content = value;
else $content = "ok";
popup.set(null);
}
</script>
<div>
<h2>{message}</h2>
{#if form === true}
{#if passwordInput === true}
<input
required
type="password"
bind:value
on:keydown={(e) => e.which === 13 && _onOkay()}
/>
{:else}
<input
required
type="text"
bind:value
on:keydown={(e) => e.which === 13 && _onOkay()}
/>
{/if}
{/if}
<div class="buttons">
<button on:click={_onCancel}> Cancel </button>
<button on:click={_onOkay}> Okay </button>
</div>
</div>
<style>
h2 {
font-size: 1rem;
text-align: center;
}
input {
width: 100%;
text-align: center;
word-wrap: break-word;
}
.buttons {
display: flex;
justify-content: space-between;
}
</style>

30
front/src/components/Alert/content.ts

@ -0,0 +1,30 @@
import { writable } from 'svelte/store';
import Alert__SvelteComponent_ from './Alert.svelte';
export const content = writable("")
export const popup = writable(null)
import { bind } from 'svelte-simple-modal';
let val;
export async function show_popup(message, form = true, passwordInput = false) {
popup.set(bind(Alert__SvelteComponent_, {
message,
form,
passwordInput
}))
await waitForCondition()
}
export async function waitForCondition() {
const unsub = popup.subscribe((value) => { val = value })
async function checkFlag() {
if (val == null) {
unsub()
await new Promise(resolve => setTimeout(resolve, 100))
} else {
await new Promise(resolve => setTimeout(resolve, 200))
return await checkFlag()
}
}
return await checkFlag()
}

394
front/src/components/Channels.svelte

@ -0,0 +1,394 @@
<script lang="ts" context="module">
import { content, show_popup } from './Alert/content'
import { onMount } from "svelte";
import { API_URL, store } from "../Auth";
import type User from "./Profile.svelte";
import type { CreateChannelDto } from './dtos/create-channel.dto';
import type { IdDto, PasswordDto } from './dtos/updateUser.dto';
export interface ChannelsType {
id: number;
name: string;
isPrivate: boolean;
password: string;
owner: User;
admins: Array<User>;
banned: Array<Array<number>>;
muted: Array<Array<number>>;
isDM: boolean;
}
export interface ChatMessageServer {
id: number;
author: User;
text: string;
}
export interface ChatMessage extends ChatMessageServer {
hidden: boolean;
}
export async function formatChannelNames(channel: Array<ChannelsType>): Promise<void> {
const res = await fetch(API_URL + "/users/all", {
credentials: "include",
mode: "cors",
})
if (res.ok) {
const users: User[] = await res.json()
if (users) {
channel.forEach((channel) => {
let channelName = channel.name;
if (channelName.startsWith("🚪 ")) return;
const split = channelName.split("&");
if (split.length > 1) {
const firstID = parseInt(split[0]);
const secondID = parseInt(split[1]);
let newChannelName = channelName;
users.forEach((user: User) => {
if (user.ftId === firstID) {
newChannelName = newChannelName.replace(
split[0],
user.username
);
}
if (user.ftId === secondID) {
newChannelName = newChannelName.replace(
split[1],
user.username
);
}
});
channel.name = newChannelName;
} else {
console.log("Could not format channel name")
}
});
}
}
}
export async function getDMs(username: string): Promise<Response | null> {
const res = await fetch(API_URL + "/channels/dms/" + username, {
credentials: "include",
mode: "cors",
})
if (res.ok)
return res;
else
return null;
}
</script>
<script lang="ts">
//--------------------------------------------------------------------------------//
let channelMode = "";
const channelOptions = ["public", "private", "protected"];
const getChannels = async () => {
const res = await fetch(API_URL + "/channels", {
credentials: "include",
mode: "cors",
});
if (res.ok) {
const newChannels: Array<ChannelsType> = await res.json();
await formatChannelNames(newChannels);
channels = newChannels;
}
};
let channels: Array<ChannelsType> = [];
onMount(async () => {
getChannels()
});
//--------------------------------------------------------------------------------/
export let onSelectChannel: (channel: ChannelsType) => void;
const createChannel = async () => {
let password = "";
await show_popup("Enter a name for the new channel:")
const name: string = $content;
if (name === "") return;
if (name.includes("#")) {
await show_popup("Channel name cannot contain #", false)
return;
}
if (channels.some((chan) => chan.name === name)) {
await show_popup("A channel with this name already exist", false)
return;
}
if (channelMode === 'protected'){
await show_popup("Enter a password for the new channel:", true, true)
password = $content
if (password == "") {
await show_popup("Password is required #", false)
return ;
}
}
const editedName = "🚪 " + name;
const body: CreateChannelDto = {
name: editedName,
owner: $store.ftId,
password: password,
isPrivate: channelMode === "private",
isDM: false,
otherDMedUsername: "",
};
const response = await fetch(API_URL + "/channels", {
credentials: "include",
method: "POST",
mode: "cors",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(body),
});
if (!response.ok) {
const error = await response.json();
await show_popup(error.message, false)
}
getChannels()
};
//--------------------------------------------------------------------------------/
const removeChannel = async (id: number) => {
await show_popup("press \"Okay\"to delete this channel", false);
if ($content === "ok") {
const response = await fetch(API_URL + "/channels/" + id, {
credentials: "include",
method: "DELETE",
mode: "cors",
});
if (response.ok) channels = channels.filter((c) => c.id !== id);
else {
const error = await response.json();
await show_popup(error.message, false)
}
}
};
//--------------------------------------------------------------------------------/
const inviteChannel = async (id: number) => {
await show_popup("Enter the username of the user you want to invite");
const username = $content;
if (username === "") return;
const response = await fetch(API_URL + "/users/" + username + "/byname", {
credentials: "include",
method: "GET",
mode: "cors",
});
if (response.ok) {
const user = await response.json();
console.log(user)
const body: IdDto = {
id: user.ftId
}
const response2 = await fetch(API_URL + "/channels/" + id + "/invite", {
credentials: "include",
method: "POST",
mode: "cors",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(body),
});
if (response2.ok) {
await show_popup("User invited", false)
} else {
const error = await response2.json();
await show_popup(error.message, false)
}
} else {
const error = await response.json();
await show_popup(error.message, false)
}
};
//--------------------------------------------------------------------------------/
const changePassword = async (id: number) => {
await show_popup("Enter the new password for this channel (leave empty to remove password) :", true, true);
const newPassword = $content;
if (newPassword === "") return;
const body: PasswordDto = {
password: newPassword
}
const response = await fetch(API_URL + "/channels/" + id + "/password", {
credentials: "include",
method: "POST",
mode: "cors",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(body),
});
if (!response.ok) {
const error = await response.json();
await show_popup(error.message, false)
} else {
getChannels()
await show_popup("Password updated", false)
}
};
//--------------------------------------------------------------------------------/
</script>
<div class="overlay">
<div class="channels" on:click|stopPropagation on:keydown|stopPropagation>
<div>
<h2 >Channels <button class="refresh" on:click={() => getChannels()}>🔄</button> </h2>
{#if channels.length > 0}
{#each channels as channel}
<li>
<span>{channel.name} : {channel.id}</span>
<div style="display:block; margin-right:10%">
<button on:click={() => onSelectChannel(channel)}>🔌</button>
<button
on:click={() => removeChannel(channel.id)}
on:keydown={() => removeChannel(channel.id)}>🗑️</button
>
{#if channel.isPrivate == true && !channel.isDM}
<button on:click={() => inviteChannel(channel.id)}>🤝</button>
{/if}
{#if !channel.isDM}
<button on:click={() => changePassword(channel.id)}>🔑</button>
{/if}
</div>
</li>
{/each}
{:else}
<p>No channels available</p>
{/if}
<div>
<select bind:value={channelMode}>
{#each channelOptions as option}
<option value={option} selected={channelMode === option}
>{option}</option
>
{/each}
</select>
{#if channelMode != ""}
<button class="button" on:click={createChannel}>Create Channel</button
>
{/if}
</div>
</div>
</div>
</div>
<style>
.overlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.5);
display: flex;
justify-content: center;
align-items: center;
overflow-x:visible;
}
.channels {
background-color: #343a40;
border: 1px solid #dedede;
border-radius: 5px;
padding: 1rem;
width: 500px;
overflow: auto;
max-height: 80%;
}
h2 {
margin-bottom: 1rem;
font-size:xx-large;
}
p {
font-size: 14px;
margin-bottom: 1rem;
}
li {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 14px;
margin-bottom: 0.5rem;
flex-wrap: wrap;
}
button {
background-color: #6b8e23;
color: #ffffff;
border: none;
border-radius: 5px;
padding: 0.5rem 1rem;
font-size: 14px;
cursor: pointer;
outline: none;
white-space: nowrap;
margin-bottom: 5px;
}
select {
color:black;
text-align: center;
margin: 50;
width: 100%;
height: 15%;
padding: 10px;
border-radius: 20px;
background: #eee;
border: none;
outline: grey;
display: inline-block;
-webkit-appearance: none;
-moz-appearance: none;
appearance: none;
cursor: pointer;
font-size: 100%;
}
.refresh {
display: inline-block;
background-color: rgb(187, 187, 187);
color: #a0a0a0;
border: none;
border-radius: 5px;
font-size: 14px;
cursor: pointer;
outline: none;
position:relative;
top:-10px;
right:auto
}
.button {
background-color: #6b8e23;
color: #ffffff;
border: none;
border-radius: 5px;
padding: 0.5rem 1rem;
font-size: 14px;
cursor: pointer;
outline: none;
width: 100%;
}
span {
width: 100%;
outline: 10%;
margin: 1%;
height: 10%;
font-size: 100%; /* Taille de la police en pourcentage */
padding: 10px;
top: 2px;
}
</style>

485
front/src/components/Chat.svelte

@ -0,0 +1,485 @@
<script lang="ts" context="module">
import { createEventDispatcher, onDestroy, onMount } from "svelte";
import { store, API_URL } from "../Auth";
import { io, Socket } from "socket.io-client";
import { show_popup, content } from "./Alert/content";
import { APPSTATE } from "../App.svelte";
import type User from "./Profile.svelte";
import { formatChannelNames, type ChannelsType, type ChatMessage, type ChatMessageServer } from "./Channels.svelte";
import type { IdDto, MuteDto } from "./dtos/updateUser.dto";
import type { ConnectionDto } from "./dtos/connection.dto";
import type { CreateMessageDto } from "./dtos/create-message.dto";
import type { kickUserDto } from "./dtos/kickUser.dto";
</script>
<script lang="ts">
export let channel: ChannelsType;
export let messages: Array<ChatMessage> = [];
export let appState: string;
export let setAppState: (newState: APPSTATE | string) => void;
let socket: Socket;
let newText = "";
let usersInterval: ReturnType<typeof setInterval>;
let blockedUsers: Array<User> = [];
let chatMembers: Array<User> = [];
async function getCurrentChannel() {
const res = await fetch(API_URL + "/channels", {
credentials: "include",
mode: "cors",
});
if (res.ok) {
const newChannels: Array<ChannelsType> = await res.json();
await formatChannelNames(newChannels);
newChannels.forEach((newChannel) => {
const urlSplit = appState.split("#", 2)
if (urlSplit.length > 1) {
const currentChannelName = appState.split("#", 2)[1];
if (newChannel.name === currentChannelName) {
channel = newChannel;
}
}
});
}
}
onMount(async () => {
socket = io(API_URL);
socket.connect();
await getCurrentChannel();
if (!channel) setAppState(APPSTATE.CHANNELS);
if (!channel.password) {
const data: ConnectionDto = {
UserId: $store.ftId,
socketKey: $store.socketKey,
ChannelId: channel.id,
pwd: "",
};
socket.emit("joinChannel", data);
} else {
await show_popup("Channel is protected, enter password:", true, true);
const password = $content
if (password === "") {
setAppState(APPSTATE.CHANNELS);
return
}
const data: ConnectionDto = {
UserId: $store.ftId,
socketKey: $store.socketKey,
ChannelId: channel.id,
pwd: password,
};
socket.emit("joinChannel", data);
}
socket.on("newMessage", (serverMsg: ChatMessageServer) => {
console.log(serverMsg);
const newMsg: ChatMessage = {...serverMsg, hidden: false};
if (blockedUsers.some((user) => newMsg.author.ftId === user.ftId))
newMsg.hidden = true;
messages = [...messages, newMsg];
});
socket.on("messages", (msgs: Array<ChatMessageServer>) => {
getMembers().then(() => {
console.log("You are joining channel: ", channel.name);
console.log(`Blocked users: ${blockedUsers.map((user) => user.username)}`);
console.log(`Chat members: ${chatMembers.map((user) => user.username)}`);
console.log(`Banned members: ${channel.banned.map((user) => user[0])}`);
console.log(`Muted users: ${channel.muted.map((user) => user[0])}`);
messages = msgs.map((msg) => {
const hidden = blockedUsers.some((user) => msg.author.ftId === user.ftId)
return {...msg, hidden: hidden};
});
});
usersInterval = setInterval(async () => {
getMembers();
}, 1000);
});
socket.on("failedJoin", (error: string) => {
show_popup(`Failed to join channel: ${error}`, false);
setAppState(APPSTATE.CHANNELS);
});
socket.on("kicked", () => {
show_popup(`You have been kicked from channel`, false);
setAppState(APPSTATE.HOME);
})
socket.on("deleted", () => {
show_popup(`Channel has been deleted`, false);
setAppState(APPSTATE.HOME);
})
console.log("Try to join channel: ", $store.ftId, channel.id);
});
const dispatch = createEventDispatcher();
async function getMembers() {
if (!channel) return;
let res = await fetch(API_URL + "/users/blocked/", {
credentials: "include",
mode: "cors",
});
if (res.ok) blockedUsers = await res.json();
res = await fetch(`${API_URL}/channels/${channel.id}/users`, {
credentials: "include",
mode: "cors",
});
if (res.ok) chatMembers = await res.json();
}
onDestroy(() => {
clearInterval(usersInterval);
socket.disconnect();
});
//--------------------------------------------------------------------------------/
const sendMessage = () => {
if (newText !== "") {
const data: CreateMessageDto = {
text: newText,
UserId: $store.ftId,
ChannelId: channel.id,
};
socket.emit("addMessage", data);
newText = "";
const messagesDiv = document.querySelector(".messages");
if (messagesDiv) {
messagesDiv.scrollTop = messagesDiv.scrollHeight;
}
}
};
//--------------------------------------------------------------------------------/
let showChatMembers = false;
function toggleChatMembers() {
showChatMembers = !showChatMembers;
}
//--------------------------------------------------------------------------------/
const banUser = async (username: string) => {
let response = await fetch(API_URL + "/users/" + username + "/byname", {
credentials: "include",
mode: "cors",
});
if (response.ok) {
const target = await response.json();
await show_popup(
"Enter a time for which the user will be banned from this channel"
);
const duration = $content;
if (duration === "") return
if (isNaN(Number(duration)) || Number(duration) < 0) return await show_popup("Invalid duration", false);
const body: MuteDto = {
data: [target.ftId, duration]
}
response = await fetch(API_URL + "/channels/" + channel.id + "/ban", {
credentials: "include",
method: "POST",
mode: "cors",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(body),
});
if (response.ok) {
const data: kickUserDto = {
chan: channel.id,
from: $store.ftId,
to: target.ftId,
};
socket.emit("kickUser", data);
await show_popup(`User banned for: ${duration} seconds`, false);
} else {
const error = await response.json();
await show_popup(error.message, false)
}
}
};
//--------------------------------------------------------------------------------/
const kickUser = async (username: string) => {
const response = await fetch(API_URL + "/users/" + username + "/byname", {
credentials: "include",
mode: "cors",
});
if (response.ok) {
const target = await response.json();
const data: kickUserDto = {
chan: channel.id,
from: $store.ftId,
to: target.ftId,
};
socket.emit("kickUser", data);
} else {
const error = await response.json();
await show_popup(error.message, false);
}
};
//--------------------------------------------------------------------------------/
const muteUser = async (username: string) => {
await show_popup("Enter mute duration in seconds");
const muteDuration = $content;
if (muteDuration === "") return;
if (isNaN(Number(muteDuration)) || Number(muteDuration) < 0) return await show_popup("Invalid duration", false);
let response = await fetch(API_URL + "/users/" + username + "/byname", {
credentials: "include",
mode: "cors",
});
const target = await response.json();
if (response.ok) {
const body: MuteDto = {
data: [target.ftId, muteDuration]
}
response = await fetch(API_URL + "/channels/" + channel.id + "/mute", {
credentials: "include",
method: "POST",
mode: "cors",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(body),
});
}
if (response.ok) await show_popup("User muted", false);
else {
const error = await response.json()
await show_popup(error.message, false);
}
};
//--------------------------------------------------------------------------------/
const adminUser = async (username: string) => {
let response = await fetch(API_URL + "/users/" + username + "/byname", {
credentials: "include",
mode: "cors",
});
if (response.ok) {
const target = await response.json();
const body: IdDto = {
id: target.ftId
}
response = await fetch(API_URL + "/channels/" + channel.id + "/admin", {
credentials: "include",
method: "POST",
mode: "cors",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(body),
});
}
if (response.ok) {
await show_popup("User promoted", false);
} else {
const error = await response.json();
await show_popup(error.message, false);
}
};
const removeAdminUser = async (username: string) => {
let response = await fetch(API_URL + "/users/" + username + "/byname", {
credentials: "include",
mode: "cors",
});
if (response.ok) {
const target = await response.json();
response = await fetch(API_URL + "/channels/" + channel.id + "/admin", {
credentials: "include",
method: "DELETE",
mode: "cors",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ id: target.ftId }),
});
}
if (response.ok) {
await show_popup("User demoted", false);
} else {
const error = await response.json();
await show_popup(error.message, false);
}
};
//--------------------------------------------------------------------------------/
const leaveChannel = async () => {
await show_popup('Press "Okay" to leave this channel?', false);
if ($content == "ok") {
await socket.emitWithAck("leaveChannel")
dispatch("return-home")
}
};
const updateHiddens = (event: CustomEvent<string>) => {
const username = event.detail;
messages = messages.map((message) => {
if (message.author.username === username) {
message.hidden = !message.hidden;
}
return message;
});
}
</script>
<div class="overlay">
<div class="chat" on:click|stopPropagation on:keydown|stopPropagation>
<div class="messages">
{#each messages as message}
<p class="message">
{#if !message.hidden}
<span
class="message-name"
on:click={() => dispatch("view-profile", message.author.ftId)}
on:keydown={() => dispatch("view-profile", message.author.ftId)}
style="cursor: pointer;"
>
{message.author.username}
</span>: {message.text}
{/if}
</p>
{/each}
</div>
<form on:submit|preventDefault={sendMessage}>
<input type="text" placeholder="Type a message..." bind:value={newText} />
<button style="background:#dedede; margin:auto">
<img src="img/send.png" alt="send" />
</button>
</form>
<div>
<button on:click={leaveChannel}>Leave</button>
<button
on:click|stopPropagation={toggleChatMembers}
on:keydown|stopPropagation
>
Chat Members
</button>
</div>
{#if showChatMembers}
<div on:click|stopPropagation on:keydown|stopPropagation />
<ul>
{#each chatMembers as member}
<li>
<p>
{member.username}
<button on:click={() => dispatch("view-profile", member.ftId)}> profile </button>
<button on:click={() => banUser(member.username)}> ban </button>
<button on:click={() => kickUser(member.username)}> kick </button>
<button on:click={() => muteUser(member.username)}> mute </button>
<button on:click={() => adminUser(member.username)}> promote </button>
<button on:click={() => removeAdminUser(member.username)}> demote </button>
</p>
</li>
{/each}
</ul>
{/if}
</div>
</div>
<style>
.overlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.5);
display: flex;
justify-content: center;
align-items: center;
}
.chat {
background-color: #343a40;
border: 1px solid #dedede;
border-radius: 5px;
padding: 1rem;
max-width: 90%;
max-height: 80vh;
width: auto;
margin: auto;
display: flex;
flex-direction: column;
}
.messages {
height: 400px;
width: 100%;
overflow-y: auto;
border-bottom: 1px solid #dedede;
padding-bottom: 1rem;
}
.message {
font-size: 14px;
max-width: 90%;
width: auto;
line-height: 1.4;
margin-bottom: 0.5rem;
word-wrap: break-word;
}
.message-name {
font-weight: 600;
color: #4c4c4c;
}
input[type="text"] {
width: 82%;
padding: 0.5rem;
border: 1px solid #dedede;
border-radius: 5px;
font-size: 14px;
outline: none;
margin-bottom: 1rem;
}
button {
background-color: #6b8e23;
color: #ffffff;
border: none;
border-radius: 5px;
padding: 0.5rem 1rem;
font-size: 14px;
cursor: pointer;
outline: none;
margin-bottom: 1rem;
}
button:last-child {
margin-bottom: 0;
}
img {
width: 16px;
height: 16px;
}
ul {
list-style: none;
padding: 0;
margin: 0;
}
li {
margin-bottom: 0.5rem;
}
li:last-child {
margin-bottom: 0;
}
</style>

190
front/src/components/Friends.svelte

@ -0,0 +1,190 @@
<script lang="ts" context="module">
import { onMount, onDestroy } from "svelte";
import { API_URL, store } from "../Auth";
import { show_popup } from "./Alert/content";
import { createEventDispatcher } from "svelte";
export interface Friend {
username: string;
status: "online" | "offline" | "in a game";
ftId: number;
}
export async function addFriend(event: any) {
console.log(typeof event);
event.preventDefault();
const username = event.target
? event.target.querySelector('input[type="text"]').value
: event.detail;
const response = await fetch(API_URL + "/users/invit/" + username, {
credentials: "include",
mode: "cors",
});
if (response.ok) {
show_popup("Invitation send.", false);
} else {
const error = (await response.json()).message;
show_popup("Invitation failed: " + error, false);
}
}
</script>
<script lang="ts">
const dispatch = createEventDispatcher();
let friends: Friend[] = [];
let invits: Friend[] = [];
let friendsInterval: ReturnType<typeof setInterval>;
onMount(() => {
getFriends();
getInvits();
friendsInterval = setInterval(async () => {
getFriends();
getInvits();
}, 5000);
});
onDestroy(() => {
clearInterval(friendsInterval);
});
async function getFriends(): Promise<void> {
let response = await fetch(API_URL + "/users/friends", {
credentials: "include",
mode: "cors",
});
friends = await response.json();
}
async function getInvits(): Promise<void> {
let response = await fetch(API_URL + "/users/invits", {
credentials: "include",
mode: "cors",
});
invits = await response.json();
}
let showUserMenu = false;
let selectedUser: string | null = null;
</script>
<div class="overlay">
<div class="friends" on:click|stopPropagation on:keydown|stopPropagation>
<div>
<h2>Friends:</h2>
{#if friends.length > 0}
<div class="friends-list">
{#each friends as friend}
<li>
<span class="message-name"
on:click={() => dispatch("view-profile", friend.ftId)}
on:keydown={() => dispatch("view-profile", friend.ftId)}
style="cursor: pointer;"
>{friend.username} is {friend.status}</span>
</li>
{/each}
</div>
{:else}
<p>No friends to display</p>
{/if}
<h2>Invitations:</h2>
{#if invits.length > 0}
<div class="invits-list">
{#each invits as invit}
<li>
<span class="message-name"
on:click={() => dispatch("view-profile", invit.ftId)}
on:keydown={() => dispatch("view-profile", invit.ftId)}
style="cursor: pointer;"
>{invit.username} invited you to be friend.</span>
</li>
{/each}
</div>
{:else}
<p>No invitations to display</p>
{/if}
<div class="friends-controls">
<h3>Add a friend</h3>
<form on:submit={addFriend}>
<input type="text" required/>
<button type="submit">Add</button>
</form>
</div>
</div>
</div>
</div>
<style>
.overlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.5);
z-index: 50;
display: flex;
justify-content: center;
align-items: center;
}
.friends {
background-color: #343a40;
border: 1px solid #198754;
border-radius: 5px;
padding: 1rem;
width: 300px;
color: #e8e6e3;
max-height: 80vh;
overflow: auto;
}
h2,
h3 {
color: #e8e6e3;
}
.friends-list,
.invits-list {
overflow-y: scroll;
max-height: 200px;
}
input[type="text"],
button {
background-color: #198754;
border: none;
color: #e8e6e3;
padding: 0.25rem 0.5rem;
margin: 0.25rem;
}
input[type="text"]::placeholder {
color: rgba(232, 230, 227, 0.5);
}
input[type="text"]:focus {
outline: none;
box-shadow: 0 0 2px 1px rgba(25, 135, 84, 0.5);
}
button:hover {
background-color: #28a745;
}
.friends-controls {
display: flex;
justify-content: space-between;
flex-wrap: wrap;
}
input[type="text"] {
flex-grow: 1;
margin-right: 0.25rem;
}
button {
flex-shrink: 0;
}
</style>

102
front/src/components/Leaderboard.svelte

@ -0,0 +1,102 @@
<script lang="ts">
import type { Player } from "./Profile.svelte";
import { onMount } from "svelte";
import { API_URL } from "../Auth";
let leaderboard: Array<Player> = [];
async function getLeader(): Promise<void> {
let response = await fetch(API_URL + "/users/leaderboard", {
credentials: "include",
mode: "cors",
});
leaderboard = await response.json();
}
onMount(() => {
getLeader();
});
</script>
<div class="overlay">
<div class="history" on:click|stopPropagation on:keydown|stopPropagation>
{#if leaderboard.length > 0}
<table>
<thead>
<tr>
<th colspan="5">Leaderboard</th>
</tr>
</thead>
<tbody>
<tr>
<td>Rank</td>
<td>Usernames</td>
<td>Wins</td>
<td>Matchs</td>
<td>Winrates</td>
</tr>
{#each leaderboard as player}
<tr>
<td>{player.rank}</td>
<td>{player.username}</td>
<td>{player.wins}</td>
<td>{player.matchs}</td>
<td>{player.winrate.toFixed(2)}%</td>
</tr>
{/each}
</tbody>
</table>
{:else}
<p>No Players to display</p>
{/if}
</div>
</div>
<style>
.overlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.5);
z-index: 50;
display: flex;
justify-content: center;
align-items: center;
}
.history {
background-color: #343A40;
border: 1px solid #198754;
border-radius: 5px;
padding: 1rem;
width: 80%;
justify-content: center;
max-height: 500px;
overflow: auto;
color: #E8E6E3;
}
td {
border: 1px solid #198754;
text-align: center;
max-width: 12ch;
overflow: hidden;
padding: 0.25rem 0.5rem;
}
table {
border-collapse: collapse;
width: 100%;
}
table thead th {
background-color: #198754;
color: #e8e6e3;
padding: 0.5rem 0;
}
table tbody tr:nth-child(odd) {
background-color: rgba(255, 255, 255, 0.1);
}
</style>

167
front/src/components/MatchHistory.svelte

@ -0,0 +1,167 @@
<script lang="ts" context="module">
import type Player from "./Profile.svelte";
export interface Match {
players: Array<Player>;
score: Array<number>;
date: string;
ranked: boolean;
}
import { API_URL } from "../Auth";
</script>
<script lang="ts">
import InfiniteScroll from "./infiniteScroll.svelte";
import { onMount } from "svelte";
import { APPSTATE } from "../App.svelte";
export let appState: string = APPSTATE.HISTORY
let username = "";
let page: number = 1;
let data: Array<Match> = [];
let newBatch: Array<Match> = [];
onMount(() => {
fetchData();
});
$: data = [...data, ...newBatch];
async function fetchData() {
let response: Response;
if (appState === APPSTATE.HISTORY) {
response = await fetch(`${API_URL}/results/global?page=${page}`, {
credentials: "include",
mode: "cors",
});
} else {
let userId = appState.split("#")[1];
response = await fetch(`${API_URL}/users/${userId}`);
if (response.ok) {
let user = await response.json();
username = user.username;
response = await fetch(`${API_URL}/results/${user.ftId}?page=${page}`, {
credentials: "include",
mode: "cors",
});
}
}
if (response.ok) {
let tmp = await response.json();
newBatch = tmp.data.map((match: Match) => {
return {
players: match.players,
score: match.score,
date: new Date(match.date).toLocaleString("fr-FR", {
timeZone: "Europe/Paris",
dateStyle: "short",
timeStyle: "short",
}),
ranked: match.ranked,
};
});
page++;
}
}
</script>
<div class="overlay">
<div class="history" on:click|stopPropagation on:keydown|stopPropagation>
<div>
<table>
<thead>
<tr>
{#if username === ""}
<th colspan="3">Global history</th>
{:else}
<th colspan="3">History of {username}</th>
{/if}
</tr>
</thead>
{#if data.length > 0}
<tbody>
<tr>
<td>Date</td>
<td>Players</td>
<td>Scores</td>
</tr>
{#each data as match}
<tr>
<td>{match.date}</td>
{#if match?.players[0]?.username && match?.players[1]?.username}
<td
>{match.players[0].username}<br />{match.players[1]
.username}</td
>
<td>{match.score[0]}<br />{match.score[1]}</td>
{/if}
</tr>
{/each}
</tbody>
{:else}
<p>No matches to display</p>
{/if}
</table>
</div>
<InfiniteScroll
hasMore={newBatch.length > 0}
threshold={10}
on:loadMore={fetchData}
/>
</div>
</div>
<style>
.overlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.5);
z-index: 50;
display: flex;
justify-content: center;
align-items: center;
}
.history {
background-color: #343A40;
border: 1px solid #198754;
border-radius: 5px;
padding: 1rem;
width: 80%;
justify-content: center;
max-height: 500px;
overflow: auto;
color: #E8E6E3;
}
td {
border: 1px solid #198754;
text-align: center;
max-width: 15ch;
overflow: hidden;
padding: 0.25rem 0.5rem;
}
table {
border-collapse: collapse;
width: 100%;
}
table thead th {
background-color: #198754;
color: #e8e6e3;
padding: 0.5rem 0;
}
table tbody tr:nth-child(odd) {
background-color: rgba(255, 255, 255, 0.1);
}
p {
color: #e8e6e3;
}
</style>

161
front/src/components/NavBar.svelte

@ -0,0 +1,161 @@
<script lang="ts">
import { API_URL } from "../Auth";
export let links = [
{ text: "Home" },
{ text: "Channels" },
{ text: "History" },
{ text: "Friends" },
{ text: "Leaderboard" },
{ text: "Profile" },
];
export let clickProfile = () => {};
export let clickHistory = () => {};
export let clickFriends = () => {};
export let clickChannels = () => {};
export let clickLeaderboard = () => {};
export let failedGameLogIn: boolean;
export let gamePlaying: boolean;
let hide = true;
function toggle() {
hide = !hide;
}
</script>
<nav class="navigation-bar" style={ failedGameLogIn || gamePlaying ? "display: none" : '' } >
<ul>
<li>
<img src="img/pong.png" alt="home-icon" />
</li>
<div class:links={hide}>
{#each links as link}
{#if link.text === "Leaderboard"}
<li>
<button on:click={clickLeaderboard}> Leaderboard </button>
</li>
{/if}
{#if link.text === "Channels"}
<li>
<button on:click={clickChannels}> Channels </button>
</li>
{/if}
{#if link.text === "Friends"}
<li>
<button on:click={clickFriends}> Friends </button>
</li>
{/if}
{#if link.text === "Profile"}
<li>
<button on:click={clickProfile}>
<img src={API_URL + "/users/avatar"} alt="avatar" />
</button>
</li>
{/if}
{#if link.text === "History"}
<li>
<button on:click={clickHistory}> History </button>
</li>
{/if}
{/each}
</div>
<button class="hamburger" on:click={toggle}>
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
>
<path d="M3 18h18v-2H3v2zm0-5h18v-2H3v2zm0-7v2h18V6H3z" />
</svg>
</button>
</ul>
</nav>
<style>
.navigation-bar {
display: flex;
justify-content: center;
align-items: center;
background-color: #343a40;
padding: 1rem;
max-height: 5vh;
}
.navigation-bar ul {
display: flex;
justify-content: center;
align-items: center;
list-style: none;
padding: 0;
}
.navigation-bar li {
margin: 0 1rem;
}
.navigation-bar img {
width: 2rem;
height: auto;
}
.navigation-bar button {
background-color: transparent;
color: #e8e6e3;
border: none;
padding: 0.5rem 1rem;
border-radius: 4px;
cursor: pointer;
font-size: 1rem;
transition: background-color 0.2s ease-in-out;
outline: none;
}
.navigation-bar button:hover {
background-color: #198754;
}
.navigation-bar button:focus {
box-shadow: 0 0 0 2px rgba(25, 135, 84, 0.25);
}
.hamburger {
display: none;
fill: #e8e6e3;
}
.links {
display: flex;
}
@media (max-width: 768px) {
.navigation-bar {
flex-direction: column;
align-items: stretch;
padding: 0;
max-height: none;
}
.navigation-bar ul {
flex-direction: column;
align-items: center;
justify-content: center;
margin: 4px;
}
.navigation-bar li {
margin: 0;
text-align: center;
}
.hamburger {
display: block;
}
.links {
display: none;
}
}
</style>

16
front/src/components/Pong/Ball.ts

@ -0,0 +1,16 @@
import { DEFAULT_BALL_INITIAL_SPEED } from "./constants";
import { Point, Rect } from "./utils";
export class Ball {
rect: Rect;
speed: Point;
constructor(spawn: Point, size: Point = new Point(20, 20)) {
this.rect = new Rect(spawn, size);
this.speed = DEFAULT_BALL_INITIAL_SPEED;
}
draw(context: CanvasRenderingContext2D, color: string) {
this.rect.draw(context, color);
}
}

8
front/src/components/Pong/ColorPicker.svelte

@ -0,0 +1,8 @@
<script lang="ts">
export let color: string;
</script>
<input class="color-input" type="color" bind:value={color} />
<style>
</style>

172
front/src/components/Pong/Game.ts

@ -0,0 +1,172 @@
import type { Socket } from "socket.io-client";
import { Ball } from "./Ball";
import { GAME_EVENTS } from "./constants";
import type { GameInfo } from "./dtos/GameInfo";
import type { GameUpdate } from "./dtos/GameUpdate";
import { Paddle } from "./Paddle";
import { Player } from "./Player";
import { Point, Rect } from "./utils";
const FPS = +import.meta.env.VITE_FRONT_FPS || 60;
export class Game {
renderCanvas: HTMLCanvasElement;
canvas: HTMLCanvasElement;
context: CanvasRenderingContext2D;
ballLastVelocity: Point;
ball: Ball;
players: Player[];
my_paddle: Paddle;
id: string;
walls: Rect[];
drawInterval: NodeJS.Timer;
elementsColor: string;
backgroundColor: string;
ranked: boolean;
youAreReady: boolean;
private readonly score_audio = new Audio("audio/score.wav");
private readonly paddle_hit_audio = new Audio("audio/paddle_hit.wav");
private readonly edge_hit_audio = new Audio("audio/edge_hit.wav");
constructor(
renderCanvas: HTMLCanvasElement,
canvas: HTMLCanvasElement,
context: CanvasRenderingContext2D,
elementsColor: string,
backgroundColor: string
) {
this.renderCanvas = renderCanvas;
this.canvas = canvas;
this.context = context;
this.players = [];
this.my_paddle = null;
this.id = "";
this.walls = [];
this.drawInterval = null;
this.elementsColor = elementsColor;
this.backgroundColor = backgroundColor;
this.ranked = false;
this.youAreReady = false;
}
setInfo(data: GameInfo) {
this.renderCanvas.width = data.mapSize.x;
this.renderCanvas.height = data.mapSize.y;
this.canvas.width = data.mapSize.x;
this.canvas.height = data.mapSize.y;
this.ranked = data.ranked;
this.youAreReady = false;
this.ball = new Ball(
new Point(this.canvas.width / 2, this.canvas.height / 2),
data.ballSize
);
const paddle1: Paddle = new Paddle(
new Point(data.paddleSize.x / 2, this.canvas.height / 2),
data.paddleSize
);
const paddle2: Paddle = new Paddle(
new Point(
this.canvas.width - data.paddleSize.x / 2,
this.canvas.height / 2
),
data.paddleSize
);
this.players = [new Player(paddle1, data.playerNames[0]), new Player(paddle2, data.playerNames[1])];
if (data.yourPaddleIndex != -1)
this.my_paddle = this.players[data.yourPaddleIndex].paddle;
this.id = data.gameId;
this.walls = data.walls.map(
(w) =>
new Rect(
new Point(w.center.x, w.center.y),
new Point(w.size.x, w.size.y)
)
);
if (this.drawInterval === null) {
this.drawInterval = setInterval(() => {
this.draw();
}, 1000 / FPS);
}
console.log("Game updated!");
}
start(socket: Socket) {
this.renderCanvas.addEventListener("pointermove", (e) => {
this.my_paddle.move(e);
const data: Point = this.my_paddle.rect.center;
socket.emit(GAME_EVENTS.PLAYER_MOVE, data);
});
console.log("Game started!");
}
update(data: GameUpdate) {
if (this.id !== "") {
if (this.players[0].paddle != this.my_paddle) {
this.players[0].paddle.rect.center = data.paddlesPositions[0];
}
if (this.players[1].paddle != this.my_paddle) {
this.players[1].paddle.rect.center = data.paddlesPositions[1];
}
if (data.ballSpeed.x * this.ball.speed.x < 0) {
this.paddle_hit_audio.play();
}
if (data.ballSpeed.y * this.ball.speed.y < 0) {
this.edge_hit_audio.play();
}
this.ball.speed = data.ballSpeed;
this.ball.rect.center = data.ballPosition;
for (let i = 0; i < data.scores.length; i++) {
if (this.players[i].score != data.scores[i]) {
this.score_audio.play();
}
this.players[i].score = data.scores[i];
}
}
}
updateColors(elementsColor: string, backgroundColor: string) {
this.elementsColor = elementsColor;
this.backgroundColor = backgroundColor;
}
draw() {
//Background
this.context.fillStyle = this.backgroundColor;
this.context.fillRect(0, 0, this.canvas.width, this.canvas.height);
//Draw lines in middle of game
this.context.beginPath();
this.context.setLineDash([10, 20]);
this.context.moveTo(this.canvas.width / 2, 0);
this.context.lineTo(this.canvas.width / 2, this.canvas.height);
this.context.strokeStyle = this.elementsColor;
this.context.lineWidth = 5;
this.context.stroke();
//Elements
this.walls.forEach((w) => w.draw(this.context, this.elementsColor));
this.players.forEach((p) => p.draw(this.context, this.elementsColor));
this.ball.draw(this.context, this.elementsColor);
//Score
const max_width = 50;
this.context.font = "50px Arial";
const text_width = this.context.measureText("0").width;
const text_offset = 50;
this.players[0].drawScore(
this.canvas.width / 2 - (text_width + text_offset),
max_width,
this.context
);
this.players[1].drawScore(
this.canvas.width / 2 + text_offset,
max_width,
this.context
);
this.renderCanvas.getContext("2d").drawImage(this.canvas, 0, 0);
}
}

Some files were not shown because too many files changed in this diff

Loading…
Cancel
Save