Skip to content

Commit

Permalink
Merge pull request #380 from TaloDev/develop
Browse files Browse the repository at this point in the history
Release 0.55.0
  • Loading branch information
tudddorrr authored Jan 3, 2025
2 parents d72cab0 + 9864c76 commit 8afa2bb
Show file tree
Hide file tree
Showing 29 changed files with 549 additions and 612 deletions.
6 changes: 5 additions & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ Please make sure to include tests with all pull requests.

You can create a new service using the `npm run service:create` command. You need to pass in the name of the entity you want the service to interact with.

For example, if you are adding a "Global Stats" service, you would run: `npm run service:create global-stat` (note that the entity name is singular and not a plural).
For example, if you are adding a "Global Stats" service, you would run: `npm run service:create -- global-stat` (note that the entity name is singular and not a plural).

This will create a policy, entity and REST API for your new entity. If you want to expose API endpoints (so that it can be used by the Unity SDK), add `--api` to the end of the command.

Expand All @@ -42,3 +42,7 @@ Modify the default name of the file from `Migration[Timestamp].ts` to `[Timestam
You should also rename the exported class to be `[PascalCaseDescriptionOfTheMigration]`.

You will then need to import and add that migration class to the end of the list of migrations inside `index.ts` in the same folder.

### ClickHouse migrations

ClickHouse migrations are created in the `src/migrations/clickhouse` folder. These are manually created and should be added to the `src/migrations/clickhouse/index.ts` file. The migration script will automatically run the migration if it hasn't already been applied.
1 change: 0 additions & 1 deletion _templates/service/new/api-service-test.ejs.t
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
---
to: "<%= (typeof api !== 'undefined') ? `tests/services/_api/${name}-api/post.test.ts` : null %>"
---
import { EntityManager } from '@mikro-orm/mysql'
import request from 'supertest'
import { APIKeyScope } from '../../../../src/entities/api-key'
import createAPIKeyAndToken from '../../../utils/createAPIKeyAndToken'
Expand Down
8 changes: 5 additions & 3 deletions _templates/service/new/service-test.ejs.t
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,16 @@ to: tests/services/<%= name %>/get.test.ts
import request from 'supertest'
import createUserAndToken from '../../utils/createUserAndToken'
import <%= h.changeCase.pascal(name) %>Factory from '../../fixtures/<%= h.changeCase.pascal(name) %>Factory'
import { EntityManager } from '@mikro-orm/mysql'

describe('<%= h.changeCase.sentenceCase(name) %> service - get', () => {
it('should return a list of <%= h.changeCase.noCase(name) %>s', async () => {
it('should return a of <%= h.changeCase.noCase(name) %>s', async () => {
const [token] = await createUserAndToken()
const <%= name %> = await new <%= h.changeCase.pascal(name) %>Factory().one()
const <%= h.changeCase.camel(name) %> = await new <%= h.changeCase.pascal(name) %>Factory().one()
await (<EntityManager>global.em).persistAndFlush(<%= h.changeCase.camel(name) %>)

await request(global.app)
.get(`/<%= name %>/<%= name %>.id`)
.get(`/<%= name %>/${<%= h.changeCase.camel(name) %>.id}`)
.auth(token, { type: 'bearer' })
.expect(200)
})
Expand Down
24 changes: 2 additions & 22 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 1 addition & 2 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "game-services",
"version": "0.54.0",
"version": "0.55.0",
"description": "",
"main": "src/index.ts",
"scripts": {
Expand Down Expand Up @@ -40,7 +40,6 @@
"hygen": "^6.2.11",
"lint-staged": ">=10",
"supertest": "^7.0.0",
"superwstest": "^2.0.4",
"ts-node": "^10.7.0",
"tsx": "^4.11.0",
"typescript": "^5.4.5",
Expand Down
2 changes: 2 additions & 0 deletions src/config/api-routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import playerAuthMiddleware from '../middlewares/player-auth-middleware'
import PlayerAuthAPIService from '../services/api/player-auth-api.service'
import continunityMiddleware from '../middlewares/continunity-middleware'
import PlayerGroupAPIService from '../services/api/player-group-api.service'
import SocketTicketAPIService from '../services/api/socket-ticket-api.service'

export default function configureAPIRoutes(app: Koa) {
app.use(apiKeyMiddleware)
Expand All @@ -33,6 +34,7 @@ export default function configureAPIRoutes(app: Koa) {
app.use(playerAuthMiddleware)
app.use(continunityMiddleware)

app.use(service('/v1/socket-tickets', new SocketTicketAPIService()))
app.use(service('/v1/game-channels', new GameChannelAPIService()))
app.use(service('/v1/player-groups', new PlayerGroupAPIService()))
app.use(service('/v1/health-check', new HealthCheckAPIService()))
Expand Down
18 changes: 18 additions & 0 deletions src/docs/socket-tickets-api.docs.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import SocketTicketAPIService from '../services/api/socket-ticket-api.service'
import APIDocs from './api-docs'

const SocketTicketAPIDocs: APIDocs<SocketTicketAPIService> = {
post: {
description: 'Create a socket ticket (expires after 5 minutes)',
samples: [
{
title: 'Sample response',
sample: {
ticket: '6c6ef345-0ac3-4edc-a221-b807fbbac4ac'
}
}
]
}
}

export default SocketTicketAPIDocs
7 changes: 1 addition & 6 deletions src/services/api/health-check-api.service.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,6 @@
import { Response, Routes } from 'koa-clay'
import { Response } from 'koa-clay'
import APIService from './api-service'

@Routes([
{
method: 'GET'
}
])
export default class HealthCheckAPIService extends APIService {
async index(): Promise<Response> {
return {
Expand Down
31 changes: 31 additions & 0 deletions src/services/api/socket-ticket-api.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { Docs, Request, Response } from 'koa-clay'
import APIService from './api-service'
import { createRedisConnection } from '../../config/redis.config'
import { v4 } from 'uuid'
import Redis from 'ioredis'
import APIKey from '../../entities/api-key'
import SocketTicketAPIDocs from '../../docs/socket-tickets-api.docs'

export async function createSocketTicket(redis: Redis, key: APIKey, devBuild: boolean): Promise<string> {
const ticket = v4()
const payload = `${key.id}:${devBuild ? '1' : '0'}`
await redis.set(`socketTickets.${ticket}`, payload, 'EX', 300)

return ticket
}

export default class SocketTicketAPIService extends APIService {
@Docs(SocketTicketAPIDocs.post)
async post(req: Request): Promise<Response> {
const redis = createRedisConnection(req.ctx)

const ticket = await createSocketTicket(redis, req.ctx.state.key, req.headers['x-talo-dev-build'] === '1')

return {
status: 200,
body: {
ticket
}
}
}
}
25 changes: 0 additions & 25 deletions src/socket/authenticateSocket.ts

This file was deleted.

18 changes: 13 additions & 5 deletions src/socket/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ import { IncomingMessage, Server } from 'http'
import { RawData, WebSocket, WebSocketServer } from 'ws'
import { captureException } from '@sentry/node'
import { EntityManager, RequestContext } from '@mikro-orm/mysql'
import authenticateSocket from './authenticateSocket'
import SocketConnection from './socketConnection'
import SocketRouter from './router/socketRouter'
import { sendMessage } from './messages/socketMessage'
Expand All @@ -11,6 +10,9 @@ import { Queue } from 'bullmq'
import { createSocketEventQueue, SocketEventData } from './socketEvent'
import { ClickHouseClient } from '@clickhouse/client'
import createClickhouseClient from '../lib/clickhouse/createClient'
import Redis from 'ioredis'
import redisConfig from '../config/redis.config'
import SocketTicket from './socketTicket'

type CloseConnectionOptions = {
code?: number
Expand Down Expand Up @@ -73,10 +75,14 @@ export default class Socket {
async handleConnection(ws: WebSocket, req: IncomingMessage): Promise<void> {
logConnection(req)

const redis = new Redis(redisConfig)

await RequestContext.create(this.em, async () => {
const key = await authenticateSocket(req.headers?.authorization ?? '')
if (key) {
const connection = new SocketConnection(this, ws, key, req)
const url = new URL(req.url, 'http://localhost')
const ticket = new SocketTicket(url.searchParams.get('ticket') ?? '')

if (await ticket.validate(redis)) {
const connection = new SocketConnection(this, ws, ticket, req.socket.remoteAddress)
this.connections.set(ws, connection)

await this.trackEvent('open', {
Expand All @@ -85,14 +91,16 @@ export default class Socket {
code: null,
gameId: connection.game.id,
playerAliasId: null,
devBuild: req.headers['x-talo-dev-build'] === '1'
devBuild: ticket.devBuild
})

await sendMessage(connection, 'v1.connected', {})
} else {
await this.closeConnection(ws)
}
})

await redis.quit()
}

async handleMessage(ws: WebSocket, data: RawData): Promise<void> {
Expand Down
39 changes: 13 additions & 26 deletions src/socket/socketConnection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,7 @@ import { WebSocket } from 'ws'
import PlayerAlias from '../entities/player-alias'
import Game from '../entities/game'
import APIKey, { APIKeyScope } from '../entities/api-key'
import { IncomingHttpHeaders, IncomingMessage } from 'http'
import { RequestContext } from '@mikro-orm/core'
import jwt from 'jsonwebtoken'
import { v4 } from 'uuid'
import Redis from 'ioredis'
import redisConfig from '../config/redis.config'
Expand All @@ -13,28 +11,25 @@ import Socket from '.'
import { SocketMessageResponse } from './messages/socketMessage'
import { logResponse } from './messages/socketLogger'
import { SocketErrorCode } from './messages/socketError'
import SocketTicket from './socketTicket'

export default class SocketConnection {
alive: boolean = true
playerAliasId: number | null = null
game: Game | null = null
private scopes: APIKeyScope[] = []
private headers: IncomingHttpHeaders = {}
private remoteAddress: string = 'unknown'
playerAliasId: number
game: Game
private apiKey: APIKey

rateLimitKey: string = v4()
rateLimitWarnings: number = 0

constructor(
private readonly wss: Socket,
private readonly ws: WebSocket,
apiKey: APIKey,
req: IncomingMessage
private readonly ticket: SocketTicket,
private readonly remoteAddress: string
) {
this.game = apiKey.game
this.scopes = apiKey.scopes
this.headers = req.headers
this.remoteAddress = req.socket.remoteAddress
this.game = this.ticket.apiKey.game
this.apiKey = this.ticket.apiKey
}

async getPlayerAlias(): Promise<PlayerAlias | null> {
Expand All @@ -44,27 +39,19 @@ export default class SocketConnection {
}

getAPIKeyId(): number {
const token = this.headers.authorization.split('Bearer ')[1]
const decodedToken = jwt.decode(token)
return decodedToken.sub
return this.ticket.apiKey.id
}

hasScope(scope: APIKeyScope): boolean {
return this.scopes.includes(APIKeyScope.FULL_ACCESS) || this.scopes.includes(scope)
return this.apiKey.scopes.includes(APIKeyScope.FULL_ACCESS) || this.apiKey.scopes.includes(scope)
}

hasScopes(scopes: APIKeyScope[]): boolean {
if (this.hasScope(APIKeyScope.FULL_ACCESS)) {
return true
}
return scopes.every((scope) => this.hasScope(scope))
return this.hasScope(APIKeyScope.FULL_ACCESS) || scopes.every((scope) => this.hasScope(scope))
}

getRateLimitMaxRequests(): number {
if (this.playerAliasId) {
return 100
}
return 10
return this.playerAliasId ? 100 : 10
}

async checkRateLimitExceeded(): Promise<boolean> {
Expand All @@ -88,7 +75,7 @@ export default class SocketConnection {
}

isDevBuild(): boolean {
return this.headers['x-talo-dev-build'] === '1'
return this.ticket.devBuild
}

async sendMessage<T extends object>(res: SocketMessageResponse, data: T): Promise<void> {
Expand Down
35 changes: 35 additions & 0 deletions src/socket/socketTicket.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { Redis } from 'ioredis'
import APIKey from '../entities/api-key'
import { RequestContext } from '@mikro-orm/mysql'

export default class SocketTicket {
apiKey: APIKey
devBuild: boolean

constructor(private readonly ticket: string) { }

async validate(redis: Redis): Promise<boolean> {
const ticketValue = await redis.get(`socketTickets.${this.ticket}`)
if (ticketValue) {
await redis.del(`socketTickets.${this.ticket}`)
const [keyId, devBuild] = ticketValue.split(':')

try {
this.devBuild = devBuild === '1'

const em = RequestContext.getEntityManager()
this.apiKey = await em.getRepository(APIKey).findOneOrFail({
id: Number(keyId),
revokedAt: null
}, {
populate: ['game']
})

return true
} catch (error) {
return false
}
}
return false
}
}
Loading

0 comments on commit 8afa2bb

Please sign in to comment.