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