diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..c4dfeb5 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,12 @@ +version: '3.1' + +services: + + mongo: + image: docker.io/mongo + restart: always + environment: + MONGO_INITDB_ROOT_USERNAME: root + MONGO_INITDB_ROOT_PASSWORD: example + ports: + - 27017:27017 diff --git a/package-lock.json b/package-lock.json index 3cc38b1..873862a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,6 +16,7 @@ "@nestjs/mongoose": "^10.0.6", "@nestjs/platform-express": "^10.3.2", "@nestjs/swagger": "^7.3.1", + "connect-mongo": "^5.1.0", "express-session": "^1.18.0", "mongoose": "^8.4.1", "reflect-metadata": "^0.2.1", @@ -3428,6 +3429,18 @@ "integrity": "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==", "dev": true }, + "node_modules/asn1.js": { + "version": "5.4.1", + "resolved": "https://registry.npmjs.org/asn1.js/-/asn1.js-5.4.1.tgz", + "integrity": "sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA==", + "license": "MIT", + "dependencies": { + "bn.js": "^4.0.0", + "inherits": "^2.0.1", + "minimalistic-assert": "^1.0.0", + "safer-buffer": "^2.1.0" + } + }, "node_modules/asynckit": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", @@ -3715,6 +3728,12 @@ "node": ">= 6" } }, + "node_modules/bn.js": { + "version": "4.12.0", + "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.0.tgz", + "integrity": "sha512-c98Bf3tPniI+scsdk237ku1Dc3ujXQTSgyiPUDEOe7tRkhrqridvh8klBv0HCEso1OLOYcHuCv/cS6DNxKH+ZA==", + "license": "MIT" + }, "node_modules/body-parser": { "version": "1.20.2", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.2.tgz", @@ -4262,6 +4281,23 @@ "typedarray": "^0.0.6" } }, + "node_modules/connect-mongo": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/connect-mongo/-/connect-mongo-5.1.0.tgz", + "integrity": "sha512-xT0vxQLqyqoUTxPLzlP9a/u+vir0zNkhiy9uAdHjSCcUUf7TS5b55Icw8lVyYFxfemP3Mf9gdwUOgeF3cxCAhw==", + "license": "MIT", + "dependencies": { + "debug": "^4.3.1", + "kruptein": "^3.0.0" + }, + "engines": { + "node": ">=12.9.0" + }, + "peerDependencies": { + "express-session": "^1.17.1", + "mongodb": ">= 5.1.0 < 7" + } + }, "node_modules/consola": { "version": "2.15.3", "resolved": "https://registry.npmjs.org/consola/-/consola-2.15.3.tgz", @@ -7121,6 +7157,18 @@ "node": ">=6" } }, + "node_modules/kruptein": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/kruptein/-/kruptein-3.0.6.tgz", + "integrity": "sha512-EQJjTwAJfQkC4NfdQdo3HXM2a9pmBm8oidzH270cYu1MbgXPNPMJuldN7OPX+qdhPO5rw4X3/iKz0BFBfkXGKA==", + "license": "MIT", + "dependencies": { + "asn1.js": "^5.4.1" + }, + "engines": { + "node": ">8" + } + }, "node_modules/leven": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz", @@ -7381,6 +7429,12 @@ "node": ">=4" } }, + "node_modules/minimalistic-assert": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz", + "integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==", + "license": "ISC" + }, "node_modules/minimatch": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", diff --git a/package.json b/package.json index 9246793..78fdabd 100644 --- a/package.json +++ b/package.json @@ -26,6 +26,7 @@ "@nestjs/mongoose": "^10.0.6", "@nestjs/platform-express": "^10.3.2", "@nestjs/swagger": "^7.3.1", + "connect-mongo": "^5.1.0", "express-session": "^1.18.0", "mongoose": "^8.4.1", "reflect-metadata": "^0.2.1", diff --git a/src/app.module.ts b/src/app.module.ts index 2ee0b62..8b20e93 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -10,8 +10,11 @@ import { CardsService } from './cards/cards.service'; @Module({ imports: [ ConfigModule.forRoot(), - MongooseModule.forRoot('mongodb://localhost:27017', { - auth: { password: 'example', username: 'root' }, + MongooseModule.forRoot(process.env.MONGO_ENDPOINT, { + auth: { + password: process.env.MONGO_PASSWORD, + username: process.env.MONGO_USER, + }, dbName: 'heron', }), GamesModule, diff --git a/src/games/dto/create-game.dto.ts b/src/games/dto/create-game.dto.ts deleted file mode 100644 index 717d4f6..0000000 --- a/src/games/dto/create-game.dto.ts +++ /dev/null @@ -1 +0,0 @@ -export class CreateGameDto {} diff --git a/src/games/dto/database-object.dto.ts b/src/games/dto/database-object.dto.ts new file mode 100644 index 0000000..2a15953 --- /dev/null +++ b/src/games/dto/database-object.dto.ts @@ -0,0 +1,11 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { Types } from 'mongoose'; + +export class DatabaseObjectDto { + constructor(data: { _id: Types.ObjectId }) { + this._id = data._id.toString(); + } + + @ApiProperty() + _id: string; +} diff --git a/src/games/dto/read-games.dto.ts b/src/games/dto/read-games.dto.ts new file mode 100644 index 0000000..1b446df --- /dev/null +++ b/src/games/dto/read-games.dto.ts @@ -0,0 +1,86 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { DatabaseObjectDto } from './database-object.dto'; +import { Container, Team } from '../schemas/game.schema'; +import { User } from 'src/users/schemas/user.entity'; + +export class ReadTeamDto extends DatabaseObjectDto { + constructor(data: ConstructorParameters[0]) { + super(data); + } +} + +export class ReadContainerDto extends DatabaseObjectDto { + constructor(data: ConstructorParameters[0]) { + super(data); + } +} + +export class ReadUserDto extends DatabaseObjectDto { + constructor( + data: { username: string; avatar: string } & ConstructorParameters< + typeof DatabaseObjectDto + >[0], + ) { + super(data); + this.username = data.username; + this.avatar = data.avatar; + } + + @ApiProperty() + username: string; + + @ApiProperty() + avatar: string; +} + +export class ReadGameDto extends DatabaseObjectDto { + constructor( + data: { + teams: Team[]; + market: Container; + marketStack: Container; + fireGems: Container; + currentTurn?: Team; + started: boolean; + ended: boolean; + owner: User; + } & ConstructorParameters[0], + ) { + super(data); + this.teams = data.teams.map((e) => new ReadTeamDto(e)); + this.market = new ReadContainerDto(data.market); + this.marketStack = new ReadContainerDto(data.marketStack); + this.fireGems = new ReadContainerDto(data.fireGems); + this.started = data.started; + this.ended = data.ended; + this.owner = new ReadUserDto(data.owner); + + if (data.currentTurn) { + this.currentTurn = new ReadTeamDto(data.currentTurn); + } + } + + @ApiProperty() + teams: ReadTeamDto[]; + + @ApiProperty() + market: ReadContainerDto; + + @ApiProperty() + marketStack: ReadContainerDto; + + @ApiProperty() + fireGems: ReadContainerDto; + + @ApiProperty() + currentTurn?: ReadTeamDto; + + @ApiProperty() + started: boolean; + + @ApiProperty() + ended: boolean; + + @ApiProperty() + owner: ReadUserDto; +} diff --git a/src/games/dto/update-game.dto.ts b/src/games/dto/update-game.dto.ts index 94f8a53..99f0499 100644 --- a/src/games/dto/update-game.dto.ts +++ b/src/games/dto/update-game.dto.ts @@ -1,4 +1 @@ -import { PartialType } from '@nestjs/mapped-types'; -import { CreateGameDto } from './create-game.dto'; - -export class UpdateGameDto extends PartialType(CreateGameDto) {} +export class UpdateGameDto {} diff --git a/src/games/games.controller.ts b/src/games/games.controller.ts index e7e9bd5..0d4a552 100644 --- a/src/games/games.controller.ts +++ b/src/games/games.controller.ts @@ -2,32 +2,56 @@ import { Controller, Get, Post, - Body, - Patch, Param, Delete, + UseGuards, + Session, + HttpException, + HttpStatus, } from '@nestjs/common'; import { GamesService } from './games.service'; -import { CreateGameDto } from './dto/create-game.dto'; -import { UpdateGameDto } from './dto/update-game.dto'; +import { AuthGuard } from './guards/auth.guard'; +import { ApiOperation, ApiResponse } from '@nestjs/swagger'; +import { DatabaseObjectDto } from './dto/database-object.dto'; @Controller('games') export class GamesController { constructor(private readonly gamesService: GamesService) {} @Post() - create() { - return this.gamesService.create(); + @ApiOperation({ summary: 'Create game' }) + @ApiResponse({ + status: 201, + description: 'Game successfully created.', + type: DatabaseObjectDto, + }) + @ApiResponse({ + status: 400, + description: 'You can only create one game at a time', + }) + @ApiResponse({ status: 403, description: 'Forbidden.' }) + @UseGuards(AuthGuard) + async create(@Session() session: Record) { + const games = await this.gamesService.findByOwner(session.user._id); + if (games.length > 0) { + throw new HttpException( + 'You can only create one game at a time', + HttpStatus.BAD_REQUEST, + ); + } + return this.gamesService.create(session.user); } @Get() + @ApiOperation({ summary: 'List all games' }) findAll() { - return this.gamesService.findAll(); + const games = this.gamesService.findAll(); + return games; } @Get(':id') findOne(@Param('id') id: string) { - return this.gamesService.findOne(id); + return this.gamesService.findById(id); } // @Patch(':id') diff --git a/src/games/games.service.ts b/src/games/games.service.ts index 258db81..8ae49bd 100644 --- a/src/games/games.service.ts +++ b/src/games/games.service.ts @@ -1,24 +1,33 @@ import { Injectable } from '@nestjs/common'; -import { CreateGameDto } from './dto/create-game.dto'; -import { UpdateGameDto } from './dto/update-game.dto'; +import { DatabaseObjectDto } from './dto/database-object.dto'; import { InjectModel } from '@nestjs/mongoose'; import { Game } from './schemas/game.schema'; import { Model } from 'mongoose'; +import { ReadGameDto } from './dto/read-games.dto'; +import { User } from 'src/users/schemas/user.entity'; @Injectable() export class GamesService { constructor(@InjectModel(Game.name) private gameModel: Model) {} - async create(): Promise { + + async create(owner: User): Promise { const createdGame = new this.gameModel(); - return createdGame.save(); + createdGame.owner = owner; + const gamedata = await createdGame.save(); + return new DatabaseObjectDto(gamedata); } - findAll() { - return this.gameModel.find(); + async findAll(): Promise { + const games = await this.gameModel.find().exec(); + return games.map((e) => new ReadGameDto(e)); } - findOne(id: string) { - return this.gameModel.find({ _id: id }).exec(); + findById(id: string) { + return this.gameModel.findById(id).exec(); + } + + findByOwner(_id: string) { + return this.gameModel.find({ owner: _id }).exec(); } // update(id: number, updateGameDto: UpdateGameDto) { diff --git a/src/games/guards/auth.guard.ts b/src/games/guards/auth.guard.ts new file mode 100644 index 0000000..f58d3cd --- /dev/null +++ b/src/games/guards/auth.guard.ts @@ -0,0 +1,23 @@ +import { + Injectable, + CanActivate, + ExecutionContext, + HttpException, + HttpStatus, +} from '@nestjs/common'; +import { Request } from 'express'; +import { Observable } from 'rxjs'; + +@Injectable() +export class AuthGuard implements CanActivate { + canActivate( + context: ExecutionContext, + ): boolean | Promise | Observable { + const http = context.switchToHttp(); + const request = http.getRequest(); + if (!(request.session as Record).user) { + throw new HttpException('Please log in', HttpStatus.UNAUTHORIZED); + } + return true; + } +} diff --git a/src/games/schemas/game.schema.ts b/src/games/schemas/game.schema.ts index 55ebd13..3e5f637 100644 --- a/src/games/schemas/game.schema.ts +++ b/src/games/schemas/game.schema.ts @@ -1,20 +1,17 @@ import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose'; -import mongoose, { HydratedDocument } from 'mongoose'; +import mongoose, { HydratedDocument, Types } from 'mongoose'; import { Card } from 'src/cards/schemas/cards.schema'; import { User } from 'src/users/schemas/user.entity'; export type GameDocument = HydratedDocument; @Schema() -class GameObject {} - -@Schema() -export class Team extends GameObject {} +export class Team extends Types.ObjectId {} export const TeamSchema = SchemaFactory.createForClass(Team); @Schema() -export class Container extends GameObject { +export class Container extends Types.ObjectId { @Prop([{ type: mongoose.Schema.Types.ObjectId, ref: 'User' }]) cards: Card[]; } @@ -22,7 +19,7 @@ export class Container extends GameObject { export const ContainerSchema = SchemaFactory.createForClass(Container); @Schema() -export class Game extends GameObject { +export class Game extends Types.ObjectId { @Prop([Team]) teams: Team[]; @@ -42,7 +39,7 @@ export class Game extends GameObject { started: boolean; @Prop({ default: false }) - private ended: boolean; + ended: boolean; @Prop({ type: mongoose.Schema.Types.ObjectId, ref: 'User' }) owner: User; diff --git a/src/main.ts b/src/main.ts index a00ad84..8555f5b 100644 --- a/src/main.ts +++ b/src/main.ts @@ -2,6 +2,7 @@ import { NestFactory } from '@nestjs/core'; import * as session from 'express-session'; import { AppModule } from './app.module'; import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger'; +import MongoStore from 'connect-mongo'; async function bootstrap() { const app = await NestFactory.create(AppModule); @@ -15,11 +16,22 @@ async function bootstrap() { SwaggerModule.setup('api', app, document); app.use( - session({ - secret: 'my-secret', + session.default({ + secret: process.env.SESSION_SECRET, resave: false, saveUninitialized: false, name: 'heron.session', + store: MongoStore.create({ + collectionName: 'sessions', + dbName: 'heron', + mongoUrl: process.env.MONGO_ENDPOINT, + mongoOptions: { + auth: { + password: process.env.MONGO_PASSWORD, + username: process.env.MONGO_USER, + }, + }, + }), }), ); diff --git a/src/users/dto/login.dto.ts b/src/users/dto/login.dto.ts index 05978df..125ed45 100644 --- a/src/users/dto/login.dto.ts +++ b/src/users/dto/login.dto.ts @@ -1,19 +1,16 @@ -import { PartialType } from '@nestjs/mapped-types'; -import { CreateUserDto } from './create-user.dto'; import { ApiProperty } from '@nestjs/swagger'; -export class LoginDto extends PartialType(CreateUserDto) { - constructor(id: string, global_name: string, avatar: string) { - super(); - this.id = id; - this.global_name = global_name; +export class LoginDto { + constructor(userId: string, username: string, avatar: string) { + this.userId = userId; + this.username = username; this.avatar = avatar; } @ApiProperty() - id: string; + userId: string; @ApiProperty() - global_name: string; + username: string; @ApiProperty() avatar: string; diff --git a/src/users/schemas/user.entity.ts b/src/users/schemas/user.entity.ts index bc55547..640380f 100644 --- a/src/users/schemas/user.entity.ts +++ b/src/users/schemas/user.entity.ts @@ -1,9 +1,18 @@ -import { Schema, SchemaFactory } from '@nestjs/mongoose'; -import { HydratedDocument } from 'mongoose'; +import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose'; +import { HydratedDocument, Types } from 'mongoose'; export type UserDocument = HydratedDocument; @Schema() -export class User {} +export class User extends Types.ObjectId { + @Prop() + userId: string; + + @Prop() + avatar: string; + + @Prop() + username: string; +} export const UserSchema = SchemaFactory.createForClass(User); diff --git a/src/users/users.controller.ts b/src/users/users.controller.ts index bd45aff..a713782 100644 --- a/src/users/users.controller.ts +++ b/src/users/users.controller.ts @@ -19,13 +19,9 @@ export class UsersController { async login( @Query('code') code: string, @Session() session: Record, - ) { - const tokenData = await this.usersService.saveDiscordToken(code); - session.tokenData = tokenData; - const user = await this.usersService.useDiscordToken( - tokenData.token_type, - tokenData.token, - ); + ): Promise { + await this.usersService.createDiscordToken({ code }, session); + const user = await this.usersService.discordLogin(session); return user; } @@ -37,10 +33,7 @@ export class UsersController { HttpStatus.UNAUTHORIZED, ); } - const user = await this.usersService.useDiscordToken( - session.tokenData.token_type, - session.tokenData.token, - ); + const user = await this.usersService.discordLogin(session); return user; } } diff --git a/src/users/users.service.ts b/src/users/users.service.ts index 7539e99..79403e0 100644 --- a/src/users/users.service.ts +++ b/src/users/users.service.ts @@ -1,30 +1,67 @@ import { HttpException, HttpStatus, Injectable } from '@nestjs/common'; import { LoginDto } from './dto/login.dto'; import { URLSearchParams } from 'url'; +import { User } from './schemas/user.entity'; +import { Model } from 'mongoose'; +import { InjectModel } from '@nestjs/mongoose'; @Injectable() export class UsersService { - async saveDiscordToken(code: string) { + constructor(@InjectModel(User.name) private userModel: Model) {} + + async createDiscordToken( + { + code, + refresh_token, + }: { + code?: string; + refresh_token?: string; + }, + session: Record, + ): Promise { + let body; + if (code) { + body = new URLSearchParams({ + client_id: process.env.DISCORD_CLIENTID, + client_secret: process.env.DISCORD_CLIENTSECRET, + code, + grant_type: 'authorization_code', + redirect_uri: process.env.API_ENDPOINT + '/login', + }); + } else if (refresh_token) { + body = new URLSearchParams({ + client_id: process.env.DISCORD_CLIENTID, + client_secret: process.env.DISCORD_CLIENTSECRET, + refresh_token, + grant_type: 'refresh_token', + }); + } else { + throw new HttpException( + 'Invalid internal call', + HttpStatus.INTERNAL_SERVER_ERROR, + ); + } const req = await fetch('https://discord.com/api/oauth2/token', { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded', }, - body: new URLSearchParams({ - client_id: process.env.DISCORD_CLIENTID, - client_secret: process.env.DISCORD_CLIENTSECRET, - code: code, - grant_type: 'authorization_code', - redirect_uri: process.env.API_ENDPOINT + '/login', - scope: 'identify', - }), + body, }); if (!req.ok) { - console.error(await req.json()); - throw new HttpException( - "Couldn't fetch Discord api", - HttpStatus.INTERNAL_SERVER_ERROR, - ); + const data = await req.json(); + if (data.error == 'invalid_grant') { + throw new HttpException( + 'Invalid code in request', + HttpStatus.UNAUTHORIZED, + ); + } else { + console.error(data); + throw new HttpException( + "Couldn't fetch Discord api", + HttpStatus.INTERNAL_SERVER_ERROR, + ); + } } const data = await req.json(); if (!data.token_type || !data.access_token) { @@ -34,27 +71,59 @@ export class UsersService { HttpStatus.INTERNAL_SERVER_ERROR, ); } - const token_type = data.token_type; - const token = data.access_token; - - return { token, token_type }; + const expires = new Date(Date.now() + data.expires_in); + session.tokenData = { + access_token: data.access_token, + token_type: data.token_type, + refresh_token: data.refresh_token, + expires, + }; } - async useDiscordToken(token_type: string, token: string) { + async discordLogin( + session: Record, + recursive: boolean = false, + ): Promise { const req2 = await fetch('https://discord.com/api/users/@me', { headers: { - authorization: token_type + ' ' + token, + authorization: + session.tokenData.token_type + ' ' + session.tokenData.access_token, }, }); if (!req2.ok) { - throw new HttpException( - "Couldn't fetch Discord api", - HttpStatus.INTERNAL_SERVER_ERROR, - ); + const error = await req2.json(); + if (error.error == 'invalid_grant' && !recursive) { + await this.createDiscordToken( + { + refresh_token: session.tokenData.refresh_token, + }, + session, + ); + return this.discordLogin(session); + } else { + console.error(error); + throw new HttpException( + "Couldn't fetch Discord api", + HttpStatus.INTERNAL_SERVER_ERROR, + ); + } } const data2 = await req2.json(); - return new LoginDto(data2.id, data2.global_name, data2.avatar); + console.log(data2); + const dto = new LoginDto(data2.id, data2.global_name, data2.avatar); + const user = await this.getOrCreateUser(dto.userId, dto); + session.user = user; + return dto; + } + + async getOrCreateUser(userId: string, data?: LoginDto) { + let user = await this.userModel.findOne({ userId }).exec(); + if (!user) { + const createdUser = new this.userModel({ ...data, userId }); + user = await createdUser.save(); + } + return user; } remove(id: number) { diff --git a/tsconfig.json b/tsconfig.json index ace9988..239aa10 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -11,6 +11,7 @@ "outDir": "./dist", "baseUrl": "./", "incremental": true, - "skipLibCheck": true + "skipLibCheck": true, + "esModuleInterop": true } }