vvandenb
2 years ago
commit
eb85d99ac2
130 changed files with 20439 additions and 0 deletions
@ -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 |
@ -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 |
@ -0,0 +1,2 @@ |
|||||
|
.env |
||||
|
postgres |
@ -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 |
@ -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 |
@ -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) |
||||
|
|
||||
|
|
@ -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 |
@ -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 |
||||
|
} |
||||
|
} |
@ -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.* |
@ -0,0 +1,5 @@ |
|||||
|
{ |
||||
|
"$schema": "https://json.schemastore.org/nest-cli", |
||||
|
"collection": "@nestjs/schematics", |
||||
|
"sourceRoot": "src" |
||||
|
} |
File diff suppressed because it is too large
@ -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" |
||||
|
} |
||||
|
} |
@ -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 {} |
@ -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() |
||||
|
} |
||||
|
} |
@ -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 |
||||
|
} |
||||
|
) |
@ -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) |
||||
|
} |
||||
|
} |
@ -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 |
||||
|
}) |
||||
|
} |
||||
|
} |
@ -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 {} |
@ -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 |
||||
|
} |
||||
|
} |
@ -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> |
@ -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> |
@ -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) |
||||
|
} |
||||
|
} |
@ -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 |
||||
|
} |
||||
|
} |
@ -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') |
||||
|
} |
||||
|
} |
||||
|
} |
@ -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 {} |
@ -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) |
||||
|
} |
||||
|
} |
@ -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 |
||||
|
} |
@ -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 |
||||
|
} |
@ -0,0 +1,12 @@ |
|||||
|
import { IsNumber, IsString } from 'class-validator' |
||||
|
|
||||
|
export class CreateMessageDto { |
||||
|
@IsString() |
||||
|
text: string |
||||
|
|
||||
|
@IsNumber() |
||||
|
UserId: number |
||||
|
|
||||
|
@IsNumber() |
||||
|
ChannelId: number |
||||
|
} |
@ -0,0 +1,12 @@ |
|||||
|
import { IsNumber } from 'class-validator' |
||||
|
|
||||
|
export class kickUserDto { |
||||
|
@IsNumber() |
||||
|
chan: number |
||||
|
|
||||
|
@IsNumber() |
||||
|
from: number |
||||
|
|
||||
|
@IsNumber() |
||||
|
to: number |
||||
|
} |
@ -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 |
||||
|
} |
@ -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 |
||||
|
} |
@ -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 |
||||
|
} |
@ -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 |
||||
|
} |
@ -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[] |
||||
|
} |
@ -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 |
||||
|
} |
@ -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 |
||||
|
} |
||||
|
} |
@ -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!') |
||||
|
}) |
@ -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 |
||||
|
} |
@ -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[] |
||||
|
} |
@ -0,0 +1,8 @@ |
|||||
|
import { type Point } from '../game/utils' |
||||
|
|
||||
|
export class GameUpdate { |
||||
|
paddlesPositions!: Point[] |
||||
|
ballSpeed!: Point |
||||
|
ballPosition!: Point |
||||
|
scores!: number[] |
||||
|
} |
@ -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[] |
||||
|
} |
@ -0,0 +1,3 @@ |
|||||
|
export class MatchmakingDto { |
||||
|
matchmaking!: boolean |
||||
|
} |
@ -0,0 +1,7 @@ |
|||||
|
import { IsBoolean } from 'class-validator' |
||||
|
import { MatchmakingDto } from './MatchmakingDto' |
||||
|
|
||||
|
export class MatchmakingDtoValidated extends MatchmakingDto { |
||||
|
@IsBoolean() |
||||
|
matchmaking!: boolean |
||||
|
} |
@ -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 |
||||
|
} |
@ -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 |
||||
|
} |
@ -0,0 +1,3 @@ |
|||||
|
export class StringDto { |
||||
|
value!: string |
||||
|
} |
@ -0,0 +1,7 @@ |
|||||
|
import { IsString } from 'class-validator' |
||||
|
import { StringDto } from './StringDto' |
||||
|
|
||||
|
export class StringDtoValidated extends StringDto { |
||||
|
@IsString() |
||||
|
value!: string |
||||
|
} |
@ -0,0 +1,12 @@ |
|||||
|
import { IsString } from 'class-validator' |
||||
|
|
||||
|
export class UserDto { |
||||
|
@IsString() |
||||
|
username!: string |
||||
|
|
||||
|
@IsString() |
||||
|
avatar!: string |
||||
|
|
||||
|
@IsString() |
||||
|
status!: string |
||||
|
} |
@ -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 |
||||
|
} |
@ -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 |
||||
|
} |
||||
|
} |
@ -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) |
||||
|
} |
||||
|
} |
@ -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) |
||||
|
} |
||||
|
} |
||||
|
} |
@ -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 |
||||
|
) |
||||
|
} |
||||
|
} |
@ -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 |
||||
|
} |
||||
|
} |
||||
|
} |
@ -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) |
||||
|
} |
||||
|
} |
@ -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 |
@ -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 |
||||
|
} |
||||
|
} |
@ -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) |
||||
|
} |
||||
|
} |
@ -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) |
||||
|
} |
||||
|
} |
||||
|
} |
@ -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 {} |
@ -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 |
||||
|
}) |
||||
|
} |
||||
|
} |
@ -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 |
||||
|
} |
||||
|
} |
@ -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 |
||||
|
} |
@ -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 |
@ -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 |
||||
|
} |
||||
|
} |
@ -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 {} |
@ -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 }) |
||||
|
} |
||||
|
} |
@ -0,0 +1,4 @@ |
|||||
|
{ |
||||
|
"extends": "./tsconfig.json", |
||||
|
"exclude": ["node_modules", "test", "dist", "**/*spec.ts"] |
||||
|
} |
@ -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 |
||||
|
} |
||||
|
} |
@ -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 |
@ -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 |
@ -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.* |
||||
|
|
@ -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> |
File diff suppressed because it is too large
@ -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" |
||||
|
} |
||||
|
} |
Binary file not shown.
Binary file not shown.
Binary file not shown.
@ -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); |
||||
|
} |
After Width: | Height: | Size: 724 B |
After Width: | Height: | Size: 342 B |
After Width: | Height: | Size: 9.1 KiB |
After Width: | Height: | Size: 4.2 KiB |
After Width: | Height: | Size: 858 B |
After Width: | Height: | Size: 1.5 KiB |
@ -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> |
@ -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); |
||||
|
} |
@ -0,0 +1,4 @@ |
|||||
|
declare global { |
||||
|
namespace App {} |
||||
|
} |
||||
|
export {}; |
@ -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> |
@ -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() |
||||
|
} |
@ -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> |
@ -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> |
@ -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> |
@ -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> |
@ -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> |
@ -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> |
@ -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); |
||||
|
} |
||||
|
} |
@ -0,0 +1,8 @@ |
|||||
|
<script lang="ts"> |
||||
|
export let color: string; |
||||
|
</script> |
||||
|
|
||||
|
<input class="color-input" type="color" bind:value={color} /> |
||||
|
|
||||
|
<style> |
||||
|
</style> |
@ -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…
Reference in new issue