From cbfb41d436738f496362007168613e0e474c8090 Mon Sep 17 00:00:00 2001 From: tudor <7089284+tudddorrr@users.noreply.github.com> Date: Thu, 28 Nov 2024 12:25:29 +0000 Subject: [PATCH 01/15] socket basics --- docker-compose.dev.yml | 14 -- docker-compose.yml | 9 + envs/.env.dev | 2 +- package-lock.json | 33 +++- package.json | 13 +- src/config/api-routes.ts | 2 +- src/config/protected-routes.ts | 2 +- src/config/providers.ts | 6 +- src/config/public-routes.ts | 2 +- src/config/socket-routes.ts | 19 +++ src/index.ts | 27 +-- src/middlewares/cleanup-middleware.ts | 2 +- src/middlewares/continunity-middleware.ts | 2 +- src/middlewares/cors-middleware.ts | 2 +- src/middlewares/current-player-middleware.ts | 2 +- src/middlewares/dev-data-middleware.ts | 2 +- src/middlewares/error-middleware.ts | 2 +- src/middlewares/limiter-middleware.ts | 2 +- src/middlewares/request-context-middleware.ts | 6 + src/middlewares/tracing-middleware.ts | 2 +- .../gameChannelListeners/message.test.ts | 103 ++++++++++++ .../playerListeners/identify.test.ts | 95 +++++++++++ tests/socket/router.test.ts | 154 ++++++++++++++++++ tests/socket/server.test.ts | 41 +++++ tests/utils/requestAuthedSocket.ts | 37 +++++ 25 files changed, 534 insertions(+), 47 deletions(-) delete mode 100644 docker-compose.dev.yml create mode 100644 src/config/socket-routes.ts create mode 100644 src/middlewares/request-context-middleware.ts create mode 100644 tests/socket/listeners/gameChannelListeners/message.test.ts create mode 100644 tests/socket/listeners/playerListeners/identify.test.ts create mode 100644 tests/socket/router.test.ts create mode 100644 tests/socket/server.test.ts create mode 100644 tests/utils/requestAuthedSocket.ts diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml deleted file mode 100644 index a22b1eab..00000000 --- a/docker-compose.dev.yml +++ /dev/null @@ -1,14 +0,0 @@ -services: - backend: - build: - context: . - target: dev - image: backend - depends_on: - - db - ports: - - 3000:80 - volumes: - - ./src:/usr/backend/src - - ./tests:/usr/backend/tests - \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml index 55103f56..9fd99f9a 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,7 +1,15 @@ services: backend: + build: + context: . + target: dev + image: backend + ports: + - 3000:80 volumes: - .env:/usr/backend/.env + - ./src:/usr/backend/src + - ./tests:/usr/backend/tests depends_on: - db - redis @@ -29,6 +37,7 @@ services: build: context: . dockerfile: ./clickhouse/Dockerfile + image: clickhouse environment: CLICKHOUSE_USER: ${CLICKHOUSE_USER} CLICKHOUSE_PASSWORD: ${CLICKHOUSE_PASSWORD} diff --git a/envs/.env.dev b/envs/.env.dev index b2b196e8..5f834bbf 100644 --- a/envs/.env.dev +++ b/envs/.env.dev @@ -15,7 +15,7 @@ CLICKHOUSE_USER=gs_ch CLICKHOUSE_PASSWORD=password CLICKHOUSE_DB=gs_ch_dev -DEMO_ORGANISATION_NAME=Talo Demo +DEMO_ORGANISATION_NAME="Talo Demo" SENDGRID_KEY= diff --git a/package-lock.json b/package-lock.json index 665691cc..3cf7e5e6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -41,7 +41,8 @@ "qrcode": "^1.5.0", "qs": "^6.11.0", "stripe": "^12.0.0", - "uuid": "^9.0.0" + "uuid": "^9.0.0", + "ws": "^8.18.0" }, "devDependencies": { "@mikro-orm/cli": "^6.4.1", @@ -52,6 +53,7 @@ "@types/lodash": "^4.14.182", "@types/node": "20", "@types/supertest": "^6.0.2", + "@types/ws": "^8.5.13", "@typescript-eslint/eslint-plugin": "^7.13.1", "@typescript-eslint/parser": "^7.13.1", "@vitest/coverage-v8": "^1.5.2", @@ -2195,6 +2197,15 @@ "@types/superagent": "^8.1.0" } }, + "node_modules/@types/ws": { + "version": "8.5.13", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.5.13.tgz", + "integrity": "sha512-osM/gWBTPKgHV8XkTunnegTRIsvF6owmf5w+JtAfOw472dptdm0dlGv4xCt6GwQRcC2XVOvvRE/0bAoQcL2QkA==", + "dev": true, + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "7.13.1", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-7.13.1.tgz", @@ -8806,6 +8817,26 @@ "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", "license": "ISC" }, + "node_modules/ws": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.0.tgz", + "integrity": "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "node_modules/y18n": { "version": "5.0.8", "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", diff --git a/package.json b/package.json index c58a8193..c32927d5 100644 --- a/package.json +++ b/package.json @@ -6,13 +6,12 @@ "scripts": { "watch": "tsx watch src/index.ts", "build": "npx tsc -p tsconfig.build.json", - "dc": "docker compose -f docker-compose.yml -f docker-compose.dev.yml", "seed": "DB_HOST=127.0.0.1 CLICKHOUSE_HOST=127.0.0.1 tsx tests/seed.ts", "test": "./tests/run-tests.sh", - "up": "npm run dc -- up --build -d", - "down": "npm run dc -- down", - "restart": "npm run dc -- restart backend && npm run logs", - "logs": "npm run dc -- logs backend --follow", + "up": "docker compose up --build -d", + "down": "docker compose down", + "restart": "docker compose restart backend && npm run logs", + "logs": "docker compose logs backend --follow", "migration:create": "DB_HOST=127.0.0.1 mikro-orm migration:create", "migration:up": "DB_HOST=127.0.0.1 mikro-orm migration:up", "service:create": "hygen service new", @@ -30,6 +29,7 @@ "@types/lodash": "^4.14.182", "@types/node": "20", "@types/supertest": "^6.0.2", + "@types/ws": "^8.5.13", "@typescript-eslint/eslint-plugin": "^7.13.1", "@typescript-eslint/parser": "^7.13.1", "@vitest/coverage-v8": "^1.5.2", @@ -79,7 +79,8 @@ "qrcode": "^1.5.0", "qs": "^6.11.0", "stripe": "^12.0.0", - "uuid": "^9.0.0" + "uuid": "^9.0.0", + "ws": "^8.18.0" }, "mikro-orm": { "configPaths": [ diff --git a/src/config/api-routes.ts b/src/config/api-routes.ts index bc19e148..49644de3 100644 --- a/src/config/api-routes.ts +++ b/src/config/api-routes.ts @@ -17,7 +17,7 @@ import PlayerAuthAPIService from '../services/api/player-auth-api.service' import continunityMiddleware from '../middlewares/continunity-middleware' import PlayerGroupAPIService from '../services/api/player-group-api.service' -export default (app: Koa) => { +export default function configureAPIRoutes(app: Koa) { app.use(apiKeyMiddleware) app.use(apiRouteAuthMiddleware) app.use(limiterMiddleware) diff --git a/src/config/protected-routes.ts b/src/config/protected-routes.ts index b52c46d9..8cae23b7 100644 --- a/src/config/protected-routes.ts +++ b/src/config/protected-routes.ts @@ -18,7 +18,7 @@ import BillingService from '../services/billing.service' import IntegrationService from '../services/integration.service' import { getRouteInfo, protectedRouteAuthMiddleware } from '../middlewares/route-middleware' -export default (app: Koa) => { +export default function protectedRoutes(app: Koa) { app.use(protectedRouteAuthMiddleware) app.use(async (ctx: Context, next: Next): Promise => { diff --git a/src/config/providers.ts b/src/config/providers.ts index 318ce180..188942b0 100644 --- a/src/config/providers.ts +++ b/src/config/providers.ts @@ -5,12 +5,12 @@ import ormConfig from './mikro-orm.config' import { MikroORM } from '@mikro-orm/mysql' import tracingMiddleware from '../middlewares/tracing-middleware' -const initProviders = async (app: Koa) => { +export default async function initProviders(app: Koa, isTest: boolean) { try { const orm = await MikroORM.init(ormConfig) app.context.em = orm.em - if (!app.context.isTest) { + if (!isTest) { const migrator = orm.getMigrator() await migrator.up() } @@ -32,5 +32,3 @@ const initProviders = async (app: Koa) => { app.use(tracingMiddleware) } - -export default initProviders diff --git a/src/config/public-routes.ts b/src/config/public-routes.ts index 18f2da7b..ee89aacc 100644 --- a/src/config/public-routes.ts +++ b/src/config/public-routes.ts @@ -6,7 +6,7 @@ import InvitePublicService from '../services/public/invite-public.service' import UserPublicService from '../services/public/user-public.service' import WebhookService from '../services/public/webhook.service' -export default (app: Koa) => { +export default function configurePublicRoutes(app: Koa) { const serviceOpts: ServiceOpts = { docs: { hidden: true diff --git a/src/config/socket-routes.ts b/src/config/socket-routes.ts new file mode 100644 index 00000000..55f7b1a4 --- /dev/null +++ b/src/config/socket-routes.ts @@ -0,0 +1,19 @@ +import { Server } from 'http' +import { WebSocketServer } from 'ws' + +export default function configureSocketRoutes(server: Server) { + const wss = new WebSocketServer({ server }) + + wss.on('connection', (ws, req) => { + ws.on('error', console.error) + + console.log(req.headers) + + ws.on('message', function message(data) { + console.log('Received:', data) + ws.send('Thanks') + }) + + ws.send('Hello') + }) +} diff --git a/src/index.ts b/src/index.ts index 2b8dc0f6..a983155e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,9 +1,8 @@ import 'dotenv/config' -import Koa, { Context, Next } from 'koa' +import Koa from 'koa' import logger from 'koa-logger' import bodyParser from 'koa-bodyparser' import helmet from 'koa-helmet' -import { RequestContext } from '@mikro-orm/mysql' import configureProtectedRoutes from './config/protected-routes' import configurePublicRoutes from './config/public-routes' import configureAPIRoutes from './config/api-routes' @@ -13,21 +12,23 @@ import initProviders from './config/providers' import createEmailQueue from './lib/queues/createEmailQueue' import devDataMiddleware from './middlewares/dev-data-middleware' import cleanupMiddleware from './middlewares/cleanup-middleware' +import requestContextMiddleware from './middlewares/request-context-middleware' +import { createServer } from 'http' +import configureSocketRoutes from './config/socket-routes' const isTest = process.env.NODE_ENV === 'test' -export const init = async (): Promise => { +export default async function init(): Promise { const app = new Koa() - app.context.isTest = isTest - await initProviders(app) + await initProviders(app, isTest) if (!isTest) app.use(logger()) app.use(errorMiddleware) app.use(bodyParser()) app.use(helmet()) app.use(corsMiddleware) - app.use((ctx: Context, next: Next) => RequestContext.create(ctx.em, next)) + app.use(requestContextMiddleware) app.use(devDataMiddleware) app.context.emailQueue = createEmailQueue() @@ -37,10 +38,16 @@ export const init = async (): Promise => { app.use(cleanupMiddleware) - if (!isTest) app.listen(80, () => console.log('Listening on port 80')) + const server = createServer(app.callback()) + configureSocketRoutes(server) + + if (!isTest) { + server.listen(80, () => console.info('Listening on port 80')) + } + return app } -if (!isTest) init() - -export default init +if (!isTest) { + init() +} diff --git a/src/middlewares/cleanup-middleware.ts b/src/middlewares/cleanup-middleware.ts index a4bf6091..ef8fb41e 100644 --- a/src/middlewares/cleanup-middleware.ts +++ b/src/middlewares/cleanup-middleware.ts @@ -1,7 +1,7 @@ import Redis from 'ioredis' import { Context, Next } from 'koa' -export default async (ctx: Context, next: Next): Promise => { +export default async function cleanupMiddleware(ctx: Context, next: Next): Promise { if (ctx.state.redis instanceof Redis) { await (ctx.state.redis as Redis).quit() } diff --git a/src/middlewares/continunity-middleware.ts b/src/middlewares/continunity-middleware.ts index 0007e394..63d086ef 100644 --- a/src/middlewares/continunity-middleware.ts +++ b/src/middlewares/continunity-middleware.ts @@ -4,7 +4,7 @@ import { APIKeyScope } from '../entities/api-key' import { isAPIRoute } from './route-middleware' import checkScope from '../policies/checkScope' -export default async (ctx: Context, next: Next): Promise => { +export default async function continuityMiddleware(ctx: Context, next: Next): Promise { if (isAPIRoute(ctx) && checkScope(ctx.state.key, APIKeyScope.WRITE_CONTINUITY_REQUESTS)) { const header = ctx.headers['x-talo-continuity-timestamp'] diff --git a/src/middlewares/cors-middleware.ts b/src/middlewares/cors-middleware.ts index a94dc82f..6dbc082a 100644 --- a/src/middlewares/cors-middleware.ts +++ b/src/middlewares/cors-middleware.ts @@ -2,7 +2,7 @@ import { Context, Next } from 'koa' import cors from '@koa/cors' import { isAPIRoute } from './route-middleware' -export default async (ctx: Context, next: Next): Promise => { +export default async function corsMiddleware(ctx: Context, next: Next): Promise { if (isAPIRoute(ctx)) { return cors()(ctx, next) } else { diff --git a/src/middlewares/current-player-middleware.ts b/src/middlewares/current-player-middleware.ts index 8e8f8ae0..e3ce776a 100644 --- a/src/middlewares/current-player-middleware.ts +++ b/src/middlewares/current-player-middleware.ts @@ -5,7 +5,7 @@ export function setCurrentPlayerState(ctx: Context, playerId: string, aliasId: n ctx.state.currentAliasId = aliasId } -export default async (ctx: Context, next: Next): Promise => { +export default async function currentPlayerMiddleware(ctx: Context, next: Next): Promise { setCurrentPlayerState( ctx, ctx.headers['x-talo-player'] as string, ctx.headers['x-talo-alias'] ? Number(ctx.headers['x-talo-alias']) : undefined diff --git a/src/middlewares/dev-data-middleware.ts b/src/middlewares/dev-data-middleware.ts index ef5ca66e..9f1d7f12 100644 --- a/src/middlewares/dev-data-middleware.ts +++ b/src/middlewares/dev-data-middleware.ts @@ -3,7 +3,7 @@ import { Context, Next } from 'koa' import Player from '../entities/player' import PlayerProp from '../entities/player-prop' -export default async (ctx: Context, next: Next): Promise => { +export default async function devDataMiddleware(ctx: Context, next: Next): Promise { if (Number(ctx.headers['x-talo-include-dev-data'])) { ctx.state.includeDevData = true } diff --git a/src/middlewares/error-middleware.ts b/src/middlewares/error-middleware.ts index 8342d423..7d5995ab 100644 --- a/src/middlewares/error-middleware.ts +++ b/src/middlewares/error-middleware.ts @@ -2,7 +2,7 @@ import { Context, Next } from 'koa' import * as Sentry from '@sentry/node' import Redis from 'ioredis' -export default async (ctx: Context, next: Next) => { +export default async function errorMiddleware(ctx: Context, next: Next) { try { await next() } catch (err) { diff --git a/src/middlewares/limiter-middleware.ts b/src/middlewares/limiter-middleware.ts index 44d41ee2..d7209306 100644 --- a/src/middlewares/limiter-middleware.ts +++ b/src/middlewares/limiter-middleware.ts @@ -5,7 +5,7 @@ import { isAPIRoute } from './route-middleware' const MAX_REQUESTS = 50 const EXPIRE_TIME = 1 -export default async (ctx: Context, next: Next): Promise => { +export default async function limiterMiddleware(ctx: Context, next: Next): Promise { if (isAPIRoute(ctx) && process.env.NODE_ENV !== 'test') { const key = `requests:${ctx.state.user.sub}` diff --git a/src/middlewares/request-context-middleware.ts b/src/middlewares/request-context-middleware.ts new file mode 100644 index 00000000..1f89d3a8 --- /dev/null +++ b/src/middlewares/request-context-middleware.ts @@ -0,0 +1,6 @@ +import { Context, Next } from 'koa' +import { RequestContext } from '@mikro-orm/mysql' + +export default async function requestContextMiddleware(ctx: Context, next: Next): Promise { + return RequestContext.create(ctx.em, next) +} diff --git a/src/middlewares/tracing-middleware.ts b/src/middlewares/tracing-middleware.ts index eccd5d17..002de484 100644 --- a/src/middlewares/tracing-middleware.ts +++ b/src/middlewares/tracing-middleware.ts @@ -2,7 +2,7 @@ import * as Sentry from '@sentry/node' import { Context, Next } from 'koa' import { stripUrlQueryAndFragment } from '@sentry/utils' -export default async (ctx: Context, next: Next) => { +export default async function tracingMiddleware(ctx: Context, next: Next) { const reqMethod = ctx.method.toUpperCase() const reqUrl = stripUrlQueryAndFragment(ctx.url) diff --git a/tests/socket/listeners/gameChannelListeners/message.test.ts b/tests/socket/listeners/gameChannelListeners/message.test.ts new file mode 100644 index 00000000..37278d6f --- /dev/null +++ b/tests/socket/listeners/gameChannelListeners/message.test.ts @@ -0,0 +1,103 @@ +import request from 'superwstest' +import Socket from '../../../../src/socket' +import { APIKeyScope } from '../../../../src/entities/api-key' +import createSocketIdentifyMessage from '../../../utils/requestAuthedSocket' +import GameChannelFactory from '../../../fixtures/GameChannelFactory' +import { EntityManager } from '@mikro-orm/mysql' + +describe('Game channel listeners - message', () => { + let socket: Socket + + beforeAll(() => { + socket = new Socket(global.server, global.em) + }) + + afterAll(() => { + socket.getServer().close() + }) + + it('should successfully send a message', async () => { + const [identifyMessage, token, player] = await createSocketIdentifyMessage([APIKeyScope.READ_PLAYERS, APIKeyScope.WRITE_GAME_CHANNELS]) + const channel = await new GameChannelFactory(player.game).one() + channel.members.add(player.aliases[0]) + await (global.em).persistAndFlush(channel) + + await request(global.server) + .ws('/') + .set('authorization', `Bearer ${token}`) + .expectJson() + .sendJson(identifyMessage) + .expectJson() + .sendJson({ + req: 'v1.channels.message', + data: { + channelName: channel.name, + message: 'Hello world' + } + }) + .expectJson((actual) => { + expect(actual.res).toBe('v1.channels.message') + expect(actual.data.channelName).toBe(channel.name) + expect(actual.data.message).toBe('Hello world') + expect(actual.data.fromPlayerAlias.id).toBe(player.aliases[0].id) + }) + }) + + it('should receive an error if the player is not in the channel', async () => { + const [identifyMessage, token, player] = await createSocketIdentifyMessage([APIKeyScope.READ_PLAYERS, APIKeyScope.WRITE_GAME_CHANNELS]) + const channel = await new GameChannelFactory(player.game).one() + await (global.em).persistAndFlush(channel) + + await request(global.server) + .ws('/') + .set('authorization', `Bearer ${token}`) + .expectJson() + .sendJson(identifyMessage) + .expectJson() + .sendJson({ + req: 'v1.channels.message', + data: { + channelName: channel.name, + message: 'Hello world' + } + }) + .expectJson({ + res: 'v1.error', + data: { + req: 'v1.channels.message', + message: 'An error occurred while processing the message', + errorCode: 'LISTENER_ERROR', + cause: 'Player not in channel' + } + }) + .close() + }) + + it('should receive an error if the channel does not exist', async () => { + const [identifyMessage, token] = await createSocketIdentifyMessage([APIKeyScope.READ_PLAYERS, APIKeyScope.WRITE_GAME_CHANNELS]) + + await request(global.server) + .ws('/') + .set('authorization', `Bearer ${token}`) + .expectJson() + .sendJson(identifyMessage) + .expectJson() + .sendJson({ + req: 'v1.channels.message', + data: { + channelName: 'Guild chat', + message: 'Hello world' + } + }) + .expectJson({ + res: 'v1.error', + data: { + req: 'v1.channels.message', + message: 'An error occurred while processing the message', + errorCode: 'LISTENER_ERROR', + cause: 'Channel not found' + } + }) + .close() + }) +}) diff --git a/tests/socket/listeners/playerListeners/identify.test.ts b/tests/socket/listeners/playerListeners/identify.test.ts new file mode 100644 index 00000000..652b5273 --- /dev/null +++ b/tests/socket/listeners/playerListeners/identify.test.ts @@ -0,0 +1,95 @@ +import request from 'superwstest' +import Socket from '../../../../src/socket' +import { APIKeyScope } from '../../../../src/entities/api-key' +import createSocketIdentifyMessage from '../../../utils/requestAuthedSocket' +import PlayerAlias from '../../../../src/entities/player-alias' +import PlayerAliasFactory from '../../../fixtures/PlayerAliasFactory' +import PlayerAuthFactory from '../../../fixtures/PlayerAuthFactory' +import { EntityManager } from '@mikro-orm/mysql' + +describe('Player listeners - identify', () => { + let socket: Socket + + beforeAll(() => { + socket = new Socket(global.server, global.em) + }) + + afterAll(() => { + socket.getServer().close() + }) + + it('should successfully identify a player', async () => { + const [identifyMessage, token, player] = await createSocketIdentifyMessage([APIKeyScope.READ_PLAYERS]) + + await request(global.server) + .ws('/') + .set('authorization', `Bearer ${token}`) + .expectJson({ + res: 'v1.connected', + data: {} + }) + .sendJson(identifyMessage) + .expectJson((actual) => { + expect(actual.res).toBe('v1.players.identify.success') + expect(actual.data.id).toBe(player.aliases[0].id) + }) + .close() + }) + + it('should require the socket token to be valid', async () => { + const [identifyMessage, token] = await createSocketIdentifyMessage([APIKeyScope.READ_PLAYERS]) + + await request(global.server) + .ws('/') + .set('authorization', `Bearer ${token}`) + .expectJson({ + res: 'v1.connected', + data: {} + }) + .sendJson({ + ...identifyMessage, + data: { + ...identifyMessage.data, + socketToken: 'invalid' + } + }) + .expectJson({ + res: 'v1.error', + data: { + req: 'v1.players.identify', + message: 'Invalid socket token', + errorCode: 'INVALID_SOCKET_TOKEN' + } + }) + .close() + }) + + it('should require a valid session token to identify Talo aliases', async () => { + const [identifyMessage, token, player] = await createSocketIdentifyMessage([APIKeyScope.READ_PLAYERS]) + const alias = await new PlayerAliasFactory(player).talo().one() + const auth = await new PlayerAuthFactory().one() + player.aliases.add(alias) + player.auth = auth + await (global.em).persistAndFlush(player) + + await request(global.server) + .ws('/') + .set('authorization', `Bearer ${token}`) + .expectJson({ + res: 'v1.connected', + data: {} + }) + .sendJson({ + req: 'v1.players.identify', + data: { + playerAliasId: alias.id, + socketToken: identifyMessage.data.socketToken + } + }) + .expectJson((actual) => { + expect(actual.res).toBe('v1.players.identify.success') + expect(actual.data.id).toBe(player.aliases[0].id) + }) + .close() + }) +}) diff --git a/tests/socket/router.test.ts b/tests/socket/router.test.ts new file mode 100644 index 00000000..a8565a50 --- /dev/null +++ b/tests/socket/router.test.ts @@ -0,0 +1,154 @@ +import request from 'superwstest' +import Socket from '../../src/socket' +import createAPIKeyAndToken from '../utils/createAPIKeyAndToken' +import { APIKeyScope } from '../../src/entities/api-key' +import createSocketIdentifyMessage from '../utils/requestAuthedSocket' + +describe('Socket router', () => { + let socket: Socket + + beforeAll(() => { + socket = new Socket(global.server, global.em) + }) + + afterAll(() => { + socket.getServer().close() + }) + + it('should reject invalid messages', async () => { + const [, token] = await createAPIKeyAndToken([]) + + await request(global.server) + .ws('/') + .set('authorization', `Bearer ${token}`) + .expectJson({ + res: 'v1.connected', + data: {} + }) + .sendJson({ + blah: 'blah' + }) + .expectJson({ + res: 'v1.error', + data: { + req: 'unknown', + message: 'Invalid message request', + errorCode: 'INVALID_MESSAGE' + } + }) + .close() + }) + + it('should reject unknown requests', async () => { + const [, token] = await createAPIKeyAndToken([]) + + await request(global.server) + .ws('/') + .set('authorization', `Bearer ${token}`) + .expectJson({ + res: 'v1.connected', + data: {} + }) + .sendJson({ + req: 'v1.magic', + data: {} + }) + .expectJson({ + res: 'v1.error', + data: { + req: 'unknown', + message: 'Invalid message request', + errorCode: 'INVALID_MESSAGE' + } + }) + .close() + }) + + it('should reject requests where a player is required but one hasn\'t been identified yet', async () => { + const [, token] = await createAPIKeyAndToken([]) + + await request(global.server) + .ws('/') + .set('authorization', `Bearer ${token}`) + .expectJson({ + res: 'v1.connected', + data: {} + }) + .sendJson({ + req: 'v1.channels.message', + data: { + channelName: 'general', + message: 'Hello, world!' + } + }) + .expectJson({ + res: 'v1.error', + data: { + req: 'v1.channels.message', + message: 'You must identify a player before sending this request', + errorCode: 'NO_PLAYER_FOUND' + } + }) + .close() + }) + + it('should reject requests where a scope is required but is not present', async () => { + const [identifyMessage, token] = await createSocketIdentifyMessage([APIKeyScope.READ_PLAYERS]) + + await request(global.server) + .ws('/') + .set('authorization', `Bearer ${token}`) + .expectJson({ + res: 'v1.connected', + data: {} + }) + .sendJson(identifyMessage) + .expectJson() + .sendJson({ + req: 'v1.channels.message', + data: { + channelName: 'general', + message: 'Hello, world!' + } + }) + .expectJson({ + res: 'v1.error', + data: { + req: 'v1.channels.message', + message: 'Missing access key scope(s): write:gameChannels', + errorCode: 'MISSING_ACCESS_KEY_SCOPES' + } + }) + .close() + }) + + it('should reject requests where the payload fails the listener\'s validation', async () => { + const [identifyMessage, token] = await createSocketIdentifyMessage([APIKeyScope.READ_PLAYERS, APIKeyScope.WRITE_GAME_CHANNELS]) + + await request(global.server) + .ws('/') + .set('authorization', `Bearer ${token}`) + .expectJson({ + res: 'v1.connected', + data: {} + }) + .sendJson(identifyMessage) + .expectJson() + .sendJson({ + req: 'v1.channels.message', + data: { + channelName: 'general', + myMessageToTheChannelIsGoingToBeThis: 'Hello, world!' + } + }) + .expectJson({ + res: 'v1.error', + data: { + req: 'v1.channels.message', + message: 'Invalid message data for request', + errorCode: 'INVALID_MESSAGE' + } + }) + .close() + }) +}) diff --git a/tests/socket/server.test.ts b/tests/socket/server.test.ts new file mode 100644 index 00000000..f21033e6 --- /dev/null +++ b/tests/socket/server.test.ts @@ -0,0 +1,41 @@ +import request from 'superwstest' +import Socket from '../../src/socket' +import createAPIKeyAndToken from '../utils/createAPIKeyAndToken' +import { isToday, subDays } from 'date-fns' +import { EntityManager } from '@mikro-orm/mysql' + +describe('Socket server', () => { + let socket: Socket + + beforeAll(() => { + socket = new Socket(global.server, global.em) + }) + + afterAll(() => { + socket.getServer().close() + }) + + it('should send a connected message when sending an auth header', async () => { + const [apiKey, token] = await createAPIKeyAndToken([]) + apiKey.lastUsedAt = subDays(new Date(), 1) + await (global.em).flush() + + await request(global.server) + .ws('/') + .set('authorization', `Bearer ${token}`) + .expectJson({ + res: 'v1.connected', + data: {} + }) + .close() + + await (global.em).refresh(apiKey) + expect(isToday(apiKey.lastUsedAt)).toBe(true) + }) + + it('should close connections without an auth header', async () => { + await request(global.server) + .ws('/') + .expectClosed(3000) + }) +}) diff --git a/tests/utils/requestAuthedSocket.ts b/tests/utils/requestAuthedSocket.ts new file mode 100644 index 00000000..0432d852 --- /dev/null +++ b/tests/utils/requestAuthedSocket.ts @@ -0,0 +1,37 @@ +import { EntityManager } from '@mikro-orm/mysql' +import { APIKeyScope } from '../../src/entities/api-key' +import PlayerFactory from '../fixtures/PlayerFactory' +import createAPIKeyAndToken from './createAPIKeyAndToken' +import Redis from 'ioredis' +import redisConfig from '../../src/config/redis.config' +import Player from '../../src/entities/player' + +type IdentifyMessage = { + req: 'v1.players.identify' + data: { + playerAliasId: number + socketToken: string + } +} + +export default async function createSocketIdentifyMessage(scopes: APIKeyScope[]): Promise<[IdentifyMessage, string, Player]> { + const [apiKey, token] = await createAPIKeyAndToken(scopes) + const player = await new PlayerFactory([apiKey.game]).one() + await (global.em).persistAndFlush(player) + + const redis = new Redis(redisConfig) + const socketToken = await player.aliases[0].createSocketToken(redis) + await redis.quit() + + return [ + { + req: 'v1.players.identify', + data: { + playerAliasId: player.aliases[0].id, + socketToken: socketToken + } + }, + token, + player + ] +} From f12bda85ed5ddfc6ae5eb51b435dc05417d98c85 Mon Sep 17 00:00:00 2001 From: tudor <7089284+tudddorrr@users.noreply.github.com> Date: Fri, 29 Nov 2024 07:49:08 +0000 Subject: [PATCH 02/15] fix tests still using em directly --- .../steamworksSyncLeaderboards.test.ts | 73 ++++++++----------- .../integrations/steamworksSyncStats.test.ts | 61 +++++++--------- 2 files changed, 56 insertions(+), 78 deletions(-) diff --git a/tests/lib/integrations/steamworksSyncLeaderboards.test.ts b/tests/lib/integrations/steamworksSyncLeaderboards.test.ts index b19e1e1d..cf0acdf3 100644 --- a/tests/lib/integrations/steamworksSyncLeaderboards.test.ts +++ b/tests/lib/integrations/steamworksSyncLeaderboards.test.ts @@ -1,5 +1,4 @@ -import { EntityManager, MikroORM } from '@mikro-orm/mysql' -import ormConfig from '../../../src/config/mikro-orm.config' +import { EntityManager } from '@mikro-orm/mysql' import { IntegrationType } from '../../../src/entities/integration' import { GetLeaderboardEntriesResponse, GetLeaderboardsForGameResponse, syncSteamworksLeaderboards } from '../../../src/lib/integrations/steamworks-integration' import IntegrationConfigFactory from '../../fixtures/IntegrationConfigFactory' @@ -17,26 +16,16 @@ import LeaderboardEntryFactory from '../../fixtures/LeaderboardEntryFactory' import { randNumber } from '@ngneat/falso' describe('Steamworks integration - sync leaderboards', () => { - let em: EntityManager const axiosMock = new AxiosMockAdapter(axios) - beforeAll(async () => { - const orm = await MikroORM.init(ormConfig) - em = orm.em - }) - - afterAll(async () => { - await em.getConnection().close() - }) - it('should pull in leaderboards and entries from steamworks', async () => { - const [, game] = await createOrganisationAndGame(em) + const [, game] = await createOrganisationAndGame() const steamworksLeaderboardId = randNumber({ min: 100_000, max: 999_999 }) const config = await new IntegrationConfigFactory().one() const integration = await new IntegrationFactory().construct(IntegrationType.STEAMWORKS, game, config).one() - await em.persistAndFlush(integration) + await (global.em).persistAndFlush(integration) const getLeaderboardsMock = vi.fn((): [number, GetLeaderboardsForGameResponse] => [200, { response: { @@ -71,19 +60,19 @@ describe('Steamworks integration - sync leaderboards', () => { }]) axiosMock.onGet(`https://partner.steam-api.com/ISteamLeaderboards/GetLeaderboardEntries/v1?appid=${integration.getConfig().appId}&leaderboardid=${steamworksLeaderboardId}&rangestart=0&rangeend=1.7976931348623157e%2B308&datarequest=RequestGlobal`).replyOnce(getEntriesMock) - await syncSteamworksLeaderboards(em, integration) + await syncSteamworksLeaderboards((global.em), integration) expect(getLeaderboardsMock).toHaveBeenCalledTimes(1) expect(getEntriesMock).toHaveBeenCalledTimes(1) - const event = await em.getRepository(SteamworksIntegrationEvent).findOne({ integration }) + const event = await (global.em).getRepository(SteamworksIntegrationEvent).findOne({ integration }) expect(event.request).toStrictEqual({ url: `https://partner.steam-api.com/ISteamLeaderboards/GetLeaderboardsForGame/v2?appid=${integration.getConfig().appId}`, body: '', method: 'GET' }) - const createdLeaderboard = await em.getRepository(Leaderboard).findOne({ + const createdLeaderboard = await (global.em).getRepository(Leaderboard).findOne({ game: integration.game, internalName: 'Quickest Win', name: 'Quickest Win', @@ -93,29 +82,29 @@ describe('Steamworks integration - sync leaderboards', () => { expect(createdLeaderboard).toBeTruthy() - const mapping = await em.getRepository(SteamworksLeaderboardMapping).findOne({ + const mapping = await (global.em).getRepository(SteamworksLeaderboardMapping).findOne({ leaderboard: createdLeaderboard, steamworksLeaderboardId }) expect(mapping).toBeTruthy() - const entry = await em.getRepository(LeaderboardEntry).findOne({ score: 1030 }) + const entry = await (global.em).getRepository(LeaderboardEntry).findOne({ score: 1030 }) expect(entry).toBeTruthy() }) it('should throw if the response leaderboards are not an array', async () => { - const [, game] = await createOrganisationAndGame(em) + const [, game] = await createOrganisationAndGame() const config = await new IntegrationConfigFactory().one() const integration = await new IntegrationFactory().construct(IntegrationType.STEAMWORKS, game, config).one() - await em.persistAndFlush(integration) + await (global.em).persistAndFlush(integration) const getLeaderboardsMock = vi.fn((): [number] => [404]) axiosMock.onGet(`https://partner.steam-api.com/ISteamLeaderboards/GetLeaderboardsForGame/v2?appid=${integration.getConfig().appId}`).replyOnce(getLeaderboardsMock) try { - await syncSteamworksLeaderboards(em, integration) + await syncSteamworksLeaderboards((global.em), integration) } catch (err) { expect(err.message).toBe('Failed to retrieve leaderboards - is your App ID correct?') } @@ -124,14 +113,14 @@ describe('Steamworks integration - sync leaderboards', () => { }) it('should update leaderboards with properties from steamworks', async () => { - const [, game] = await createOrganisationAndGame(em) + const [, game] = await createOrganisationAndGame() const leaderboard = await new LeaderboardFactory([game]).state(() => ({ sortMode: LeaderboardSortMode.ASC })).one() const mapping = new SteamworksLeaderboardMapping(randNumber({ min: 100_000, max: 999_999 }), leaderboard) const config = await new IntegrationConfigFactory().one() const integration = await new IntegrationFactory().construct(IntegrationType.STEAMWORKS, game, config).one() - await em.persistAndFlush([leaderboard, mapping, integration]) + await (global.em).persistAndFlush([leaderboard, mapping, integration]) const getLeaderboardsMock = vi.fn((): [number, GetLeaderboardsForGameResponse] => [200, { response: { @@ -161,12 +150,12 @@ describe('Steamworks integration - sync leaderboards', () => { }]) axiosMock.onGet(`https://partner.steam-api.com/ISteamLeaderboards/GetLeaderboardEntries/v1?appid=${integration.getConfig().appId}&leaderboardid=${mapping.steamworksLeaderboardId}&rangestart=0&rangeend=1.7976931348623157e%2B308&datarequest=RequestGlobal`).replyOnce(getEntriesMock) - await syncSteamworksLeaderboards(em, integration) + await syncSteamworksLeaderboards((global.em), integration) expect(getLeaderboardsMock).toHaveBeenCalledTimes(1) expect(getEntriesMock).toHaveBeenCalledTimes(1) - const updatedLeaderboard = await em.getRepository(Leaderboard).findOne({ + const updatedLeaderboard = await (global.em).getRepository(Leaderboard).findOne({ game: integration.game, internalName: 'Biggest Combo', name: 'Biggest Combo', @@ -178,14 +167,14 @@ describe('Steamworks integration - sync leaderboards', () => { }) it('should create a leaderboard mapping if a leaderboard with the same internal name exists', async () => { - const [, game] = await createOrganisationAndGame(em) + const [, game] = await createOrganisationAndGame() const leaderboard = await new LeaderboardFactory([game]).one() const steamworksLeaderboardId = randNumber({ min: 100_000, max: 999_999 }) const config = await new IntegrationConfigFactory().one() const integration = await new IntegrationFactory().construct(IntegrationType.STEAMWORKS, game, config).one() - await em.persistAndFlush([leaderboard, integration]) + await (global.em).persistAndFlush([leaderboard, integration]) const getLeaderboardsMock = vi.fn((): [number, GetLeaderboardsForGameResponse] => [200, { response: { @@ -215,12 +204,12 @@ describe('Steamworks integration - sync leaderboards', () => { }]) axiosMock.onGet(`https://partner.steam-api.com/ISteamLeaderboards/GetLeaderboardEntries/v1?appid=${integration.getConfig().appId}&leaderboardid=${steamworksLeaderboardId}&rangestart=0&rangeend=1.7976931348623157e%2B308&datarequest=RequestGlobal`).replyOnce(getEntriesMock) - await syncSteamworksLeaderboards(em, integration) + await syncSteamworksLeaderboards((global.em), integration) expect(getLeaderboardsMock).toHaveBeenCalledTimes(1) expect(getEntriesMock).toHaveBeenCalledTimes(1) - const mapping = await em.getRepository(SteamworksLeaderboardMapping).findOne({ + const mapping = await (global.em).getRepository(SteamworksLeaderboardMapping).findOne({ leaderboard, steamworksLeaderboardId }) @@ -229,13 +218,13 @@ describe('Steamworks integration - sync leaderboards', () => { }) it('should create leaderboards in steamworks', async () => { - const [, game] = await createOrganisationAndGame(em) + const [, game] = await createOrganisationAndGame() const leaderboard = await new LeaderboardFactory([game]).state(() => ({ sortMode: LeaderboardSortMode.DESC })).one() const config = await new IntegrationConfigFactory().one() const integration = await new IntegrationFactory().construct(IntegrationType.STEAMWORKS, game, config).one() - await em.persistAndFlush([leaderboard, integration]) + await (global.em).persistAndFlush([leaderboard, integration]) const getLeaderboardsMock = vi.fn((): [number, GetLeaderboardsForGameResponse] => [200, { response: { @@ -261,11 +250,11 @@ describe('Steamworks integration - sync leaderboards', () => { }]) axiosMock.onPost('https://partner.steam-api.com/ISteamLeaderboards/FindOrCreateLeaderboard/v2').replyOnce(createMock) - await syncSteamworksLeaderboards(em, integration) + await syncSteamworksLeaderboards((global.em), integration) expect(getLeaderboardsMock).toHaveBeenCalledTimes(1) - const event = await em.getRepository(SteamworksIntegrationEvent).findOne({ integration }, { orderBy: { id: 'DESC' } }) + const event = await (global.em).getRepository(SteamworksIntegrationEvent).findOne({ integration }, { orderBy: { id: 'DESC' } }) expect(event.request).toStrictEqual({ url: 'https://partner.steam-api.com/ISteamLeaderboards/FindOrCreateLeaderboard/v2', body: `appid=${config.appId}&name=${leaderboard.internalName}&sortmethod=Descending&displaytype=Numeric&createifnotfound=true&onlytrustedwrites=true&onlyfriendsreads=false`, @@ -274,7 +263,7 @@ describe('Steamworks integration - sync leaderboards', () => { }) it('should push through entries from steamworks for existing steam player aliases', async () => { - const [, game] = await createOrganisationAndGame(em) + const [, game] = await createOrganisationAndGame() const steamworksLeaderboardId = randNumber({ min: 100_000, max: 999_999 }) @@ -282,7 +271,7 @@ describe('Steamworks integration - sync leaderboards', () => { const config = await new IntegrationConfigFactory().one() const integration = await new IntegrationFactory().construct(IntegrationType.STEAMWORKS, game, config).one() - await em.persistAndFlush([player, integration]) + await (global.em).persistAndFlush([player, integration]) const getLeaderboardsMock = vi.fn((): [number, GetLeaderboardsForGameResponse] => [200, { response: { @@ -317,17 +306,17 @@ describe('Steamworks integration - sync leaderboards', () => { }]) axiosMock.onGet(`https://partner.steam-api.com/ISteamLeaderboards/GetLeaderboardEntries/v1?appid=${integration.getConfig().appId}&leaderboardid=${steamworksLeaderboardId}&rangestart=0&rangeend=1.7976931348623157e%2B308&datarequest=RequestGlobal`).replyOnce(getEntriesMock) - await syncSteamworksLeaderboards(em, integration) + await syncSteamworksLeaderboards((global.em), integration) expect(getLeaderboardsMock).toHaveBeenCalledTimes(1) expect(getEntriesMock).toHaveBeenCalledTimes(1) - const entry = await em.getRepository(LeaderboardEntry).findOne({ playerAlias: player.aliases[0] }) + const entry = await (global.em).getRepository(LeaderboardEntry).findOne({ playerAlias: player.aliases[0] }) expect(entry).toBeTruthy() }) it('should push through entries from talo into steamworks', async () => { - const [, game] = await createOrganisationAndGame(em) + const [, game] = await createOrganisationAndGame() const leaderboard = await new LeaderboardFactory([game]).one() const mapping = new SteamworksLeaderboardMapping(randNumber({ min: 100_000, max: 999_999 }), leaderboard) @@ -337,7 +326,7 @@ describe('Steamworks integration - sync leaderboards', () => { const config = await new IntegrationConfigFactory().one() const integration = await new IntegrationFactory().construct(IntegrationType.STEAMWORKS, game, config).one() - await em.persistAndFlush([leaderboard, mapping, player, entry, integration]) + await (global.em).persistAndFlush([leaderboard, mapping, player, entry, integration]) const getLeaderboardsMock = vi.fn((): [number, GetLeaderboardsForGameResponse] => [200, { response: { @@ -374,13 +363,13 @@ describe('Steamworks integration - sync leaderboards', () => { }]) axiosMock.onPost('https://partner.steam-api.com/ISteamLeaderboards/SetLeaderboardScore/v1').replyOnce(createMock) - await syncSteamworksLeaderboards(em, integration) + await syncSteamworksLeaderboards((global.em), integration) expect(getLeaderboardsMock).toHaveBeenCalledTimes(1) expect(getEntriesMock).toHaveBeenCalledTimes(1) expect(createMock).toHaveBeenCalledTimes(1) - const event = await em.getRepository(SteamworksIntegrationEvent).findOne({ integration }, { orderBy: { id: 'DESC' } }) + const event = await (global.em).getRepository(SteamworksIntegrationEvent).findOne({ integration }, { orderBy: { id: 'DESC' } }) expect(event.request).toStrictEqual({ url: 'https://partner.steam-api.com/ISteamLeaderboards/SetLeaderboardScore/v1', body: `appid=${config.appId}&leaderboardid=${mapping.steamworksLeaderboardId}&steamid=${player.aliases[0].identifier}&score=${entry.score}&scoremethod=KeepBest`, diff --git a/tests/lib/integrations/steamworksSyncStats.test.ts b/tests/lib/integrations/steamworksSyncStats.test.ts index 8c20be32..e3758d6a 100644 --- a/tests/lib/integrations/steamworksSyncStats.test.ts +++ b/tests/lib/integrations/steamworksSyncStats.test.ts @@ -1,5 +1,4 @@ -import { EntityManager, MikroORM } from '@mikro-orm/mysql' -import ormConfig from '../../../src/config/mikro-orm.config' +import { EntityManager } from '@mikro-orm/mysql' import { IntegrationType } from '../../../src/entities/integration' import { GetSchemaForGameResponse, GetUserStatsForGameResponse, syncSteamworksStats } from '../../../src/lib/integrations/steamworks-integration' import IntegrationConfigFactory from '../../fixtures/IntegrationConfigFactory' @@ -16,26 +15,16 @@ import PlayerGameStatFactory from '../../fixtures/PlayerGameStatFactory' import { randSlug, randText } from '@ngneat/falso' describe('Steamworks integration - sync stats', () => { - let em: EntityManager const axiosMock = new AxiosMockAdapter(axios) - beforeAll(async () => { - const orm = await MikroORM.init(ormConfig) - em = orm.em - }) - - afterAll(async () => { - await em.getConnection().close() - }) - it('should pull in stats from steamworks', async () => { - const [, game] = await createOrganisationAndGame(em) + const [, game] = await createOrganisationAndGame() const statDisplayName = randText() const config = await new IntegrationConfigFactory().one() const integration = await new IntegrationFactory().construct(IntegrationType.STEAMWORKS, game, config).one() - await em.persistAndFlush(integration) + await (global.em).persistAndFlush(integration) const getSchemaMock = vi.fn((): [number, GetSchemaForGameResponse] => [200, { game: { @@ -55,18 +44,18 @@ describe('Steamworks integration - sync stats', () => { }]) axiosMock.onGet(`https://partner.steam-api.com/ISteamUserStats/GetSchemaForGame/v2?appid=${integration.getConfig().appId}`).replyOnce(getSchemaMock) - await syncSteamworksStats(em, integration) + await syncSteamworksStats((global.em), integration) expect(getSchemaMock).toHaveBeenCalledTimes(1) - const event = await em.getRepository(SteamworksIntegrationEvent).findOne({ integration }) + const event = await (global.em).getRepository(SteamworksIntegrationEvent).findOne({ integration }) expect(event.request).toStrictEqual({ url: `https://partner.steam-api.com/ISteamUserStats/GetSchemaForGame/v2?appid=${integration.getConfig().appId}`, body: '', method: 'GET' }) - const createdStat = await em.getRepository(GameStat).findOne({ + const createdStat = await (global.em).getRepository(GameStat).findOne({ game: integration.game, name: statDisplayName, globalValue: 500, @@ -79,7 +68,7 @@ describe('Steamworks integration - sync stats', () => { }) it('should update existing stats with the name and default value from steamworks', async () => { - const [, game] = await createOrganisationAndGame(em) + const [, game] = await createOrganisationAndGame() const statName = 'stat_' + randSlug() const statDisplayName = randText() @@ -89,7 +78,7 @@ describe('Steamworks integration - sync stats', () => { const config = await new IntegrationConfigFactory().one() const integration = await new IntegrationFactory().construct(IntegrationType.STEAMWORKS, game, config).one() - await em.persistAndFlush([stat, integration]) + await (global.em).persistAndFlush([stat, integration]) const getSchemaMock = vi.fn((): [number, GetSchemaForGameResponse] => [200, { game: { @@ -109,17 +98,17 @@ describe('Steamworks integration - sync stats', () => { }]) axiosMock.onGet(`https://partner.steam-api.com/ISteamUserStats/GetSchemaForGame/v2?appid=${integration.getConfig().appId}`).replyOnce(getSchemaMock) - await syncSteamworksStats(em, integration) + await syncSteamworksStats((global.em), integration) expect(getSchemaMock).toHaveBeenCalledTimes(1) - await em.refresh(stat) + await (global.em).refresh(stat) expect(stat.name).toBe(statDisplayName) expect(stat.defaultValue).toBe(500) }) it('should pull in player stats from steamworks', async () => { - const [, game] = await createOrganisationAndGame(em) + const [, game] = await createOrganisationAndGame() const statName = 'stat_' + randSlug() @@ -128,7 +117,7 @@ describe('Steamworks integration - sync stats', () => { const config = await new IntegrationConfigFactory().one() const integration = await new IntegrationFactory().construct(IntegrationType.STEAMWORKS, game, config).one() - await em.persistAndFlush([player, integration]) + await (global.em).persistAndFlush([player, integration]) const getSchemaMock = vi.fn((): [number, GetSchemaForGameResponse] => [200, { game: { @@ -161,24 +150,24 @@ describe('Steamworks integration - sync stats', () => { }]) axiosMock.onGet(`https://partner.steam-api.com/ISteamUserStats/GetUserStatsForGame/v2?appid=${integration.getConfig().appId}&steamid=${player.aliases[0].identifier}`).replyOnce(getUserStatsMock) - await syncSteamworksStats(em, integration) + await syncSteamworksStats((global.em), integration) expect(getSchemaMock).toHaveBeenCalledTimes(1) expect(getUserStatsMock).toHaveBeenCalledTimes(1) - const playerStat = await em.getRepository(PlayerGameStat).findOne({ value: 301 }) + const playerStat = await (global.em).getRepository(PlayerGameStat).findOne({ value: 301 }) expect(playerStat).toBeTruthy() }) it('should not pull in player stats for players that do not exist in steamworks', async () => { - const [, game] = await createOrganisationAndGame(em) + const [, game] = await createOrganisationAndGame() const player = await new PlayerFactory([game]).withSteamAlias().one() const config = await new IntegrationConfigFactory().one() const integration = await new IntegrationFactory().construct(IntegrationType.STEAMWORKS, game, config).one() - await em.persistAndFlush([player, integration]) + await (global.em).persistAndFlush([player, integration]) const getSchemaMock = vi.fn((): [number, GetSchemaForGameResponse] => [200, { game: { @@ -201,17 +190,17 @@ describe('Steamworks integration - sync stats', () => { const getUserStatsMock = vi.fn((): [number] => [400]) axiosMock.onGet(`https://partner.steam-api.com/ISteamUserStats/GetUserStatsForGame/v2?appid=${integration.getConfig().appId}&steamid=${player.aliases[0].identifier}`).replyOnce(getUserStatsMock) - await syncSteamworksStats(em, integration) + await syncSteamworksStats((global.em), integration) expect(getSchemaMock).toHaveBeenCalledTimes(1) expect(getUserStatsMock).toHaveBeenCalledTimes(1) - const playerStat = await em.getRepository(PlayerGameStat).findOne({ player }) + const playerStat = await (global.em).getRepository(PlayerGameStat).findOne({ player }) expect(playerStat).toBeNull() }) it('should update player stats with the ones from steamworks', async () => { - const [, game] = await createOrganisationAndGame(em) + const [, game] = await createOrganisationAndGame() const statName = 'stat_' + randSlug() @@ -222,7 +211,7 @@ describe('Steamworks integration - sync stats', () => { const config = await new IntegrationConfigFactory().one() const integration = await new IntegrationFactory().construct(IntegrationType.STEAMWORKS, game, config).one() - await em.persistAndFlush([player, playerStat, integration]) + await (global.em).persistAndFlush([player, playerStat, integration]) const getSchemaMock = vi.fn((): [number, GetSchemaForGameResponse] => [200, { game: { @@ -255,7 +244,7 @@ describe('Steamworks integration - sync stats', () => { }]) axiosMock.onGet(`https://partner.steam-api.com/ISteamUserStats/GetUserStatsForGame/v2?appid=${integration.getConfig().appId}&steamid=${player.aliases[0].identifier}`).replyOnce(getUserStatsMock) - await syncSteamworksStats(em, integration) + await syncSteamworksStats((global.em), integration) expect(getSchemaMock).toHaveBeenCalledTimes(1) expect(getUserStatsMock).toHaveBeenCalledTimes(1) @@ -264,7 +253,7 @@ describe('Steamworks integration - sync stats', () => { }) it('should push through player stats that only exist in talo', async () => { - const [, game] = await createOrganisationAndGame(em) + const [, game] = await createOrganisationAndGame() const statName = 'stat_' + randSlug() @@ -275,7 +264,7 @@ describe('Steamworks integration - sync stats', () => { const config = await new IntegrationConfigFactory().one() const integration = await new IntegrationFactory().construct(IntegrationType.STEAMWORKS, game, config).one() - await em.persistAndFlush([player, playerStat, integration]) + await (global.em).persistAndFlush([player, playerStat, integration]) const getSchemaMock = vi.fn((): [number, GetSchemaForGameResponse] => [200, { game: { @@ -312,13 +301,13 @@ describe('Steamworks integration - sync stats', () => { }]) axiosMock.onPost('https://partner.steam-api.com/ISteamUserStats/SetUserStatsForGame/v1').replyOnce(setMock) - await syncSteamworksStats(em, integration) + await syncSteamworksStats((global.em), integration) expect(getSchemaMock).toHaveBeenCalledTimes(1) expect(getUserStatsMock).toHaveBeenCalledTimes(1) expect(setMock).toHaveBeenCalledTimes(1) - const event = await em.getRepository(SteamworksIntegrationEvent).findOne({ integration }, { orderBy: { id: 'DESC' } }) + const event = await (global.em).getRepository(SteamworksIntegrationEvent).findOne({ integration }, { orderBy: { id: 'DESC' } }) expect(event.request).toStrictEqual({ url: 'https://partner.steam-api.com/ISteamUserStats/SetUserStatsForGame/v1', body: `appid=${config.appId}&steamid=${player.aliases[0].identifier}&count=1&name%5B0%5D=${stat.internalName}&value%5B0%5D=${playerStat.value}`, From f2cef83687f6037138c2530606f91f1b0e106249 Mon Sep 17 00:00:00 2001 From: tudor <7089284+tudddorrr@users.noreply.github.com> Date: Wed, 4 Dec 2024 07:44:57 +0000 Subject: [PATCH 03/15] player identify basics --- package-lock.json | 11 ++- package.json | 3 +- src/config/providers.ts | 2 + src/config/socket-routes.ts | 19 ---- src/entities/player-alias.ts | 8 ++ src/index.ts | 6 +- src/lib/auth/getAPIKeyFromToken.ts | 21 ++++ src/middlewares/api-key-middleware.ts | 28 ++---- src/services/api/player-api.service.ts | 6 +- src/services/api/player-auth-api.service.ts | 16 +++- src/socket/authenticateSocket.ts | 28 ++++++ src/socket/index.ts | 83 ++++++++++++++++ src/socket/listeners/playerListeners.ts | 44 +++++++++ src/socket/router/socketRouter.ts | 101 ++++++++++++++++++++ src/socket/router/socketRoutes.ts | 20 ++++ src/socket/socketConnection.ts | 10 ++ src/socket/socketMessage.ts | 39 ++++++++ 17 files changed, 394 insertions(+), 51 deletions(-) delete mode 100644 src/config/socket-routes.ts create mode 100644 src/lib/auth/getAPIKeyFromToken.ts create mode 100644 src/socket/authenticateSocket.ts create mode 100644 src/socket/index.ts create mode 100644 src/socket/listeners/playerListeners.ts create mode 100644 src/socket/router/socketRouter.ts create mode 100644 src/socket/router/socketRoutes.ts create mode 100644 src/socket/socketConnection.ts create mode 100644 src/socket/socketMessage.ts diff --git a/package-lock.json b/package-lock.json index 3cf7e5e6..c325d382 100644 --- a/package-lock.json +++ b/package-lock.json @@ -42,7 +42,8 @@ "qs": "^6.11.0", "stripe": "^12.0.0", "uuid": "^9.0.0", - "ws": "^8.18.0" + "ws": "^8.18.0", + "zod": "^3.23.8" }, "devDependencies": { "@mikro-orm/cli": "^6.4.1", @@ -8937,6 +8938,14 @@ "funding": { "url": "https://github.com/sponsors/sindresorhus" } + }, + "node_modules/zod": { + "version": "3.23.8", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.23.8.tgz", + "integrity": "sha512-XBx9AXhXktjUqnepgTiE5flcKIYWi/rme0Eaj+5Y0lftuGBq+jyRu/md4WnuxqgP1ubdpNCsYEYPxrzVHD8d6g==", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } } } } diff --git a/package.json b/package.json index c32927d5..fc8c10e2 100644 --- a/package.json +++ b/package.json @@ -80,7 +80,8 @@ "qs": "^6.11.0", "stripe": "^12.0.0", "uuid": "^9.0.0", - "ws": "^8.18.0" + "ws": "^8.18.0", + "zod": "^3.23.8" }, "mikro-orm": { "configPaths": [ diff --git a/src/config/providers.ts b/src/config/providers.ts index 188942b0..d46d0a15 100644 --- a/src/config/providers.ts +++ b/src/config/providers.ts @@ -4,6 +4,7 @@ import * as Sentry from '@sentry/node' import ormConfig from './mikro-orm.config' import { MikroORM } from '@mikro-orm/mysql' import tracingMiddleware from '../middlewares/tracing-middleware' +import createEmailQueue from '../lib/queues/createEmailQueue' export default async function initProviders(app: Koa, isTest: boolean) { try { @@ -20,6 +21,7 @@ export default async function initProviders(app: Koa, isTest: boolean) { } SendGrid.setApiKey(process.env.SENDGRID_KEY) + app.context.emailQueue = createEmailQueue() Sentry.init({ dsn: process.env.SENTRY_DSN, diff --git a/src/config/socket-routes.ts b/src/config/socket-routes.ts deleted file mode 100644 index 55f7b1a4..00000000 --- a/src/config/socket-routes.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { Server } from 'http' -import { WebSocketServer } from 'ws' - -export default function configureSocketRoutes(server: Server) { - const wss = new WebSocketServer({ server }) - - wss.on('connection', (ws, req) => { - ws.on('error', console.error) - - console.log(req.headers) - - ws.on('message', function message(data) { - console.log('Received:', data) - ws.send('Thanks') - }) - - ws.send('Hello') - }) -} diff --git a/src/entities/player-alias.ts b/src/entities/player-alias.ts index 4489677a..a46ff83c 100644 --- a/src/entities/player-alias.ts +++ b/src/entities/player-alias.ts @@ -1,5 +1,7 @@ import { Cascade, Entity, Filter, ManyToOne, PrimaryKey, Property } from '@mikro-orm/mysql' import Player from './player' +import Redis from 'ioredis' +import { v4 } from 'uuid' export enum PlayerAliasService { STEAM = 'steam', @@ -37,6 +39,12 @@ export default class PlayerAlias { @Property({ onUpdate: () => new Date() }) updatedAt: Date = new Date() + async createSocketToken(redis: Redis): Promise { + const token = v4() + await redis.set(`socketTokens.${this.id}`, token) + return token + } + toJSON() { const player = { ...this.player.toJSON() } delete player.aliases diff --git a/src/index.ts b/src/index.ts index a983155e..e2e3b2be 100644 --- a/src/index.ts +++ b/src/index.ts @@ -9,12 +9,11 @@ import configureAPIRoutes from './config/api-routes' import corsMiddleware from './middlewares/cors-middleware' import errorMiddleware from './middlewares/error-middleware' import initProviders from './config/providers' -import createEmailQueue from './lib/queues/createEmailQueue' import devDataMiddleware from './middlewares/dev-data-middleware' import cleanupMiddleware from './middlewares/cleanup-middleware' import requestContextMiddleware from './middlewares/request-context-middleware' import { createServer } from 'http' -import configureSocketRoutes from './config/socket-routes' +import Socket from './socket' const isTest = process.env.NODE_ENV === 'test' @@ -30,7 +29,6 @@ export default async function init(): Promise { app.use(corsMiddleware) app.use(requestContextMiddleware) app.use(devDataMiddleware) - app.context.emailQueue = createEmailQueue() configureProtectedRoutes(app) configurePublicRoutes(app) @@ -39,7 +37,7 @@ export default async function init(): Promise { app.use(cleanupMiddleware) const server = createServer(app.callback()) - configureSocketRoutes(server) + app.context.wss = new Socket(server, app.context.em) if (!isTest) { server.listen(80, () => console.info('Listening on port 80')) diff --git a/src/lib/auth/getAPIKeyFromToken.ts b/src/lib/auth/getAPIKeyFromToken.ts new file mode 100644 index 00000000..9e98965a --- /dev/null +++ b/src/lib/auth/getAPIKeyFromToken.ts @@ -0,0 +1,21 @@ +import { RequestContext } from '@mikro-orm/mysql' +import APIKey from '../../entities/api-key' +import jwt from 'jsonwebtoken' + +export default async function getAPIKeyFromToken(authHeader: string): Promise { + const parts = authHeader.split('Bearer ') + if (parts.length === 2) { + const em = RequestContext.getEntityManager() + const decodedToken = jwt.decode(parts[1]) + + if (decodedToken) { + const apiKey = await em.getRepository(APIKey).findOne(decodedToken.sub, { + populate: ['game', 'game.apiSecret'] + }) + + return apiKey + } + } + + return null +} diff --git a/src/middlewares/api-key-middleware.ts b/src/middlewares/api-key-middleware.ts index 811acefc..2020ca96 100644 --- a/src/middlewares/api-key-middleware.ts +++ b/src/middlewares/api-key-middleware.ts @@ -1,30 +1,18 @@ import { Context, Next } from 'koa' -import jwt from 'jsonwebtoken' import { isAPIRoute } from './route-middleware' +import getAPIKeyFromToken from '../lib/auth/getAPIKeyFromToken' import { EntityManager } from '@mikro-orm/mysql' -import APIKey from '../entities/api-key' export default async function apiKeyMiddleware(ctx: Context, next: Next): Promise { if (isAPIRoute(ctx)) { - const parts = (ctx.headers?.authorization ?? '').split('Bearer ') - if (parts.length === 2) { - const em: EntityManager = ctx.em - const decodedToken = jwt.decode(parts[1]) + const apiKey = await getAPIKeyFromToken(ctx.headers?.authorization ?? '') + if (apiKey) { + ctx.state.key = apiKey + ctx.state.secret = apiKey.game.apiSecret.getPlainSecret() + ctx.state.game = apiKey.game - if (decodedToken) { - const apiKey = await em.getRepository(APIKey).findOne(decodedToken.sub, { - populate: ['game', 'game.apiSecret'] - }) - - if (apiKey) { - ctx.state.key = apiKey - ctx.state.secret = apiKey.game.apiSecret.getPlainSecret() - ctx.state.game = apiKey.game - - if (!apiKey.revokedAt) apiKey.lastUsedAt = new Date() - await em.flush() - } - } + if (!apiKey.revokedAt) apiKey.lastUsedAt = new Date() + await (ctx.em).flush() } } diff --git a/src/services/api/player-api.service.ts b/src/services/api/player-api.service.ts index 6d965e74..f61a7306 100644 --- a/src/services/api/player-api.service.ts +++ b/src/services/api/player-api.service.ts @@ -14,6 +14,7 @@ import checkScope from '../../policies/checkScope' import Integration, { IntegrationType } from '../../entities/integration' import { validateAuthSessionToken } from '../../middlewares/player-auth-middleware' import { setCurrentPlayerState } from '../../middlewares/current-player-middleware' +import { createRedisConnection } from '../../config/redis.config' async function getRealIdentifier( req: Request, @@ -145,10 +146,13 @@ export default class PlayerAPIService extends APIService { alias.lastSeenAt = alias.player.lastSeenAt = new Date() await em.flush() + const socketToken = await alias.createSocketToken(createRedisConnection(req.ctx)) + return { status: 200, body: { - alias + alias, + socketToken } } } diff --git a/src/services/api/player-auth-api.service.ts b/src/services/api/player-auth-api.service.ts index 79527efc..413646fe 100644 --- a/src/services/api/player-auth-api.service.ts +++ b/src/services/api/player-auth-api.service.ts @@ -125,6 +125,7 @@ export default class PlayerAuthAPIService extends APIService { em.persist(alias.player.auth) const sessionToken = await alias.player.auth.createSession(alias) + const socketToken = await alias.createSocketToken(createRedisConnection(req.ctx)) createPlayerAuthActivity(req, alias.player, { type: PlayerAuthActivityType.REGISTERED, @@ -139,7 +140,8 @@ export default class PlayerAuthAPIService extends APIService { status: 200, body: { alias, - sessionToken + sessionToken, + socketToken } } } @@ -172,9 +174,9 @@ export default class PlayerAuthAPIService extends APIService { const passwordMatches = await bcrypt.compare(password, alias.player.auth.password) if (!passwordMatches) this.handleFailedLogin(req) - if (alias.player.auth.verificationEnabled) { - const redis = createRedisConnection(req.ctx) + const redis = createRedisConnection(req.ctx) + if (alias.player.auth.verificationEnabled) { await em.populate(alias.player, ['game']) const code = generateSixDigitCode() @@ -196,6 +198,7 @@ export default class PlayerAuthAPIService extends APIService { } } else { const sessionToken = await alias.player.auth.createSession(alias) + const socketToken = await alias.createSocketToken(redis) createPlayerAuthActivity(req, alias.player, { type: PlayerAuthActivityType.LOGGED_IN @@ -207,7 +210,8 @@ export default class PlayerAuthAPIService extends APIService { status: 200, body: { alias, - sessionToken + sessionToken, + socketToken } } } @@ -252,6 +256,7 @@ export default class PlayerAuthAPIService extends APIService { await redis.del(this.getRedisAuthKey(key, alias)) const sessionToken = await alias.player.auth.createSession(alias) + const socketToken = await alias.createSocketToken(redis) createPlayerAuthActivity(req, alias.player, { type: PlayerAuthActivityType.LOGGED_IN @@ -263,7 +268,8 @@ export default class PlayerAuthAPIService extends APIService { status: 200, body: { alias, - sessionToken + sessionToken, + socketToken } } } diff --git a/src/socket/authenticateSocket.ts b/src/socket/authenticateSocket.ts new file mode 100644 index 00000000..67b3523e --- /dev/null +++ b/src/socket/authenticateSocket.ts @@ -0,0 +1,28 @@ +import { WebSocket } from 'ws' +import getAPIKeyFromToken from '../lib/auth/getAPIKeyFromToken' +import { promisify } from 'util' +import jwt from 'jsonwebtoken' +import { RequestContext } from '@mikro-orm/core' +import APIKey from '../entities/api-key' + +export default async function authenticateSocket(authHeader: string, ws: WebSocket): Promise { + const apiKey = await getAPIKeyFromToken(authHeader) + if (!apiKey || apiKey.revokedAt) { + ws.close(3000) + return + } + + apiKey.lastUsedAt = new Date() + await RequestContext.getEntityManager().flush() + + try { + const token = authHeader.split('Bearer ')[1] + const secret = apiKey.game.apiSecret.getPlainSecret() + await promisify(jwt.verify)(token, secret) + } catch (err) { + ws.close(3000) + return + } + + return apiKey +} diff --git a/src/socket/index.ts b/src/socket/index.ts new file mode 100644 index 00000000..d5cff6a3 --- /dev/null +++ b/src/socket/index.ts @@ -0,0 +1,83 @@ +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 './socketMessage' + +export default class Socket { + private readonly wss: WebSocketServer + private connections: SocketConnection[] = [] + private router: SocketRouter + + constructor(server: Server, private readonly em: EntityManager) { + this.wss = new WebSocketServer({ server }) + this.wss.on('connection', async (ws, req) => { + await this.handleConnection(ws, req) + + ws.on('message', (data) => this.handleMessage(ws, data)) + ws.on('pong', () => this.handlePong(ws)) + ws.on('close', () => this.handleClose(ws)) + ws.on('error', captureException) + }) + + this.router = new SocketRouter(this) + + this.heartbeat() + } + + heartbeat(): void { + const interval = setInterval(() => { + this.connections.forEach((conn) => { + if (!conn.alive) { + conn.ws.terminate() + return + } + + conn.alive = false + conn.ws.ping() + }) + }, 30_000) + + this.wss.on('close', () => { + clearInterval(interval) + }) + } + + async handleConnection(ws: WebSocket, req: IncomingMessage): Promise { + await RequestContext.create(this.em, async () => { + const key = await authenticateSocket(req.headers?.authorization ?? '', ws) + this.connections.push(new SocketConnection(ws, key.game)) + sendMessage(this.connections.at(-1), 'v1.connected', {}) + }) + } + + async handleMessage(ws: WebSocket, data: RawData): Promise { + await RequestContext.create(this.em, async () => { + await this.router.handleMessage(this.findConnection(ws), data) + }) + } + + handlePong(ws: WebSocket): void { + const connection = this.findConnection(ws) + if (!connection) return + + connection.alive = true + } + + handleClose(ws: WebSocket): void { + this.connections = this.connections.filter((conn) => conn.ws !== ws) + } + + findConnection(ws: WebSocket): SocketConnection | undefined { + const connection = this.connections.find((conn) => conn.ws === ws) + if (!connection) { + ws.close(3000) + return + } + + return connection + } +} diff --git a/src/socket/listeners/playerListeners.ts b/src/socket/listeners/playerListeners.ts new file mode 100644 index 00000000..80cb376e --- /dev/null +++ b/src/socket/listeners/playerListeners.ts @@ -0,0 +1,44 @@ +import { z, ZodType } from 'zod' +import { createListener } from '../router/socketRouter' +import { sendMessage } from '../socketMessage' +import Redis from 'ioredis' +import redisConfig from '../../config/redis.config' +import { RequestContext } from '@mikro-orm/core' +import PlayerAlias from '../../entities/player-alias' +import { SocketMessageListener } from '../router/socketRoutes' + +const playerListeners: SocketMessageListener[] = [ + createListener( + 'v1.players.identify', + z.object({ + playerAliasId: z.number(), + token: z.string() + }), + async (conn, data) => { + const redis = new Redis(redisConfig) + const token = await redis.get(`socketTokens.${data.playerAliasId}`) + + if (token === data.token) { + conn.playerAlias = await (RequestContext.getEntityManager()) + .getRepository(PlayerAlias) + .findOne({ + id: data.playerAliasId, + player: { + game: conn.game + } + }) + + sendMessage(conn, 'v1.players.identify.success', conn.playerAlias) + } else { + sendMessage(conn, 'v1.players.identify.error', { + reason: 'Invalid token' + }) + } + + await redis.quit() + }, + false + ) +] + +export default playerListeners diff --git a/src/socket/router/socketRouter.ts b/src/socket/router/socketRouter.ts new file mode 100644 index 00000000..1fd66404 --- /dev/null +++ b/src/socket/router/socketRouter.ts @@ -0,0 +1,101 @@ +import { z, ZodError, ZodType } from 'zod' +import Socket from '..' +import { SocketMessageRequest, requests, sendError } from '../socketMessage' +import SocketConnection from '../socketConnection' +import { RawData } from 'ws' +import { addBreadcrumb } from '@sentry/node' +import routes, { SocketMessageListener, SocketMessageListenerHandler } from './socketRoutes' +import { pick } from 'lodash' + +export function createListener( + req: SocketMessageRequest, + validator: T, + handler: SocketMessageListenerHandler>, + requirePlayer = true +): SocketMessageListener { + return { + req, + validator, + handler, + requirePlayer + } +} + +const socketMessageValidator = z.object({ + req: z.enum(requests), + data: z.object({}).passthrough() +}) + +type SocketMessage = z.infer + +export default class SocketRouter { + constructor(readonly socket: Socket) {} + + async handleMessage(conn: SocketConnection, rawData: RawData): Promise { + addBreadcrumb({ + category: 'message', + message: rawData.toString(), + level: 'info' + }) + + let message: SocketMessage = null + + try { + message = await this.getParsedMessage(rawData) + + const handled = await this.routeMessage(conn, message) + if (!handled) { + sendError(conn, message.req, new Error('Unhandled request')) + } + } catch (err) { + if (err instanceof ZodError) { + sendError(conn, 'unknown', new Error('Invalid message', { cause: this.sanitiseZodError(err) })) + } else { + sendError(conn, message.req, new Error('Routing error', { cause: err })) + } + } + } + + async getParsedMessage(rawData: RawData): Promise { + return await socketMessageValidator.parseAsync(JSON.parse(rawData.toString())) + } + + async routeMessage(conn: SocketConnection, message: SocketMessage): Promise { + let handled = false + + for (const route of routes) { + for await (const listener of route) { + if (listener.req === message.req) { + try { + handled = true + + if (listener.requirePlayer && !conn.playerAlias) { + sendError(conn, message.req, new Error('No player found')) + } else { + const data = await listener.validator.parseAsync(message.data) + listener.handler(conn, data, this.socket) + } + + break + } catch (err) { + if (err instanceof ZodError) { + sendError(conn, message.req, new Error('Invalid message data', { cause: this.sanitiseZodError(err) })) + } else { + sendError(conn, message.req, new Error('Listener error', { cause: err })) + } + } + } + } + } + + return handled + } + + sanitiseZodError(err: ZodError) { + return { + issues: err.issues.map((issue) => { + return pick(issue, ['received', 'code', 'options', 'path']) + }) + } + } +} diff --git a/src/socket/router/socketRoutes.ts b/src/socket/router/socketRoutes.ts new file mode 100644 index 00000000..3a930e6e --- /dev/null +++ b/src/socket/router/socketRoutes.ts @@ -0,0 +1,20 @@ +import { z, ZodType } from 'zod' +import { SocketMessageRequest } from '../socketMessage' +import SocketConnection from '../socketConnection' +import Socket from '..' +import playerListeners from '../listeners/playerListeners' + +export type SocketMessageListenerHandler = (conn: SocketConnection, data: T, socket: Socket) => void | Promise + +export type SocketMessageListener = { + req: SocketMessageRequest + validator: T + handler: SocketMessageListenerHandler> + requirePlayer: boolean +} + +const routes: SocketMessageListener[][] = [ + playerListeners +] + +export default routes diff --git a/src/socket/socketConnection.ts b/src/socket/socketConnection.ts new file mode 100644 index 00000000..0da36de9 --- /dev/null +++ b/src/socket/socketConnection.ts @@ -0,0 +1,10 @@ +import { WebSocket } from 'ws' +import PlayerAlias from '../entities/player-alias' +import Game from '../entities/game' + +export default class SocketConnection { + playerAlias: PlayerAlias | null = null + alive: boolean = true + + constructor(readonly ws: WebSocket, readonly game: Game) {} +} diff --git a/src/socket/socketMessage.ts b/src/socket/socketMessage.ts new file mode 100644 index 00000000..d751392e --- /dev/null +++ b/src/socket/socketMessage.ts @@ -0,0 +1,39 @@ +import { captureException, setTag } from '@sentry/node' +import SocketConnection from './socketConnection' + +export const requests = [ + 'v1.players.identify' +] as const + +export type SocketMessageRequest = typeof requests[number] + +export const responses = [ + 'v1.connected', + 'v1.error', + 'v1.players.identify.success', + 'v1.players.identify.error' +] as const + +export type SocketMessageResponse = typeof responses[number] + +export function sendMessage(connection: SocketConnection, res: SocketMessageResponse, data: T) { + connection.ws.send(JSON.stringify({ + res, + data + })) +} + +export function sendMessages(connections: SocketConnection[], type: SocketMessageResponse, data: T) { + connections.forEach((ws) => sendMessage(ws, type, data)) +} + +export function sendError(connection: SocketConnection, req: SocketMessageRequest | 'unknown', error: Error) { + setTag('request', req) + captureException(error) + + sendMessage(connection, 'v1.error', { + req, + message: error.message, + cause: error.cause + }) +} From b7e44dd0faad46d6ce2b85f870fead4d387400d2 Mon Sep 17 00:00:00 2001 From: tudor <7089284+tudddorrr@users.noreply.github.com> Date: Wed, 4 Dec 2024 08:11:44 +0000 Subject: [PATCH 04/15] unified error handling --- src/socket/index.ts | 2 +- src/socket/listeners/playerListeners.ts | 9 +++--- src/socket/messages/socketError.ts | 31 ++++++++++++++++++++ src/socket/messages/socketMessage.ts | 26 +++++++++++++++++ src/socket/router/socketRouter.ts | 17 ++++++----- src/socket/router/socketRoutes.ts | 11 +++++-- src/socket/socketMessage.ts | 39 ------------------------- 7 files changed, 80 insertions(+), 55 deletions(-) create mode 100644 src/socket/messages/socketError.ts create mode 100644 src/socket/messages/socketMessage.ts delete mode 100644 src/socket/socketMessage.ts diff --git a/src/socket/index.ts b/src/socket/index.ts index d5cff6a3..7ebf8b0a 100644 --- a/src/socket/index.ts +++ b/src/socket/index.ts @@ -5,7 +5,7 @@ import { EntityManager, RequestContext } from '@mikro-orm/mysql' import authenticateSocket from './authenticateSocket' import SocketConnection from './socketConnection' import SocketRouter from './router/socketRouter' -import { sendMessage } from './socketMessage' +import { sendMessage } from './messages/socketMessage' export default class Socket { private readonly wss: WebSocketServer diff --git a/src/socket/listeners/playerListeners.ts b/src/socket/listeners/playerListeners.ts index 80cb376e..aeff9598 100644 --- a/src/socket/listeners/playerListeners.ts +++ b/src/socket/listeners/playerListeners.ts @@ -1,11 +1,12 @@ import { z, ZodType } from 'zod' import { createListener } from '../router/socketRouter' -import { sendMessage } from '../socketMessage' +import { sendMessage } from '../messages/socketMessage' import Redis from 'ioredis' import redisConfig from '../../config/redis.config' import { RequestContext } from '@mikro-orm/core' import PlayerAlias from '../../entities/player-alias' import { SocketMessageListener } from '../router/socketRoutes' +import SocketError, { sendError } from '../messages/socketError' const playerListeners: SocketMessageListener[] = [ createListener( @@ -14,7 +15,7 @@ const playerListeners: SocketMessageListener[] = [ playerAliasId: z.number(), token: z.string() }), - async (conn, data) => { + async ({ conn, req, data }) => { const redis = new Redis(redisConfig) const token = await redis.get(`socketTokens.${data.playerAliasId}`) @@ -30,9 +31,7 @@ const playerListeners: SocketMessageListener[] = [ sendMessage(conn, 'v1.players.identify.success', conn.playerAlias) } else { - sendMessage(conn, 'v1.players.identify.error', { - reason: 'Invalid token' - }) + sendError(conn, req, new SocketError('INVALID_SOCKET_TOKEN', 'Invalid socket token')) } await redis.quit() diff --git a/src/socket/messages/socketError.ts b/src/socket/messages/socketError.ts new file mode 100644 index 00000000..946618f8 --- /dev/null +++ b/src/socket/messages/socketError.ts @@ -0,0 +1,31 @@ +import { captureException, setTag } from '@sentry/node' +import { sendMessage, SocketMessageRequest } from './socketMessage' +import SocketConnection from '../socketConnection' + +const codes = [ + 'INVALID_MESSAGE', + 'INVALID_MESSAGE_DATA', + 'NO_PLAYER_FOUND', + 'UNHANDLED_REQUEST', + 'ROUTING_ERROR', + 'LISTENER_ERROR', + 'INVALID_SOCKET_TOKEN' +] as const + +export type SocketErrorCode = typeof codes[number] + +export default class SocketError { + constructor(public code: SocketErrorCode, public message: string) {} +} + +export function sendError(conn: SocketConnection, req: SocketMessageRequest | 'unknown', error: SocketError) { + setTag('request', req) + setTag('errorCode', error.code) + captureException(error) + + sendMessage(conn, 'v1.error', { + req, + message: error.message, + errorCode: error.code + }) +} diff --git a/src/socket/messages/socketMessage.ts b/src/socket/messages/socketMessage.ts new file mode 100644 index 00000000..ef20a1ed --- /dev/null +++ b/src/socket/messages/socketMessage.ts @@ -0,0 +1,26 @@ +import SocketConnection from '../socketConnection' + +export const requests = [ + 'v1.players.identify' +] as const + +export type SocketMessageRequest = typeof requests[number] + +export const responses = [ + 'v1.connected', + 'v1.error', + 'v1.players.identify.success' +] as const + +export type SocketMessageResponse = typeof responses[number] + +export function sendMessage(conn: SocketConnection, res: SocketMessageResponse, data: T) { + conn.ws.send(JSON.stringify({ + res, + data + })) +} + +export function sendMessages(conns: SocketConnection[], type: SocketMessageResponse, data: T) { + conns.forEach((ws) => sendMessage(ws, type, data)) +} diff --git a/src/socket/router/socketRouter.ts b/src/socket/router/socketRouter.ts index 1fd66404..a6c1a3a8 100644 --- a/src/socket/router/socketRouter.ts +++ b/src/socket/router/socketRouter.ts @@ -1,11 +1,12 @@ import { z, ZodError, ZodType } from 'zod' import Socket from '..' -import { SocketMessageRequest, requests, sendError } from '../socketMessage' +import { SocketMessageRequest, requests } from '../messages/socketMessage' import SocketConnection from '../socketConnection' import { RawData } from 'ws' import { addBreadcrumb } from '@sentry/node' import routes, { SocketMessageListener, SocketMessageListenerHandler } from './socketRoutes' import { pick } from 'lodash' +import SocketError, { sendError } from '../messages/socketError' export function createListener( req: SocketMessageRequest, @@ -45,13 +46,13 @@ export default class SocketRouter { const handled = await this.routeMessage(conn, message) if (!handled) { - sendError(conn, message.req, new Error('Unhandled request')) + sendError(conn, message.req, new SocketError('UNHANDLED_REQUEST', 'Request not handled')) } } catch (err) { if (err instanceof ZodError) { - sendError(conn, 'unknown', new Error('Invalid message', { cause: this.sanitiseZodError(err) })) + sendError(conn, 'unknown', new SocketError('INVALID_MESSAGE', 'Invalid message request')) } else { - sendError(conn, message.req, new Error('Routing error', { cause: err })) + sendError(conn, message.req, new SocketError('ROUTING_ERROR', 'An error occurred while routing the message')) } } } @@ -70,18 +71,18 @@ export default class SocketRouter { handled = true if (listener.requirePlayer && !conn.playerAlias) { - sendError(conn, message.req, new Error('No player found')) + sendError(conn, message.req, new SocketError('NO_PLAYER_FOUND', 'No player found')) } else { const data = await listener.validator.parseAsync(message.data) - listener.handler(conn, data, this.socket) + listener.handler({ conn, req: listener.req, data, socket: this.socket }) } break } catch (err) { if (err instanceof ZodError) { - sendError(conn, message.req, new Error('Invalid message data', { cause: this.sanitiseZodError(err) })) + sendError(conn, message.req, new SocketError('INVALID_MESSAGE', 'Invalid message data for request')) } else { - sendError(conn, message.req, new Error('Listener error', { cause: err })) + sendError(conn, message.req, new SocketError('LISTENER_ERROR', 'An error occurred while processing the message')) } } } diff --git a/src/socket/router/socketRoutes.ts b/src/socket/router/socketRoutes.ts index 3a930e6e..7fa56bc3 100644 --- a/src/socket/router/socketRoutes.ts +++ b/src/socket/router/socketRoutes.ts @@ -1,10 +1,17 @@ import { z, ZodType } from 'zod' -import { SocketMessageRequest } from '../socketMessage' +import { SocketMessageRequest } from '../messages/socketMessage' import SocketConnection from '../socketConnection' import Socket from '..' import playerListeners from '../listeners/playerListeners' -export type SocketMessageListenerHandler = (conn: SocketConnection, data: T, socket: Socket) => void | Promise +type SocketMessageListenerHandlerParams = { + conn: SocketConnection + req: SocketMessageRequest + data: T + socket: Socket +} + +export type SocketMessageListenerHandler = (params: SocketMessageListenerHandlerParams) => void | Promise export type SocketMessageListener = { req: SocketMessageRequest diff --git a/src/socket/socketMessage.ts b/src/socket/socketMessage.ts deleted file mode 100644 index d751392e..00000000 --- a/src/socket/socketMessage.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { captureException, setTag } from '@sentry/node' -import SocketConnection from './socketConnection' - -export const requests = [ - 'v1.players.identify' -] as const - -export type SocketMessageRequest = typeof requests[number] - -export const responses = [ - 'v1.connected', - 'v1.error', - 'v1.players.identify.success', - 'v1.players.identify.error' -] as const - -export type SocketMessageResponse = typeof responses[number] - -export function sendMessage(connection: SocketConnection, res: SocketMessageResponse, data: T) { - connection.ws.send(JSON.stringify({ - res, - data - })) -} - -export function sendMessages(connections: SocketConnection[], type: SocketMessageResponse, data: T) { - connections.forEach((ws) => sendMessage(ws, type, data)) -} - -export function sendError(connection: SocketConnection, req: SocketMessageRequest | 'unknown', error: Error) { - setTag('request', req) - captureException(error) - - sendMessage(connection, 'v1.error', { - req, - message: error.message, - cause: error.cause - }) -} From 7c2f64f4efcd14a2d270253437878958240e9d05 Mon Sep 17 00:00:00 2001 From: tudor <7089284+tudddorrr@users.noreply.github.com> Date: Sat, 7 Dec 2024 00:57:23 +0000 Subject: [PATCH 05/15] channel basics --- _templates/service/new/service-test.ejs.t | 8 +- _templates/service/new/service.ejs.t | 5 +- src/config/api-routes.ts | 2 + src/config/protected-routes.ts | 2 + src/entities/api-key.ts | 2 + src/entities/game-channel.ts | 75 ++++++ src/entities/game-feedback-category.ts | 2 +- src/entities/game-stat.ts | 2 +- src/entities/index.ts | 2 + src/entities/leaderboard.ts | 2 +- src/entities/player-alias.ts | 6 +- src/migrations/.snapshot-gs_dev.json | 225 ++++++++++++++++++ .../20241206233511CreateGameChannelTables.ts | 33 +++ src/migrations/index.ts | 5 + src/policies/api/game-channel-api.policy.ts | 21 ++ src/policies/game-channel.policy.ts | 11 + src/policies/game-feedback.policy.ts | 2 +- src/services/api/game-channel-api.service.ts | 162 +++++++++++++ src/services/game-channel.service.ts | 20 ++ src/services/game-feedback.service.ts | 2 +- src/services/player.service.ts | 2 +- src/socket/index.ts | 12 +- src/socket/listeners/gameChannelListeners.ts | 40 ++++ src/socket/listeners/playerListeners.ts | 8 +- src/socket/messages/socketError.ts | 13 +- src/socket/messages/socketMessage.ts | 8 +- src/socket/router/socketRouter.ts | 21 +- src/socket/router/socketRoutes.ts | 10 +- src/socket/socketConnection.ts | 10 +- tests/fixtures/GameChannelFactory.ts | 23 ++ .../_api/game-channel-api/post.test.ts | 23 ++ tests/services/game-channel/index.test.ts | 13 + .../game-feedback/postCategory.test.ts | 2 +- tests/services/game-stat/post.test.ts | 2 +- tests/services/leaderboard/post.test.ts | 2 +- tests/services/player/post.test.ts | 2 +- 36 files changed, 733 insertions(+), 47 deletions(-) create mode 100644 src/entities/game-channel.ts create mode 100644 src/migrations/20241206233511CreateGameChannelTables.ts create mode 100644 src/policies/api/game-channel-api.policy.ts create mode 100644 src/policies/game-channel.policy.ts create mode 100644 src/services/api/game-channel-api.service.ts create mode 100644 src/services/game-channel.service.ts create mode 100644 src/socket/listeners/gameChannelListeners.ts create mode 100644 tests/fixtures/GameChannelFactory.ts create mode 100644 tests/services/_api/game-channel-api/post.test.ts create mode 100644 tests/services/game-channel/index.test.ts diff --git a/_templates/service/new/service-test.ejs.t b/_templates/service/new/service-test.ejs.t index 7344567c..3a00534e 100644 --- a/_templates/service/new/service-test.ejs.t +++ b/_templates/service/new/service-test.ejs.t @@ -1,15 +1,17 @@ --- -to: tests/services/<%= name %>/index.test.ts +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' -describe('<%= h.changeCase.sentenceCase(name) %> service - index', () => { +describe('<%= h.changeCase.sentenceCase(name) %> service - get', () => { it('should return a list of <%= h.changeCase.noCase(name) %>s', async () => { const [token] = await createUserAndToken() + const <%= name %> = await new <%= h.changeCase.pascal(name) %>Factory().one() await request(global.app) - .get('/<%= name %>s') + .get(`/<%= name %>/<%= name %>.id`) .auth(token, { type: 'bearer' }) .expect(200) }) diff --git a/_templates/service/new/service.ejs.t b/_templates/service/new/service.ejs.t index 0faef591..7796797a 100644 --- a/_templates/service/new/service.ejs.t +++ b/_templates/service/new/service.ejs.t @@ -7,12 +7,9 @@ import <%= h.changeCase.pascal(name) %> from '../entities/<%= name %>' import <%= h.changeCase.pascal(name) %>Policy from '../policies/<%= name %>.policy' export default class <%= h.changeCase.pascal(name) %>Service extends Service { - @Validate({ - query: ['<%= h.changeCase.camel(name) %>Id'] - }) @HasPermission(<%= h.changeCase.pascal(name) %>Policy, 'get') async get(req: Request): Promise { - const { <%= h.changeCase.camel(name) %>Id } = req.query + const { <%= h.changeCase.camel(name) %>Id } = req.params const em: EntityManager = req.ctx.em const <%= h.changeCase.camel(name) %> = await em.getRepository(<%= h.changeCase.pascal(name) %>).findOne(Number(<%= h.changeCase.camel(name) %>Id)) diff --git a/src/config/api-routes.ts b/src/config/api-routes.ts index 49644de3..c2923b2e 100644 --- a/src/config/api-routes.ts +++ b/src/config/api-routes.ts @@ -1,5 +1,6 @@ import Koa, { Context, Next } from 'koa' import { service } from 'koa-clay' +import GameChannelAPIService from '../services/api/game-channel-api.service' import HealthCheckAPIService from '../services/api/health-check-api.service' import GameFeedbackAPIService from '../services/api/game-feedback-api.service' import GameConfigAPIService from '../services/api/game-config-api.service' @@ -32,6 +33,7 @@ export default function configureAPIRoutes(app: Koa) { app.use(playerAuthMiddleware) app.use(continunityMiddleware) + app.use(service('/v1/game-channels', new GameChannelAPIService())) app.use(service('/v1/player-groups', new PlayerGroupAPIService())) app.use(service('/v1/health-check', new HealthCheckAPIService())) app.use(service('/v1/game-feedback', new GameFeedbackAPIService())) diff --git a/src/config/protected-routes.ts b/src/config/protected-routes.ts index 8cae23b7..90018cbd 100644 --- a/src/config/protected-routes.ts +++ b/src/config/protected-routes.ts @@ -1,5 +1,6 @@ import Koa, { Context, Next } from 'koa' import { service, ServiceOpts } from 'koa-clay' +import GameChannelService from '../services/game-channel.service' import GameFeedbackService from '../services/game-feedback.service' import PlayerGroupService from '../services/player-group.service' import OrganisationService from '../services/organisation.service' @@ -47,6 +48,7 @@ export default function protectedRoutes(app: Koa) { app.use(service('/games/:gameId/integrations', new IntegrationService(), serviceOpts)) app.use(service('/games/:gameId/player-groups', new PlayerGroupService(), serviceOpts)) app.use(service('/games/:gameId/game-feedback', new GameFeedbackService(), serviceOpts)) + app.use(service('/games/:gameId/game-channels', new GameChannelService(), serviceOpts)) app.use(service('/games', new GameService(), serviceOpts)) app.use(service('/users', new UserService(), serviceOpts)) } diff --git a/src/entities/api-key.ts b/src/entities/api-key.ts index 9856156c..4576d6a8 100644 --- a/src/entities/api-key.ts +++ b/src/entities/api-key.ts @@ -3,6 +3,8 @@ import Game from './game' import User from './user' export enum APIKeyScope { + READ_GAME_CHANNELS = 'read:gameChannels', + WRITE_GAME_CHANNELS = 'write:gameChannels', READ_PLAYER_GROUPS = 'read:playerGroups', WRITE_CONTINUITY_REQUESTS = 'write:continuityRequests', READ_GAME_FEEDBACK = 'read:gameFeedback', diff --git a/src/entities/game-channel.ts b/src/entities/game-channel.ts new file mode 100644 index 00000000..de331c83 --- /dev/null +++ b/src/entities/game-channel.ts @@ -0,0 +1,75 @@ +import { Cascade, Collection, Embedded, Entity, EntityManager, ManyToMany, ManyToOne, PrimaryKey, Property } from '@mikro-orm/mysql' +import PlayerAlias from './player-alias' +import Game from './game' +import Prop from './prop' +import { Required, ValidationCondition } from 'koa-clay' +import { devDataPlayerFilter } from '../middlewares/dev-data-middleware' +import { Request } from 'koa-clay' + +@Entity() +export default class GameChannel { + @PrimaryKey() + id: number + + @Required({ + validation: async (val: unknown, req: Request): Promise => { + const duplicateName = await (req.ctx.em).getRepository(GameChannel).findOne({ + name: val, + game: req.ctx.state.game + }) + + return [ + { + check: !duplicateName, + error: `A channel with the name '${val}' already exists` + } + ] + } + }) + @Property() + name: string + + @ManyToOne(() => PlayerAlias, { nullable: false, cascade: [Cascade.REMOVE] }) + owner: PlayerAlias + + @ManyToMany(() => PlayerAlias, (alias) => alias.channels, { owner: true }) + members = new Collection(this) + + @ManyToOne(() => Game) + game: Game + + @Embedded(() => Prop, { array: true }) + props: Prop[] = [] + + @Property() + createdAt: Date = new Date() + + @Property({ onUpdate: () => new Date() }) + updatedAt: Date = new Date() + + constructor(game: Game) { + this.game = game + } + + toJSON() { + return { + id: this.id, + name: this.name, + owner: this.owner, + props: this.props, + createdAt: this.createdAt, + updatedAt: this.updatedAt + } + } + + async toJSONWithCount(em: EntityManager, includeDevData: boolean) { + return { + ...this.toJSON(), + count: await this.members.loadCount({ + where: { + player: includeDevData ? {} : devDataPlayerFilter(em) + } + }) + } + } +} diff --git a/src/entities/game-feedback-category.ts b/src/entities/game-feedback-category.ts index f13c4675..35509893 100644 --- a/src/entities/game-feedback-category.ts +++ b/src/entities/game-feedback-category.ts @@ -19,7 +19,7 @@ export default class GameFeedbackCategory { return [ { check: !duplicateInternalName, - error: `A feedback category with the internalName ${val} already exists` + error: `A feedback category with the internalName '${val}' already exists` } ] } diff --git a/src/entities/game-stat.ts b/src/entities/game-stat.ts index 41e0212d..d73d6ac7 100644 --- a/src/entities/game-stat.ts +++ b/src/entities/game-stat.ts @@ -20,7 +20,7 @@ export default class GameStat { return [ { check: !duplicateInternalName, - error: `A stat with the internalName ${val} already exists` + error: `A stat with the internalName '${val}' already exists` } ] } diff --git a/src/entities/index.ts b/src/entities/index.ts index cfdeaf75..c1e4ee30 100644 --- a/src/entities/index.ts +++ b/src/entities/index.ts @@ -1,3 +1,4 @@ +import GameChannel from './game-channel' import UserPinnedGroup from './user-pinned-group' import PlayerAuthActivity from './player-auth-activity' import PlayerAuth from './player-auth' @@ -34,6 +35,7 @@ import PlayerGroup from './player-group' import GameSecret from './game-secret' export default [ + GameChannel, UserPinnedGroup, PlayerAuthActivity, PlayerAuth, diff --git a/src/entities/leaderboard.ts b/src/entities/leaderboard.ts index 1210b5cf..9ce548e4 100644 --- a/src/entities/leaderboard.ts +++ b/src/entities/leaderboard.ts @@ -25,7 +25,7 @@ export default class Leaderboard { return [ { check: !duplicateInternalName, - error: `A leaderboard with the internalName ${val} already exists` + error: `A leaderboard with the internalName '${val}' already exists` } ] } diff --git a/src/entities/player-alias.ts b/src/entities/player-alias.ts index a46ff83c..007fdf87 100644 --- a/src/entities/player-alias.ts +++ b/src/entities/player-alias.ts @@ -1,7 +1,8 @@ -import { Cascade, Entity, Filter, ManyToOne, PrimaryKey, Property } from '@mikro-orm/mysql' +import { Cascade, Collection, Entity, Filter, ManyToMany, ManyToOne, PrimaryKey, Property } from '@mikro-orm/mysql' import Player from './player' import Redis from 'ioredis' import { v4 } from 'uuid' +import GameChannel from './game-channel' export enum PlayerAliasService { STEAM = 'steam', @@ -33,6 +34,9 @@ export default class PlayerAlias { @Property() lastSeenAt: Date = new Date() + @ManyToMany(() => GameChannel, (channel) => channel.members) + channels = new Collection(this) + @Property() createdAt: Date = new Date() diff --git a/src/migrations/.snapshot-gs_dev.json b/src/migrations/.snapshot-gs_dev.json index a6642fa7..708387a0 100644 --- a/src/migrations/.snapshot-gs_dev.json +++ b/src/migrations/.snapshot-gs_dev.json @@ -2468,6 +2468,229 @@ }, "nativeEnums": {} }, + { + "columns": { + "id": { + "name": "id", + "type": "int", + "unsigned": true, + "autoincrement": true, + "primary": true, + "nullable": false, + "length": null, + "mappedType": "integer" + }, + "name": { + "name": "name", + "type": "varchar(255)", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "length": 255, + "mappedType": "string" + }, + "owner_id": { + "name": "owner_id", + "type": "int", + "unsigned": true, + "autoincrement": false, + "primary": false, + "nullable": false, + "length": null, + "mappedType": "integer" + }, + "game_id": { + "name": "game_id", + "type": "int", + "unsigned": true, + "autoincrement": false, + "primary": false, + "nullable": false, + "length": null, + "mappedType": "integer" + }, + "props": { + "name": "props", + "type": "json", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "length": null, + "mappedType": "json" + }, + "created_at": { + "name": "created_at", + "type": "datetime", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "length": null, + "mappedType": "datetime" + }, + "updated_at": { + "name": "updated_at", + "type": "datetime", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "length": null, + "mappedType": "datetime" + } + }, + "name": "game_channel", + "indexes": [ + { + "columnNames": [ + "owner_id" + ], + "composite": false, + "keyName": "game_channel_owner_id_index", + "constraint": false, + "primary": false, + "unique": false + }, + { + "columnNames": [ + "game_id" + ], + "composite": false, + "keyName": "game_channel_game_id_index", + "constraint": false, + "primary": false, + "unique": false + }, + { + "keyName": "PRIMARY", + "columnNames": [ + "id" + ], + "composite": false, + "constraint": true, + "primary": true, + "unique": true + } + ], + "checks": [], + "foreignKeys": { + "game_channel_owner_id_foreign": { + "constraintName": "game_channel_owner_id_foreign", + "columnNames": [ + "owner_id" + ], + "localTableName": "game_channel", + "referencedColumnNames": [ + "id" + ], + "referencedTableName": "player_alias", + "deleteRule": "cascade" + }, + "game_channel_game_id_foreign": { + "constraintName": "game_channel_game_id_foreign", + "columnNames": [ + "game_id" + ], + "localTableName": "game_channel", + "referencedColumnNames": [ + "id" + ], + "referencedTableName": "game", + "updateRule": "cascade" + } + }, + "nativeEnums": {} + }, + { + "columns": { + "game_channel_id": { + "name": "game_channel_id", + "type": "int", + "unsigned": true, + "autoincrement": false, + "primary": false, + "nullable": false, + "length": null, + "mappedType": "integer" + }, + "player_alias_id": { + "name": "player_alias_id", + "type": "int", + "unsigned": true, + "autoincrement": false, + "primary": false, + "nullable": false, + "length": null, + "mappedType": "integer" + } + }, + "name": "game_channel_members", + "indexes": [ + { + "columnNames": [ + "game_channel_id" + ], + "composite": false, + "keyName": "game_channel_members_game_channel_id_index", + "constraint": false, + "primary": false, + "unique": false + }, + { + "columnNames": [ + "player_alias_id" + ], + "composite": false, + "keyName": "game_channel_members_player_alias_id_index", + "constraint": false, + "primary": false, + "unique": false + }, + { + "keyName": "PRIMARY", + "columnNames": [ + "game_channel_id", + "player_alias_id" + ], + "composite": true, + "constraint": true, + "primary": true, + "unique": true + } + ], + "checks": [], + "foreignKeys": { + "game_channel_members_game_channel_id_foreign": { + "constraintName": "game_channel_members_game_channel_id_foreign", + "columnNames": [ + "game_channel_id" + ], + "localTableName": "game_channel_members", + "referencedColumnNames": [ + "id" + ], + "referencedTableName": "game_channel", + "deleteRule": "cascade", + "updateRule": "cascade" + }, + "game_channel_members_player_alias_id_foreign": { + "constraintName": "game_channel_members_player_alias_id_foreign", + "columnNames": [ + "player_alias_id" + ], + "localTableName": "game_channel_members", + "referencedColumnNames": [ + "id" + ], + "referencedTableName": "player_alias", + "deleteRule": "cascade", + "updateRule": "cascade" + } + }, + "nativeEnums": {} + }, { "columns": { "id": { @@ -3801,6 +4024,8 @@ "nullable": false, "length": null, "enumItems": [ + "read:gameChannels", + "write:gameChannels", "read:playerGroups", "write:continuityRequests", "read:gameFeedback", diff --git a/src/migrations/20241206233511CreateGameChannelTables.ts b/src/migrations/20241206233511CreateGameChannelTables.ts new file mode 100644 index 00000000..10065382 --- /dev/null +++ b/src/migrations/20241206233511CreateGameChannelTables.ts @@ -0,0 +1,33 @@ +import { Migration } from '@mikro-orm/migrations' + +export class CreateGameChannelTables extends Migration { + + override async up(): Promise { + this.addSql('create table `game_channel` (`id` int unsigned not null auto_increment primary key, `name` varchar(255) not null, `owner_id` int unsigned not null, `game_id` int unsigned not null, `props` json not null, `created_at` datetime not null, `updated_at` datetime not null) default character set utf8mb4 engine = InnoDB;') + this.addSql('alter table `game_channel` add index `game_channel_owner_id_index`(`owner_id`);') + this.addSql('alter table `game_channel` add index `game_channel_game_id_index`(`game_id`);') + + this.addSql('create table `game_channel_members` (`game_channel_id` int unsigned not null, `player_alias_id` int unsigned not null, primary key (`game_channel_id`, `player_alias_id`)) default character set utf8mb4 engine = InnoDB;') + this.addSql('alter table `game_channel_members` add index `game_channel_members_game_channel_id_index`(`game_channel_id`);') + this.addSql('alter table `game_channel_members` add index `game_channel_members_player_alias_id_index`(`player_alias_id`);') + + this.addSql('alter table `game_channel` add constraint `game_channel_owner_id_foreign` foreign key (`owner_id`) references `player_alias` (`id`) on delete cascade;') + this.addSql('alter table `game_channel` add constraint `game_channel_game_id_foreign` foreign key (`game_id`) references `game` (`id`) on update cascade;') + + this.addSql('alter table `game_channel_members` add constraint `game_channel_members_game_channel_id_foreign` foreign key (`game_channel_id`) references `game_channel` (`id`) on update cascade on delete cascade;') + this.addSql('alter table `game_channel_members` add constraint `game_channel_members_player_alias_id_foreign` foreign key (`player_alias_id`) references `player_alias` (`id`) on update cascade on delete cascade;') + + this.addSql('alter table `apikey` modify `scopes` text not null;') + } + + override async down(): Promise { + this.addSql('alter table `game_channel_members` drop foreign key `game_channel_members_game_channel_id_foreign`;') + + this.addSql('drop table if exists `game_channel`;') + + this.addSql('drop table if exists `game_channel_members`;') + + this.addSql('alter table `apikey` modify `scopes` text not null;') + } + +} diff --git a/src/migrations/index.ts b/src/migrations/index.ts index fb49cc3e..338b98c2 100644 --- a/src/migrations/index.ts +++ b/src/migrations/index.ts @@ -33,6 +33,7 @@ import { CreateUserPinnedGroupsTable } from './20241001194252CreateUserPinnedGro import { AddPlayerGroupMembersVisibleColumn } from './20241014202844AddPlayerGroupMembersVisibleColumn' import { AddPlayerPropCreatedAtColumn } from './20241101233908AddPlayerPropCreatedAtColumn' import { AddPlayerAliasLastSeenAtColumn } from './20241102004938AddPlayerAliasLastSeenAtColumn' +import { CreateGameChannelTables } from './20241206233511CreateGameChannelTables' export default [ { @@ -174,5 +175,9 @@ export default [ { name: 'AddPlayerAliasLastSeenAtColumn', class: AddPlayerAliasLastSeenAtColumn + }, + { + name: 'CreateGameChannelTables', + class: CreateGameChannelTables } ] diff --git a/src/policies/api/game-channel-api.policy.ts b/src/policies/api/game-channel-api.policy.ts new file mode 100644 index 00000000..bc1d1a92 --- /dev/null +++ b/src/policies/api/game-channel-api.policy.ts @@ -0,0 +1,21 @@ +import Policy from '../policy' +import { PolicyResponse } from 'koa-clay' +import { APIKeyScope } from '../../entities/api-key' + +export default class GameChannelAPIPolicy extends Policy { + async index(): Promise { + return await this.hasScope(APIKeyScope.READ_GAME_CHANNELS) + } + + async post(): Promise { + return await this.hasScope(APIKeyScope.WRITE_GAME_CHANNELS) + } + + async join(): Promise { + return await this.hasScope(APIKeyScope.READ_GAME_CHANNELS) + } + + async leave(): Promise { + return await this.hasScope(APIKeyScope.WRITE_GAME_CHANNELS) + } +} diff --git a/src/policies/game-channel.policy.ts b/src/policies/game-channel.policy.ts new file mode 100644 index 00000000..fbd4ef62 --- /dev/null +++ b/src/policies/game-channel.policy.ts @@ -0,0 +1,11 @@ +import Policy from './policy' +import { PolicyResponse, Request } from 'koa-clay' + +export default class GameChannelPolicy extends Policy { + async index(req: Request): Promise { + if (this.isAPICall()) return true + + const { gameId } = req.params + return await this.canAccessGame(Number(gameId)) + } +} diff --git a/src/policies/game-feedback.policy.ts b/src/policies/game-feedback.policy.ts index de4fd653..9e32250c 100644 --- a/src/policies/game-feedback.policy.ts +++ b/src/policies/game-feedback.policy.ts @@ -5,7 +5,7 @@ import UserTypeGate from './user-type-gate' import GameFeedbackCategory from '../entities/game-feedback-category' export default class GameFeedbackPolicy extends Policy { - async get(req: Request): Promise { + async index(req: Request): Promise { const { gameId } = req.params return await this.canAccessGame(Number(gameId)) } diff --git a/src/services/api/game-channel-api.service.ts b/src/services/api/game-channel-api.service.ts new file mode 100644 index 00000000..21c98529 --- /dev/null +++ b/src/services/api/game-channel-api.service.ts @@ -0,0 +1,162 @@ +import { forwardRequest, ForwardTo, HasPermission, Request, Response, Validate } from 'koa-clay' +import GameChannelAPIPolicy from '../../policies/api/game-channel-api.policy' +import APIService from './api-service' +import GameChannel from '../../entities/game-channel' +import { EntityManager } from '@mikro-orm/mysql' +import PlayerAlias from '../../entities/player-alias' +import sanitiseProps from '../../lib/props/sanitiseProps' +import Socket from '../../socket' +import { sendMessage, sendMessages, SocketMessageResponse } from '../../socket/messages/socketMessage' + +async function getAlias(req: Request): Promise { + const em: EntityManager = req.ctx.em + return await em.getRepository(PlayerAlias).findOne({ + id: req.ctx.state.currentAliasId, + player: { + game: req.ctx.state.game + } + }) +} + +async function sendMessageToChannelMembers(req: Request, channel: GameChannel, res: SocketMessageResponse, data: T) { + const socket: Socket = req.ctx.wss + const conns = socket.findConnections((conn) => channel.members.getIdentifiers().includes(conn.playerAlias.id)) + sendMessages(conns, res, data) +} + +export default class GameChannelAPIService extends APIService { + @ForwardTo('games.game-channel', 'index') + async index(req: Request): Promise { + return forwardRequest(req) + } + + @HasPermission(GameChannelAPIPolicy, 'post') + @Validate({ + headers: ['x-talo-alias'], + body: [GameChannel] + }) + async post(req: Request): Promise { + const { name, props } = req.body + const em: EntityManager = req.ctx.em + + const alias = await getAlias(req) + if (!alias) { + return { + status: 404, + body: { + error: 'Player not found' + } + } + } + + const channel = new GameChannel(req.ctx.state.game) + channel.name = name + channel.owner = alias + channel.members.add(alias) + + if (props) { + channel.props = sanitiseProps(props) + } + + await em.persistAndFlush(channel) + + const socket: Socket = req.ctx.wss + const conn = socket.findConnections((conn) => conn.playerAlias.id === alias.id)[0] + if (conn) { + sendMessage(conn, 'v1.channels.player-joined', { channel }) + } + + return { + status: 200, + body: { + channel + } + } + } + + @HasPermission(GameChannelAPIPolicy, 'join') + @Validate({ + headers: ['x-talo-alias'], + body: ['name'] + }) + async join(req: Request): Promise { + const { name } = req.body + const em: EntityManager = req.ctx.em + + const alias = await getAlias(req) + if (!alias) { + return { + status: 404, + body: { + error: 'Player not found' + } + } + } + + const channel = await em.getRepository(GameChannel).findOne({ game: req.ctx.state.game, name }) + if (!channel) { + return { + status: 404, + body: { + error: 'Channel not found' + } + } + } + + await channel.members.loadItems() + channel.members.add(alias) + await em.flush() + + sendMessageToChannelMembers(req, channel, 'v1.channels.player-left', { channel }) + + return { + status: 200, + body: { + channel + } + } + } + + @HasPermission(GameChannelAPIPolicy, 'leave') + @Validate({ + headers: ['x-talo-alias'], + body: ['name'] + }) + async leave(req: Request): Promise { + const { name } = req.body + const em: EntityManager = req.ctx.em + + const alias = await getAlias(req) + if (!alias) { + return { + status: 404, + body: { + error: 'Player not found' + } + } + } + + const channel = await em.getRepository(GameChannel).findOne({ game: req.ctx.state.game, name }) + if (!channel) { + return { + status: 404, + body: { + error: 'Channel not found' + } + } + } + + await channel.members.loadItems() + channel.members.remove(alias) + await em.flush() + + sendMessageToChannelMembers(req, channel, 'v1.channels.player-left', { channel }) + + return { + status: 200, + body: { + channel + } + } + } +} diff --git a/src/services/game-channel.service.ts b/src/services/game-channel.service.ts new file mode 100644 index 00000000..3f1145d1 --- /dev/null +++ b/src/services/game-channel.service.ts @@ -0,0 +1,20 @@ +import { EntityManager } from '@mikro-orm/mysql' +import { HasPermission, Service, Request, Response } from 'koa-clay' +import GameChannel from '../entities/game-channel' +import GameChannelPolicy from '../policies/game-channel.policy' + +export default class GameChannelService extends Service { + @HasPermission(GameChannelPolicy, 'index') + async index(req: Request): Promise { + const em: EntityManager = req.ctx.em + + const channels = await em.getRepository(GameChannel).find({ game: req.ctx.state.game }) + + return { + status: 200, + body: { + channels: await Promise.all(channels.map((channel) => channel.toJSONWithCount(em, req.ctx.state.includeDevData))) + } + } + } +} diff --git a/src/services/game-feedback.service.ts b/src/services/game-feedback.service.ts index 25ff9116..cc69c7e8 100644 --- a/src/services/game-feedback.service.ts +++ b/src/services/game-feedback.service.ts @@ -36,7 +36,7 @@ const itemsPerPage = 50 ]) export default class GameFeedbackService extends Service { @Validate({ query: ['page'] }) - @HasPermission(GameFeedbackPolicy, 'get') + @HasPermission(GameFeedbackPolicy, 'index') async index(req: Request): Promise { const { feedbackCategoryInternalName, search, page } = req.query const em: EntityManager = req.ctx.em diff --git a/src/services/player.service.ts b/src/services/player.service.ts index c14e9783..7878e91b 100644 --- a/src/services/player.service.ts +++ b/src/services/player.service.ts @@ -94,7 +94,7 @@ export default class PlayerService extends Service { if (count > 0) { req.ctx.throw(400, { - message: `Player with identifier ${alias.identifier} already exists`, + message: `Player with identifier '${alias.identifier}' already exists`, errorCode: PlayerAuthErrorCode.IDENTIFIER_TAKEN }) } diff --git a/src/socket/index.ts b/src/socket/index.ts index 7ebf8b0a..26d33c0a 100644 --- a/src/socket/index.ts +++ b/src/socket/index.ts @@ -49,19 +49,19 @@ export default class Socket { async handleConnection(ws: WebSocket, req: IncomingMessage): Promise { await RequestContext.create(this.em, async () => { const key = await authenticateSocket(req.headers?.authorization ?? '', ws) - this.connections.push(new SocketConnection(ws, key.game)) + this.connections.push(new SocketConnection(ws, key)) sendMessage(this.connections.at(-1), 'v1.connected', {}) }) } async handleMessage(ws: WebSocket, data: RawData): Promise { await RequestContext.create(this.em, async () => { - await this.router.handleMessage(this.findConnection(ws), data) + await this.router.handleMessage(this.findConnectionBySocket(ws), data) }) } handlePong(ws: WebSocket): void { - const connection = this.findConnection(ws) + const connection = this.findConnectionBySocket(ws) if (!connection) return connection.alive = true @@ -71,7 +71,7 @@ export default class Socket { this.connections = this.connections.filter((conn) => conn.ws !== ws) } - findConnection(ws: WebSocket): SocketConnection | undefined { + findConnectionBySocket(ws: WebSocket): SocketConnection | undefined { const connection = this.connections.find((conn) => conn.ws === ws) if (!connection) { ws.close(3000) @@ -80,4 +80,8 @@ export default class Socket { return connection } + + findConnections(filter: (conn: SocketConnection) => boolean): SocketConnection[] { + return this.connections.filter(filter) + } } diff --git a/src/socket/listeners/gameChannelListeners.ts b/src/socket/listeners/gameChannelListeners.ts new file mode 100644 index 00000000..a37c9927 --- /dev/null +++ b/src/socket/listeners/gameChannelListeners.ts @@ -0,0 +1,40 @@ +import { z, ZodType } from 'zod' +import { SocketMessageListener } from '../router/socketRoutes' +import { createListener } from '../router/socketRouter' +import { RequestContext } from '@mikro-orm/core' +import GameChannel from '../../entities/game-channel' +import { sendMessages } from '../messages/socketMessage' +import { APIKeyScope } from '../../entities/api-key' + +const gameChannelListeners: SocketMessageListener[] = [ + createListener( + 'v1.channels.message', + z.object({ + channelName: z.string(), + message: z.string() + }), + async ({ conn, data, socket }) => { + const channel = await (RequestContext.getEntityManager() + .getRepository(GameChannel) + .findOne({ + name: data.channelName, + game: conn.game + }, { + populate: ['members'] + })) + + if (!channel) return + + const conns = socket.findConnections((conn) => channel.members.getIdentifiers().includes(conn.playerAlias.id)) + sendMessages(conns, 'v1.channels.message', { + channelName: channel.name, + message: data.message + }) + }, + { + apiKeyScopes: [APIKeyScope.WRITE_GAME_CHANNELS] + } + ) +] + +export default gameChannelListeners diff --git a/src/socket/listeners/playerListeners.ts b/src/socket/listeners/playerListeners.ts index aeff9598..1213aa9c 100644 --- a/src/socket/listeners/playerListeners.ts +++ b/src/socket/listeners/playerListeners.ts @@ -20,14 +20,14 @@ const playerListeners: SocketMessageListener[] = [ const token = await redis.get(`socketTokens.${data.playerAliasId}`) if (token === data.token) { - conn.playerAlias = await (RequestContext.getEntityManager()) + conn.playerAlias = await (RequestContext.getEntityManager() .getRepository(PlayerAlias) .findOne({ id: data.playerAliasId, player: { game: conn.game } - }) + })) sendMessage(conn, 'v1.players.identify.success', conn.playerAlias) } else { @@ -36,7 +36,9 @@ const playerListeners: SocketMessageListener[] = [ await redis.quit() }, - false + { + requirePlayer: false + } ) ] diff --git a/src/socket/messages/socketError.ts b/src/socket/messages/socketError.ts index 946618f8..0ac8e2cc 100644 --- a/src/socket/messages/socketError.ts +++ b/src/socket/messages/socketError.ts @@ -9,7 +9,8 @@ const codes = [ 'UNHANDLED_REQUEST', 'ROUTING_ERROR', 'LISTENER_ERROR', - 'INVALID_SOCKET_TOKEN' + 'INVALID_SOCKET_TOKEN', + 'MISSING_ACCESS_KEY_SCOPE' ] as const export type SocketErrorCode = typeof codes[number] @@ -18,12 +19,18 @@ export default class SocketError { constructor(public code: SocketErrorCode, public message: string) {} } -export function sendError(conn: SocketConnection, req: SocketMessageRequest | 'unknown', error: SocketError) { +type SocketErrorReq = SocketMessageRequest | 'unknown' + +export function sendError(conn: SocketConnection, req: SocketErrorReq, error: SocketError) { setTag('request', req) setTag('errorCode', error.code) captureException(error) - sendMessage(conn, 'v1.error', { + sendMessage<{ + req: SocketErrorReq + message: string + errorCode: SocketErrorCode + }>(conn, 'v1.error', { req, message: error.message, errorCode: error.code diff --git a/src/socket/messages/socketMessage.ts b/src/socket/messages/socketMessage.ts index ef20a1ed..5952c5b8 100644 --- a/src/socket/messages/socketMessage.ts +++ b/src/socket/messages/socketMessage.ts @@ -1,7 +1,8 @@ import SocketConnection from '../socketConnection' export const requests = [ - 'v1.players.identify' + 'v1.players.identify', + 'v1.channels.message' ] as const export type SocketMessageRequest = typeof requests[number] @@ -9,7 +10,10 @@ export type SocketMessageRequest = typeof requests[number] export const responses = [ 'v1.connected', 'v1.error', - 'v1.players.identify.success' + 'v1.players.identify.success', + 'v1.channels.player-joined', + 'v1.channels.player-left', + 'v1.channels.message' ] as const export type SocketMessageResponse = typeof responses[number] diff --git a/src/socket/router/socketRouter.ts b/src/socket/router/socketRouter.ts index a6c1a3a8..4261954b 100644 --- a/src/socket/router/socketRouter.ts +++ b/src/socket/router/socketRouter.ts @@ -4,21 +4,21 @@ import { SocketMessageRequest, requests } from '../messages/socketMessage' import SocketConnection from '../socketConnection' import { RawData } from 'ws' import { addBreadcrumb } from '@sentry/node' -import routes, { SocketMessageListener, SocketMessageListenerHandler } from './socketRoutes' -import { pick } from 'lodash' +import routes, { SocketMessageListener, SocketMessageListenerHandler, SocketMessageListenerOptions } from './socketRoutes' import SocketError, { sendError } from '../messages/socketError' +import { APIKeyScope } from '../../entities/api-key' export function createListener( req: SocketMessageRequest, validator: T, handler: SocketMessageListenerHandler>, - requirePlayer = true + options?: SocketMessageListenerOptions ): SocketMessageListener { return { req, validator, handler, - requirePlayer + options } } @@ -70,8 +70,11 @@ export default class SocketRouter { try { handled = true - if (listener.requirePlayer && !conn.playerAlias) { + if ((listener.options.requirePlayer ?? true) && !conn.playerAlias) { sendError(conn, message.req, new SocketError('NO_PLAYER_FOUND', 'No player found')) + } else if ((listener.options.apiKeyScopes ?? []).some((scope) => !conn.scopes.includes(scope as APIKeyScope))) { + const missing = listener.options.apiKeyScopes.filter((scope) => !conn.scopes.includes(scope as APIKeyScope)) + sendError(conn, message.req, new SocketError('MISSING_ACCESS_KEY_SCOPE', `Missing access key scope(s): ${missing.join(', ')}`)) } else { const data = await listener.validator.parseAsync(message.data) listener.handler({ conn, req: listener.req, data, socket: this.socket }) @@ -91,12 +94,4 @@ export default class SocketRouter { return handled } - - sanitiseZodError(err: ZodError) { - return { - issues: err.issues.map((issue) => { - return pick(issue, ['received', 'code', 'options', 'path']) - }) - } - } } diff --git a/src/socket/router/socketRoutes.ts b/src/socket/router/socketRoutes.ts index 7fa56bc3..7e52ab30 100644 --- a/src/socket/router/socketRoutes.ts +++ b/src/socket/router/socketRoutes.ts @@ -3,6 +3,7 @@ import { SocketMessageRequest } from '../messages/socketMessage' import SocketConnection from '../socketConnection' import Socket from '..' import playerListeners from '../listeners/playerListeners' +import gameChannelListeners from '../listeners/gameChannelListeners' type SocketMessageListenerHandlerParams = { conn: SocketConnection @@ -12,16 +13,21 @@ type SocketMessageListenerHandlerParams = { } export type SocketMessageListenerHandler = (params: SocketMessageListenerHandlerParams) => void | Promise +export type SocketMessageListenerOptions = { + requirePlayer?: boolean + apiKeyScopes?: string[] +} export type SocketMessageListener = { req: SocketMessageRequest validator: T handler: SocketMessageListenerHandler> - requirePlayer: boolean + options: SocketMessageListenerOptions } const routes: SocketMessageListener[][] = [ - playerListeners + playerListeners, + gameChannelListeners ] export default routes diff --git a/src/socket/socketConnection.ts b/src/socket/socketConnection.ts index 0da36de9..d4038a15 100644 --- a/src/socket/socketConnection.ts +++ b/src/socket/socketConnection.ts @@ -1,10 +1,16 @@ import { WebSocket } from 'ws' import PlayerAlias from '../entities/player-alias' import Game from '../entities/game' +import APIKey, { APIKeyScope } from '../entities/api-key' export default class SocketConnection { - playerAlias: PlayerAlias | null = null alive: boolean = true + playerAlias: PlayerAlias | null = null + game: Game | null = null + scopes: APIKeyScope[] = [] - constructor(readonly ws: WebSocket, readonly game: Game) {} + constructor(readonly ws: WebSocket, apiKey: APIKey) { + this.game = apiKey.game + this.scopes = apiKey.scopes + } } diff --git a/tests/fixtures/GameChannelFactory.ts b/tests/fixtures/GameChannelFactory.ts new file mode 100644 index 00000000..c7768f12 --- /dev/null +++ b/tests/fixtures/GameChannelFactory.ts @@ -0,0 +1,23 @@ +import { Factory } from 'hefty' +import GameChannel from '../../src/entities/game-channel' +import { randText } from '@ngneat/falso' +import Game from '../../src/entities/game' +import PlayerFactory from './PlayerFactory' + +export default class GameChannelFactory extends Factory { + private game: Game + + constructor(game: Game) { + super(GameChannel) + + this.game = game + } + + protected definition(): void { + this.state(async () => ({ + name: randText(), + owner: (await new PlayerFactory([this.game]).one()).aliases[0], + game: this.game + })) + } +} diff --git a/tests/services/_api/game-channel-api/post.test.ts b/tests/services/_api/game-channel-api/post.test.ts new file mode 100644 index 00000000..d8ab2c2d --- /dev/null +++ b/tests/services/_api/game-channel-api/post.test.ts @@ -0,0 +1,23 @@ +import request from 'supertest' +import { APIKeyScope } from '../../../../src/entities/api-key' +import createAPIKeyAndToken from '../../../utils/createAPIKeyAndToken' + +describe('Game channel API service - post', () => { + it('should create a game channel if the scope is valid', async () => { + const [, token] = await createAPIKeyAndToken([APIKeyScope.WRITE_GAME_CHANNELS]) + + await request(global.app) + .post('/v1/game-channels') + .auth(token, { type: 'bearer' }) + .expect(200) + }) + + it('should not create a game channel if the scope is not valid', async () => { + const [, token] = await createAPIKeyAndToken([]) + + await request(global.app) + .post('/v1/game-channels') + .auth(token, { type: 'bearer' }) + .expect(403) + }) +}) diff --git a/tests/services/game-channel/index.test.ts b/tests/services/game-channel/index.test.ts new file mode 100644 index 00000000..a023f693 --- /dev/null +++ b/tests/services/game-channel/index.test.ts @@ -0,0 +1,13 @@ +import request from 'supertest' +import createUserAndToken from '../../utils/createUserAndToken' + +describe('Game channel service - index', () => { + it('should return a list of game channels', async () => { + const [token] = await createUserAndToken() + + await request(global.app) + .get('/game-channels') + .auth(token, { type: 'bearer' }) + .expect(200) + }) +}) diff --git a/tests/services/game-feedback/postCategory.test.ts b/tests/services/game-feedback/postCategory.test.ts index 5c7a5896..2a02ee3a 100644 --- a/tests/services/game-feedback/postCategory.test.ts +++ b/tests/services/game-feedback/postCategory.test.ts @@ -80,7 +80,7 @@ describe('Game feedback service - post category', () => { expect(res.body).toStrictEqual({ errors: { - internalName: ['A feedback category with the internalName bugs already exists'] + internalName: ['A feedback category with the internalName \'bugs\' already exists'] } }) }) diff --git a/tests/services/game-stat/post.test.ts b/tests/services/game-stat/post.test.ts index c37bd7c9..6d34e863 100644 --- a/tests/services/game-stat/post.test.ts +++ b/tests/services/game-stat/post.test.ts @@ -116,7 +116,7 @@ describe('Game stat service - post', () => { expect(res.body).toStrictEqual({ errors: { - internalName: ['A stat with the internalName levels-completed already exists'] + internalName: ['A stat with the internalName \'levels-completed\' already exists'] } }) }) diff --git a/tests/services/leaderboard/post.test.ts b/tests/services/leaderboard/post.test.ts index c42efa95..77519667 100644 --- a/tests/services/leaderboard/post.test.ts +++ b/tests/services/leaderboard/post.test.ts @@ -97,7 +97,7 @@ describe('Leaderboard service - post', () => { expect(res.body).toStrictEqual({ errors: { - internalName: ['A leaderboard with the internalName highscores already exists'] + internalName: ['A leaderboard with the internalName \'highscores\' already exists'] } }) }) diff --git a/tests/services/player/post.test.ts b/tests/services/player/post.test.ts index da8b7dfa..0d27a79b 100644 --- a/tests/services/player/post.test.ts +++ b/tests/services/player/post.test.ts @@ -182,7 +182,7 @@ describe('Player service - post', () => { .expect(400) expect(res.body).toStrictEqual({ - message: `Player with identifier ${player.aliases[0].identifier} already exists`, + message: `Player with identifier '${player.aliases[0].identifier}' already exists`, errorCode: 'IDENTIFIER_TAKEN' }) }) From e40d4173e07c6e21fc2a68449af2996f7461c537 Mon Sep 17 00:00:00 2001 From: tudor <7089284+tudddorrr@users.noreply.github.com> Date: Sat, 7 Dec 2024 01:25:41 +0000 Subject: [PATCH 06/15] fix circular deps --- src/index.ts | 7 +++--- src/services/api/game-channel-api.service.ts | 8 +++--- src/socket/listeners/gameChannelListeners.ts | 4 +-- src/socket/listeners/playerListeners.ts | 4 +-- .../{socketRoutes.ts => createListener.ts} | 25 +++++++++++-------- src/socket/router/socketRouter.ts | 25 +++++++------------ 6 files changed, 35 insertions(+), 38 deletions(-) rename src/socket/router/{socketRoutes.ts => createListener.ts} (55%) diff --git a/src/index.ts b/src/index.ts index e2e3b2be..e8aeab18 100644 --- a/src/index.ts +++ b/src/index.ts @@ -27,8 +27,8 @@ export default async function init(): Promise { app.use(bodyParser()) app.use(helmet()) app.use(corsMiddleware) - app.use(requestContextMiddleware) app.use(devDataMiddleware) + app.use(requestContextMiddleware) configureProtectedRoutes(app) configurePublicRoutes(app) @@ -36,10 +36,9 @@ export default async function init(): Promise { app.use(cleanupMiddleware) - const server = createServer(app.callback()) - app.context.wss = new Socket(server, app.context.em) - if (!isTest) { + const server = createServer(app.callback()) + app.context.wss = new Socket(server, app.context.em) server.listen(80, () => console.info('Listening on port 80')) } diff --git a/src/services/api/game-channel-api.service.ts b/src/services/api/game-channel-api.service.ts index 21c98529..e65178f7 100644 --- a/src/services/api/game-channel-api.service.ts +++ b/src/services/api/game-channel-api.service.ts @@ -25,16 +25,16 @@ async function sendMessageToChannelMembers(req: Request, channel: GameChannel } export default class GameChannelAPIService extends APIService { - @ForwardTo('games.game-channel', 'index') + @ForwardTo('games.game-channels', 'index') async index(req: Request): Promise { return forwardRequest(req) } - @HasPermission(GameChannelAPIPolicy, 'post') @Validate({ headers: ['x-talo-alias'], body: [GameChannel] }) + @HasPermission(GameChannelAPIPolicy, 'post') async post(req: Request): Promise { const { name, props } = req.body const em: EntityManager = req.ctx.em @@ -74,11 +74,11 @@ export default class GameChannelAPIService extends APIService { } } - @HasPermission(GameChannelAPIPolicy, 'join') @Validate({ headers: ['x-talo-alias'], body: ['name'] }) + @HasPermission(GameChannelAPIPolicy, 'join') async join(req: Request): Promise { const { name } = req.body const em: EntityManager = req.ctx.em @@ -117,11 +117,11 @@ export default class GameChannelAPIService extends APIService { } } - @HasPermission(GameChannelAPIPolicy, 'leave') @Validate({ headers: ['x-talo-alias'], body: ['name'] }) + @HasPermission(GameChannelAPIPolicy, 'leave') async leave(req: Request): Promise { const { name } = req.body const em: EntityManager = req.ctx.em diff --git a/src/socket/listeners/gameChannelListeners.ts b/src/socket/listeners/gameChannelListeners.ts index a37c9927..88126087 100644 --- a/src/socket/listeners/gameChannelListeners.ts +++ b/src/socket/listeners/gameChannelListeners.ts @@ -1,6 +1,6 @@ import { z, ZodType } from 'zod' -import { SocketMessageListener } from '../router/socketRoutes' -import { createListener } from '../router/socketRouter' +import { SocketMessageListener } from '../router/createListener' +import createListener from '../router/createListener' import { RequestContext } from '@mikro-orm/core' import GameChannel from '../../entities/game-channel' import { sendMessages } from '../messages/socketMessage' diff --git a/src/socket/listeners/playerListeners.ts b/src/socket/listeners/playerListeners.ts index 1213aa9c..81ab0ba9 100644 --- a/src/socket/listeners/playerListeners.ts +++ b/src/socket/listeners/playerListeners.ts @@ -1,11 +1,11 @@ import { z, ZodType } from 'zod' -import { createListener } from '../router/socketRouter' +import createListener from '../router/createListener' import { sendMessage } from '../messages/socketMessage' import Redis from 'ioredis' import redisConfig from '../../config/redis.config' import { RequestContext } from '@mikro-orm/core' import PlayerAlias from '../../entities/player-alias' -import { SocketMessageListener } from '../router/socketRoutes' +import { SocketMessageListener } from '../router/createListener' import SocketError, { sendError } from '../messages/socketError' const playerListeners: SocketMessageListener[] = [ diff --git a/src/socket/router/socketRoutes.ts b/src/socket/router/createListener.ts similarity index 55% rename from src/socket/router/socketRoutes.ts rename to src/socket/router/createListener.ts index 7e52ab30..a1e4a8e3 100644 --- a/src/socket/router/socketRoutes.ts +++ b/src/socket/router/createListener.ts @@ -2,8 +2,6 @@ import { z, ZodType } from 'zod' import { SocketMessageRequest } from '../messages/socketMessage' import SocketConnection from '../socketConnection' import Socket from '..' -import playerListeners from '../listeners/playerListeners' -import gameChannelListeners from '../listeners/gameChannelListeners' type SocketMessageListenerHandlerParams = { conn: SocketConnection @@ -12,8 +10,8 @@ type SocketMessageListenerHandlerParams = { socket: Socket } -export type SocketMessageListenerHandler = (params: SocketMessageListenerHandlerParams) => void | Promise -export type SocketMessageListenerOptions = { +type SocketMessageListenerHandler = (params: SocketMessageListenerHandlerParams) => void | Promise +type SocketMessageListenerOptions = { requirePlayer?: boolean apiKeyScopes?: string[] } @@ -25,9 +23,16 @@ export type SocketMessageListener = { options: SocketMessageListenerOptions } -const routes: SocketMessageListener[][] = [ - playerListeners, - gameChannelListeners -] - -export default routes +export default function createListener( + req: SocketMessageRequest, + validator: T, + handler: SocketMessageListenerHandler>, + options?: SocketMessageListenerOptions +): SocketMessageListener { + return { + req, + validator, + handler, + options + } +} diff --git a/src/socket/router/socketRouter.ts b/src/socket/router/socketRouter.ts index 4261954b..6a229cfc 100644 --- a/src/socket/router/socketRouter.ts +++ b/src/socket/router/socketRouter.ts @@ -1,26 +1,14 @@ import { z, ZodError, ZodType } from 'zod' import Socket from '..' -import { SocketMessageRequest, requests } from '../messages/socketMessage' +import { requests } from '../messages/socketMessage' import SocketConnection from '../socketConnection' import { RawData } from 'ws' import { addBreadcrumb } from '@sentry/node' -import routes, { SocketMessageListener, SocketMessageListenerHandler, SocketMessageListenerOptions } from './socketRoutes' +import { SocketMessageListener } from './createListener' import SocketError, { sendError } from '../messages/socketError' import { APIKeyScope } from '../../entities/api-key' - -export function createListener( - req: SocketMessageRequest, - validator: T, - handler: SocketMessageListenerHandler>, - options?: SocketMessageListenerOptions -): SocketMessageListener { - return { - req, - validator, - handler, - options - } -} +import playerListeners from '../listeners/playerListeners' +import gameChannelListeners from '../listeners/gameChannelListeners' const socketMessageValidator = z.object({ req: z.enum(requests), @@ -29,6 +17,11 @@ const socketMessageValidator = z.object({ type SocketMessage = z.infer +const routes: SocketMessageListener[][] = [ + playerListeners, + gameChannelListeners +] + export default class SocketRouter { constructor(readonly socket: Socket) {} From 90cb4b9a6638a4f4ec7b6a7d034ca54af8de01c7 Mon Sep 17 00:00:00 2001 From: tudor <7089284+tudddorrr@users.noreply.github.com> Date: Mon, 9 Dec 2024 16:02:39 +0000 Subject: [PATCH 07/15] socket tests --- package-lock.json | 20 ++++++ package.json | 1 + src/index.ts | 4 +- src/middlewares/player-auth-middleware.ts | 8 ++- src/socket/index.ts | 14 ++-- src/socket/listeners/gameChannelListeners.ts | 10 ++- src/socket/listeners/playerListeners.ts | 27 ++++++-- src/socket/messages/socketError.ts | 9 ++- src/socket/messages/socketMessage.ts | 16 +++-- src/socket/router/createListener.ts | 3 +- src/socket/router/socketRouter.ts | 34 ++++++---- tests/setupTest.ts | 7 ++ .../playerListeners/identify.test.ts | 67 ++++++++++++++++--- 13 files changed, 173 insertions(+), 47 deletions(-) diff --git a/package-lock.json b/package-lock.json index c325d382..a82e4d37 100644 --- a/package-lock.json +++ b/package-lock.json @@ -65,6 +65,7 @@ "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", @@ -7912,6 +7913,25 @@ "node": ">=14.18.0" } }, + "node_modules/superwstest": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/superwstest/-/superwstest-2.0.4.tgz", + "integrity": "sha512-7u9H76yvLMdjwdrD0BFdc2JN6m2dcEQ8h7+nERrIFGADDw0HBA+clG1Yx/aQ0B/RqKzrHNkVVkGzvVBeknoCeg==", + "dev": true, + "dependencies": { + "@types/supertest": "*", + "@types/ws": "7.x || 8.x", + "ws": "7.x || 8.x" + }, + "peerDependencies": { + "supertest": "*" + }, + "peerDependenciesMeta": { + "supertest": { + "optional": true + } + } + }, "node_modules/supports-color": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", diff --git a/package.json b/package.json index fc8c10e2..5fc033b4 100644 --- a/package.json +++ b/package.json @@ -40,6 +40,7 @@ "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", diff --git a/src/index.ts b/src/index.ts index e8aeab18..d23bb1a5 100644 --- a/src/index.ts +++ b/src/index.ts @@ -36,9 +36,9 @@ export default async function init(): Promise { app.use(cleanupMiddleware) + const server = createServer(app.callback()) + app.context.wss = new Socket(server, app.context.em) if (!isTest) { - const server = createServer(app.callback()) - app.context.wss = new Socket(server, app.context.em) server.listen(80, () => console.info('Listening on port 80')) } diff --git a/src/middlewares/player-auth-middleware.ts b/src/middlewares/player-auth-middleware.ts index 36ad7f80..42c53649 100644 --- a/src/middlewares/player-auth-middleware.ts +++ b/src/middlewares/player-auth-middleware.ts @@ -43,8 +43,7 @@ export async function validateAuthSessionToken(ctx: Context, alias: PlayerAlias) } try { - const payload = await promisify(jwt.verify)(sessionToken, alias.player.auth.sessionKey) - if (payload.playerId !== ctx.state.currentPlayerId || payload.aliasId !== ctx.state.currentAliasId) { + if (!await validateSessionTokenJWT(sessionToken as string, alias)) { throw new Error() } } catch (err) { @@ -54,3 +53,8 @@ export async function validateAuthSessionToken(ctx: Context, alias: PlayerAlias) }) } } + +export async function validateSessionTokenJWT(sessionToken: string, alias: PlayerAlias): Promise { + const payload = await promisify(jwt.verify)(sessionToken, alias.player.auth.sessionKey) + return payload.playerId === alias.player.id && payload.aliasId === alias.id +} diff --git a/src/socket/index.ts b/src/socket/index.ts index 26d33c0a..068c161b 100644 --- a/src/socket/index.ts +++ b/src/socket/index.ts @@ -19,7 +19,7 @@ export default class Socket { ws.on('message', (data) => this.handleMessage(ws, data)) ws.on('pong', () => this.handlePong(ws)) - ws.on('close', () => this.handleClose(ws)) + ws.on('close', () => this.handleCloseConnection(ws)) ws.on('error', captureException) }) @@ -28,6 +28,10 @@ export default class Socket { this.heartbeat() } + getServer(): WebSocketServer { + return this.wss + } + heartbeat(): void { const interval = setInterval(() => { this.connections.forEach((conn) => { @@ -49,8 +53,10 @@ export default class Socket { async handleConnection(ws: WebSocket, req: IncomingMessage): Promise { await RequestContext.create(this.em, async () => { const key = await authenticateSocket(req.headers?.authorization ?? '', ws) - this.connections.push(new SocketConnection(ws, key)) - sendMessage(this.connections.at(-1), 'v1.connected', {}) + if (key) { + this.connections.push(new SocketConnection(ws, key)) + sendMessage(this.connections.at(-1), 'v1.connected', {}) + } }) } @@ -67,7 +73,7 @@ export default class Socket { connection.alive = true } - handleClose(ws: WebSocket): void { + handleCloseConnection(ws: WebSocket): void { this.connections = this.connections.filter((conn) => conn.ws !== ws) } diff --git a/src/socket/listeners/gameChannelListeners.ts b/src/socket/listeners/gameChannelListeners.ts index 88126087..8fc871ef 100644 --- a/src/socket/listeners/gameChannelListeners.ts +++ b/src/socket/listeners/gameChannelListeners.ts @@ -23,12 +23,18 @@ const gameChannelListeners: SocketMessageListener[] = [ populate: ['members'] })) - if (!channel) return + if (!channel) { + throw new Error('Channel not found') + } + if (!channel.members.getIdentifiers().includes(conn.playerAlias.id)) { + throw new Error('Player not in channel') + } const conns = socket.findConnections((conn) => channel.members.getIdentifiers().includes(conn.playerAlias.id)) sendMessages(conns, 'v1.channels.message', { channelName: channel.name, - message: data.message + message: data.message, + fromPlayerAlias: conn.playerAlias }) }, { diff --git a/src/socket/listeners/playerListeners.ts b/src/socket/listeners/playerListeners.ts index 81ab0ba9..7b3e5e8c 100644 --- a/src/socket/listeners/playerListeners.ts +++ b/src/socket/listeners/playerListeners.ts @@ -4,22 +4,25 @@ import { sendMessage } from '../messages/socketMessage' import Redis from 'ioredis' import redisConfig from '../../config/redis.config' import { RequestContext } from '@mikro-orm/core' -import PlayerAlias from '../../entities/player-alias' +import PlayerAlias, { PlayerAliasService } from '../../entities/player-alias' import { SocketMessageListener } from '../router/createListener' import SocketError, { sendError } from '../messages/socketError' +import { APIKeyScope } from '../../entities/api-key' +import { validateSessionTokenJWT } from '../../middlewares/player-auth-middleware' const playerListeners: SocketMessageListener[] = [ createListener( 'v1.players.identify', z.object({ playerAliasId: z.number(), - token: z.string() + socketToken: z.string(), + sessionToken: z.string().optional() }), async ({ conn, req, data }) => { const redis = new Redis(redisConfig) const token = await redis.get(`socketTokens.${data.playerAliasId}`) - if (token === data.token) { + if (token === data.socketToken) { conn.playerAlias = await (RequestContext.getEntityManager() .getRepository(PlayerAlias) .findOne({ @@ -27,9 +30,22 @@ const playerListeners: SocketMessageListener[] = [ player: { game: conn.game } + }, { + populate: ['player.auth'] })) - sendMessage(conn, 'v1.players.identify.success', conn.playerAlias) + if (conn.playerAlias.service === PlayerAliasService.TALO) { + try { + if (!await validateSessionTokenJWT(data.sessionToken, conn.playerAlias)) { + throw new Error() + } + sendMessage(conn, 'v1.players.identify.success', conn.playerAlias) + } catch (err) { + sendError(conn, req, new SocketError('INVALID_SESSION', 'Session token is invalid')) + } + } else { + sendMessage(conn, 'v1.players.identify.success', conn.playerAlias) + } } else { sendError(conn, req, new SocketError('INVALID_SOCKET_TOKEN', 'Invalid socket token')) } @@ -37,7 +53,8 @@ const playerListeners: SocketMessageListener[] = [ await redis.quit() }, { - requirePlayer: false + requirePlayer: false, + apiKeyScopes: [APIKeyScope.READ_PLAYERS] } ) ] diff --git a/src/socket/messages/socketError.ts b/src/socket/messages/socketError.ts index 0ac8e2cc..836c259b 100644 --- a/src/socket/messages/socketError.ts +++ b/src/socket/messages/socketError.ts @@ -10,13 +10,14 @@ const codes = [ 'ROUTING_ERROR', 'LISTENER_ERROR', 'INVALID_SOCKET_TOKEN', - 'MISSING_ACCESS_KEY_SCOPE' + 'INVALID_SESSION', + 'MISSING_ACCESS_KEY_SCOPES' ] as const export type SocketErrorCode = typeof codes[number] export default class SocketError { - constructor(public code: SocketErrorCode, public message: string) {} + constructor(public code: SocketErrorCode, public message: string, public cause?: string) {} } type SocketErrorReq = SocketMessageRequest | 'unknown' @@ -30,9 +31,11 @@ export function sendError(conn: SocketConnection, req: SocketErrorReq, error: So req: SocketErrorReq message: string errorCode: SocketErrorCode + cause?: string }>(conn, 'v1.error', { req, message: error.message, - errorCode: error.code + errorCode: error.code, + cause: error.cause }) } diff --git a/src/socket/messages/socketMessage.ts b/src/socket/messages/socketMessage.ts index 5952c5b8..8c846733 100644 --- a/src/socket/messages/socketMessage.ts +++ b/src/socket/messages/socketMessage.ts @@ -19,12 +19,18 @@ export const responses = [ export type SocketMessageResponse = typeof responses[number] export function sendMessage(conn: SocketConnection, res: SocketMessageResponse, data: T) { - conn.ws.send(JSON.stringify({ - res, - data - })) + if (conn.ws.readyState === conn.ws.OPEN) { + conn.ws.send(JSON.stringify({ + res, + data + })) + } } export function sendMessages(conns: SocketConnection[], type: SocketMessageResponse, data: T) { - conns.forEach((ws) => sendMessage(ws, type, data)) + conns.forEach((ws) => { + if (ws.ws.readyState === ws.ws.OPEN) { + sendMessage(ws, type, data) + } + }) } diff --git a/src/socket/router/createListener.ts b/src/socket/router/createListener.ts index a1e4a8e3..365695f1 100644 --- a/src/socket/router/createListener.ts +++ b/src/socket/router/createListener.ts @@ -2,6 +2,7 @@ import { z, ZodType } from 'zod' import { SocketMessageRequest } from '../messages/socketMessage' import SocketConnection from '../socketConnection' import Socket from '..' +import { APIKeyScope } from '../../entities/api-key' type SocketMessageListenerHandlerParams = { conn: SocketConnection @@ -13,7 +14,7 @@ type SocketMessageListenerHandlerParams = { type SocketMessageListenerHandler = (params: SocketMessageListenerHandlerParams) => void | Promise type SocketMessageListenerOptions = { requirePlayer?: boolean - apiKeyScopes?: string[] + apiKeyScopes?: APIKeyScope[] } export type SocketMessageListener = { diff --git a/src/socket/router/socketRouter.ts b/src/socket/router/socketRouter.ts index 6a229cfc..02ba5fde 100644 --- a/src/socket/router/socketRouter.ts +++ b/src/socket/router/socketRouter.ts @@ -35,7 +35,7 @@ export default class SocketRouter { let message: SocketMessage = null try { - message = await this.getParsedMessage(rawData) + message = await socketMessageValidator.parseAsync(JSON.parse(rawData.toString())) const handled = await this.routeMessage(conn, message) if (!handled) { @@ -50,10 +50,6 @@ export default class SocketRouter { } } - async getParsedMessage(rawData: RawData): Promise { - return await socketMessageValidator.parseAsync(JSON.parse(rawData.toString())) - } - async routeMessage(conn: SocketConnection, message: SocketMessage): Promise { let handled = false @@ -63,14 +59,14 @@ export default class SocketRouter { try { handled = true - if ((listener.options.requirePlayer ?? true) && !conn.playerAlias) { - sendError(conn, message.req, new SocketError('NO_PLAYER_FOUND', 'No player found')) - } else if ((listener.options.apiKeyScopes ?? []).some((scope) => !conn.scopes.includes(scope as APIKeyScope))) { - const missing = listener.options.apiKeyScopes.filter((scope) => !conn.scopes.includes(scope as APIKeyScope)) - sendError(conn, message.req, new SocketError('MISSING_ACCESS_KEY_SCOPE', `Missing access key scope(s): ${missing.join(', ')}`)) + if (!this.meetsPlayerRequirement(conn, listener)) { + sendError(conn, message.req, new SocketError('NO_PLAYER_FOUND', 'You must identify a player before sending this request')) + } else if (!this.meetsScopeRequirements(conn, listener)) { + const missing = this.getMissingScopes(conn, listener) + sendError(conn, message.req, new SocketError('MISSING_ACCESS_KEY_SCOPES', `Missing access key scope(s): ${missing.join(', ')}`)) } else { const data = await listener.validator.parseAsync(message.data) - listener.handler({ conn, req: listener.req, data, socket: this.socket }) + await listener.handler({ conn, req: listener.req, data, socket: this.socket }) } break @@ -78,7 +74,7 @@ export default class SocketRouter { if (err instanceof ZodError) { sendError(conn, message.req, new SocketError('INVALID_MESSAGE', 'Invalid message data for request')) } else { - sendError(conn, message.req, new SocketError('LISTENER_ERROR', 'An error occurred while processing the message')) + sendError(conn, message.req, new SocketError('LISTENER_ERROR', 'An error occurred while processing the message', err.message)) } } } @@ -87,4 +83,18 @@ export default class SocketRouter { return handled } + + meetsPlayerRequirement(conn: SocketConnection, listener: SocketMessageListener): boolean { + const requirePlayer = listener.options.requirePlayer ?? true + return Boolean(conn.playerAlias) || !requirePlayer + } + + meetsScopeRequirements(conn: SocketConnection, listener: SocketMessageListener): boolean { + const requiredScopes = listener.options.apiKeyScopes ?? [] + return requiredScopes.every((scope) => conn.scopes.includes(scope as APIKeyScope)) + } + + getMissingScopes(conn: SocketConnection, listener: SocketMessageListener): APIKeyScope[] { + return (listener.options.apiKeyScopes ?? []).filter((scope) => !conn.scopes.includes(scope)) + } } diff --git a/tests/setupTest.ts b/tests/setupTest.ts index c65f8e8a..cfdc0208 100644 --- a/tests/setupTest.ts +++ b/tests/setupTest.ts @@ -3,6 +3,7 @@ import init from '../src' import ormConfig from '../src/config/mikro-orm.config' import createClickhouseClient from '../src/lib/clickhouse/createClient' import { NodeClickHouseClient } from '@clickhouse/client/dist/client' +import { createServer } from 'http' beforeAll(async () => { vi.mock('@sendgrid/mail') @@ -16,6 +17,9 @@ beforeAll(async () => { global.app = app.callback() global.em = app.context.em + global.server = createServer() + global.server.listen(0) + global.clickhouse = createClickhouseClient() await (global.clickhouse as NodeClickHouseClient).command({ query: `TRUNCATE ALL TABLES from ${process.env.CLICKHOUSE_DB}` @@ -25,10 +29,13 @@ beforeAll(async () => { afterAll(async () => { await (global.em as EntityManager).getConnection().close(true) + global.server.close() + const clickhouse = global.clickhouse as NodeClickHouseClient clickhouse.close() delete global.em delete global.app + delete global.server delete global.clickhouse }) diff --git a/tests/socket/listeners/playerListeners/identify.test.ts b/tests/socket/listeners/playerListeners/identify.test.ts index 652b5273..084eaed3 100644 --- a/tests/socket/listeners/playerListeners/identify.test.ts +++ b/tests/socket/listeners/playerListeners/identify.test.ts @@ -2,10 +2,11 @@ import request from 'superwstest' import Socket from '../../../../src/socket' import { APIKeyScope } from '../../../../src/entities/api-key' import createSocketIdentifyMessage from '../../../utils/requestAuthedSocket' -import PlayerAlias from '../../../../src/entities/player-alias' -import PlayerAliasFactory from '../../../fixtures/PlayerAliasFactory' -import PlayerAuthFactory from '../../../fixtures/PlayerAuthFactory' import { EntityManager } from '@mikro-orm/mysql' +import createAPIKeyAndToken from '../../../utils/createAPIKeyAndToken' +import PlayerFactory from '../../../fixtures/PlayerFactory' +import Redis from 'ioredis' +import redisConfig from '../../../../src/config/redis.config' describe('Player listeners - identify', () => { let socket: Socket @@ -65,12 +66,18 @@ describe('Player listeners - identify', () => { }) it('should require a valid session token to identify Talo aliases', async () => { - const [identifyMessage, token, player] = await createSocketIdentifyMessage([APIKeyScope.READ_PLAYERS]) - const alias = await new PlayerAliasFactory(player).talo().one() - const auth = await new PlayerAuthFactory().one() - player.aliases.add(alias) - player.auth = auth - await (global.em).persistAndFlush(player) + const em: EntityManager = global.em + + const [apiKey, token] = await createAPIKeyAndToken([APIKeyScope.READ_PLAYERS]) + const player = await new PlayerFactory([apiKey.game]).withTaloAlias().one() + await em.persistAndFlush(player) + + const redis = new Redis(redisConfig) + const socketToken = await player.aliases[0].createSocketToken(redis) + await redis.quit() + + const sessionToken = await player.auth.createSession(player.aliases[0]) + await em.flush() await request(global.server) .ws('/') @@ -82,8 +89,9 @@ describe('Player listeners - identify', () => { .sendJson({ req: 'v1.players.identify', data: { - playerAliasId: alias.id, - socketToken: identifyMessage.data.socketToken + playerAliasId: player.aliases[0].id, + socketToken: socketToken, + sessionToken: sessionToken } }) .expectJson((actual) => { @@ -92,4 +100,41 @@ describe('Player listeners - identify', () => { }) .close() }) + + it('should reject identify for Talo aliases without a valid session token', async () => { + const em: EntityManager = global.em + + const [apiKey, token] = await createAPIKeyAndToken([APIKeyScope.READ_PLAYERS]) + const player = await new PlayerFactory([apiKey.game]).withTaloAlias().one() + await em.persistAndFlush(player) + + const redis = new Redis(redisConfig) + const socketToken = await player.aliases[0].createSocketToken(redis) + await redis.quit() + + await request(global.server) + .ws('/') + .set('authorization', `Bearer ${token}`) + .expectJson({ + res: 'v1.connected', + data: {} + }) + .sendJson({ + req: 'v1.players.identify', + data: { + playerAliasId: player.aliases[0].id, + socketToken: socketToken, + sessionToken: 'blah' + } + }) + .expectJson({ + res: 'v1.error', + data: { + req: 'v1.players.identify', + message: 'Session token is invalid', + errorCode: 'INVALID_SESSION' + } + }) + .close() + }) }) From 34bafbfb0498c384239cbba075b550cf29a5b5e3 Mon Sep 17 00:00:00 2001 From: tudor <7089284+tudddorrr@users.noreply.github.com> Date: Mon, 9 Dec 2024 22:27:47 +0000 Subject: [PATCH 08/15] channel subscriptions, editing, deleting --- src/docs/game-channel-api.docs.ts | 240 ++++++++++++++++++ src/docs/player-api.docs.ts | 3 + src/entities/game-channel.ts | 10 +- src/migrations/.snapshot-gs_dev.json | 22 ++ .../20241206233511CreateGameChannelTables.ts | 2 +- src/policies/api/game-channel-api.policy.ts | 46 +++- src/services/api/game-channel-api.service.ts | 227 +++++++++++++---- src/socket/listeners/gameChannelListeners.ts | 8 +- 8 files changed, 504 insertions(+), 54 deletions(-) create mode 100644 src/docs/game-channel-api.docs.ts diff --git a/src/docs/game-channel-api.docs.ts b/src/docs/game-channel-api.docs.ts new file mode 100644 index 00000000..7b88896c --- /dev/null +++ b/src/docs/game-channel-api.docs.ts @@ -0,0 +1,240 @@ +import GameChannelAPIService from '../services/api/game-channel-api.service' +import APIDocs from './api-docs' + +const GameChannelAPIDocs: APIDocs = { + index: { + description: 'List game channels', + samples: [ + { + title: 'Sample response', + sample: { + channels: [ + { + id: 1, + name: 'general-chat', + owner: null, + props: [ + { key: 'channelType', value: 'public' } + ], + createdAt: '2024-12-09T12:00:00Z', + updatedAt: '2024-12-09T12:00:00Z' + }, + { + id: 2, + name: 'guild-chat', + owner: { + id: 1, + service: 'username', + identifier: 'guild_admin_bob', + player: { + id: '7a4e70ec-6ee6-418e-923d-b3a45051b7f9', + props: [ + { key: 'currentLevel', value: '58' }, + { key: 'xPos', value: '13.29' }, + { key: 'yPos', value: '26.44' }, + { key: 'zoneId', value: '3' } + ], + aliases: [ + '/* [Circular] */' + ], + devBuild: false, + createdAt: '2022-01-15T13:20:32.133Z', + lastSeenAt: '2022-04-12T15:09:43.066Z', + groups: [ + { id: '5826ca71-1964-4a1b-abcb-a61ffbe003be', name: 'Winners' } + ] + } + }, + props: [ + { key: 'channelType', value: 'guild' }, + { key: 'guildId', value: '5912' } + ], + createdAt: '2024-12-09T12:00:00Z', + updatedAt: '2024-12-09T12:00:00Z' + } + ] + } + } + ] + }, + post: { + description: 'Create a game channel', + params: { + headers: { + 'x-talo-alias': 'The ID of the player\'s alias' + }, + body: { + name: 'The name of the channel', + props: 'An array of @type(Props:prop)' + } + }, + samples: [ + { + title: 'Sample request', + sample: { + name: 'general-chat', + props: [ + { key: 'channelType', value: 'public' } + ] + } + }, + { + title: 'Sample response', + sample: { + channel: { + id: 1, + name: 'general-chat', + owner: { + id: 1, + service: 'username', + identifier: 'guild_admin_bob', + player: { + id: '7a4e70ec-6ee6-418e-923d-b3a45051b7f9', + props: [ + { key: 'currentLevel', value: '58' }, + { key: 'xPos', value: '13.29' }, + { key: 'yPos', value: '26.44' }, + { key: 'zoneId', value: '3' } + ], + aliases: [ + '/* [Circular] */' + ], + devBuild: false, + createdAt: '2022-01-15T13:20:32.133Z', + lastSeenAt: '2022-04-12T15:09:43.066Z', + groups: [ + { id: '5826ca71-1964-4a1b-abcb-a61ffbe003be', name: 'Winners' } + ] + } + }, + props: [ + { key: 'channelType', value: 'public' } + ], + createdAt: '2024-12-09T12:00:00Z', + updatedAt: '2024-12-09T12:00:00Z' + } + } + } + ] + }, + join: { + description: 'Join a game channel', + params: { + headers: { + 'x-talo-alias': 'The ID of the player\'s alias' + }, + route: { + id: 'The ID of the channel' + } + }, + samples: [ + { + title: 'Sample request', + sample: { + name: 'general-chat' + } + }, + { + title: 'Sample response', + sample: { + channel: { + id: 1, + name: 'general-chat', + owner: null, + props: [ + { key: 'channelType', value: 'public' } + ], + createdAt: '2024-12-09T12:00:00Z', + updatedAt: '2024-12-09T12:00:00Z' + } + } + } + ] + }, + leave: { + description: 'Leave a game channel', + params: { + headers: { + 'x-talo-alias': 'The ID of the player\'s alias' + }, + route: { + id: 'The ID of the channel' + } + } + }, + put: { + description: 'Update a game channel', + params: { + headers: { + 'x-talo-alias': 'The ID of the player\'s alias' + }, + body: { + name: 'The new name of the channel', + props: 'An array of @type(Props:prop)', + ownerAliasId: 'The ID of the new owner of the channel' + }, + route: { + id: 'The ID of the channel' + } + }, + samples: [ + { + title: 'Sample request', + sample: { + name: 'new-general-chat', + props: [ + { key: 'channelType', value: 'public' }, + { key: 'recentlyUpdated', value: 'true' } + ], + ownerAliasId: 2 + } + }, + { + title: 'Sample response', + sample: { + channel: { + id: 1, + name: 'new-general-chat', + owner: { + id: 2, + service: 'username', + identifier: 'new_admin_john', + player: { + id: '7a4e70ec-6ee6-418e-923d-b3a45051b7f9', + props: [], + aliases: [ + '/* [Circular] */' + ], + devBuild: false, + createdAt: '2022-01-15T13:20:32.133Z', + lastSeenAt: '2022-04-12T15:09:43.066Z', + groups: [ + { id: '5826ca71-1964-4a1b-abcb-a61ffbe003be', name: 'Winners' } + ] + } + }, + props: [ + { key: 'channelType', value: 'public' }, + { key: 'recentlyUpdated', value: 'true' } + ], + createdAt: '2024-12-09T12:00:00Z', + updatedAt: '2024-12-09T12:01:00Z' + } + } + } + ] + }, + delete: { + description: 'Delete a game channel', + params: { + headers: { + 'x-talo-alias': 'The ID of the player\'s alias' + }, + route: { + id: 'The ID of the channel' + } + } + } +} + +export default GameChannelAPIDocs diff --git a/src/docs/player-api.docs.ts b/src/docs/player-api.docs.ts index b6bbf308..cc0938db 100644 --- a/src/docs/player-api.docs.ts +++ b/src/docs/player-api.docs.ts @@ -5,6 +5,9 @@ const PlayerAPIDocs: APIDocs = { identify: { description: 'Identify a player', params: { + headers: { + 'x-talo-session': 'The session token (required if using Talo player authentication)' + }, query: { service: 'The name of the service where the identity of the player comes from (e.g. "steam", "epic" or "username")', identifier: 'The unique identifier of the player. This can be their username, an email or a numeric ID' diff --git a/src/entities/game-channel.ts b/src/entities/game-channel.ts index de331c83..5a602aea 100644 --- a/src/entities/game-channel.ts +++ b/src/entities/game-channel.ts @@ -29,12 +29,19 @@ export default class GameChannel { @Property() name: string - @ManyToOne(() => PlayerAlias, { nullable: false, cascade: [Cascade.REMOVE] }) + @ManyToOne(() => PlayerAlias, { cascade: [Cascade.REMOVE] }) owner: PlayerAlias @ManyToMany(() => PlayerAlias, (alias) => alias.channels, { owner: true }) members = new Collection(this) + @Property() + totalMessages: number = 0 + + @Required() + @Property() + autoCleanup: boolean = false + @ManyToOne(() => Game) game: Game @@ -56,6 +63,7 @@ export default class GameChannel { id: this.id, name: this.name, owner: this.owner, + totalMessages: this.totalMessages, props: this.props, createdAt: this.createdAt, updatedAt: this.updatedAt diff --git a/src/migrations/.snapshot-gs_dev.json b/src/migrations/.snapshot-gs_dev.json index 708387a0..992e2ec2 100644 --- a/src/migrations/.snapshot-gs_dev.json +++ b/src/migrations/.snapshot-gs_dev.json @@ -2496,10 +2496,32 @@ "unsigned": true, "autoincrement": false, "primary": false, + "nullable": true, + "length": null, + "mappedType": "integer" + }, + "total_messages": { + "name": "total_messages", + "type": "int", + "unsigned": false, + "autoincrement": false, + "primary": false, "nullable": false, "length": null, + "default": "0", "mappedType": "integer" }, + "auto_cleanup": { + "name": "auto_cleanup", + "type": "tinyint(1)", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "length": 1, + "default": "false", + "mappedType": "boolean" + }, "game_id": { "name": "game_id", "type": "int", diff --git a/src/migrations/20241206233511CreateGameChannelTables.ts b/src/migrations/20241206233511CreateGameChannelTables.ts index 10065382..4e248def 100644 --- a/src/migrations/20241206233511CreateGameChannelTables.ts +++ b/src/migrations/20241206233511CreateGameChannelTables.ts @@ -3,7 +3,7 @@ import { Migration } from '@mikro-orm/migrations' export class CreateGameChannelTables extends Migration { override async up(): Promise { - this.addSql('create table `game_channel` (`id` int unsigned not null auto_increment primary key, `name` varchar(255) not null, `owner_id` int unsigned not null, `game_id` int unsigned not null, `props` json not null, `created_at` datetime not null, `updated_at` datetime not null) default character set utf8mb4 engine = InnoDB;') + this.addSql('create table `game_channel` (`id` int unsigned not null auto_increment primary key, `name` varchar(255) not null, `owner_id` int unsigned null, `game_id` int unsigned not null, `total_messages` int not null default 0, `auto_cleanup` tinyint(1) not null default false, `props` json not null, `created_at` datetime not null, `updated_at` datetime not null) default character set utf8mb4 engine = InnoDB;') this.addSql('alter table `game_channel` add index `game_channel_owner_id_index`(`owner_id`);') this.addSql('alter table `game_channel` add index `game_channel_game_id_index`(`game_id`);') diff --git a/src/policies/api/game-channel-api.policy.ts b/src/policies/api/game-channel-api.policy.ts index bc1d1a92..57b11956 100644 --- a/src/policies/api/game-channel-api.policy.ts +++ b/src/policies/api/game-channel-api.policy.ts @@ -1,21 +1,63 @@ import Policy from '../policy' -import { PolicyResponse } from 'koa-clay' +import { PolicyDenial, PolicyResponse } from 'koa-clay' import { APIKeyScope } from '../../entities/api-key' +import PlayerAlias from '../../entities/player-alias' +import { EntityManager } from '@mikro-orm/mysql' export default class GameChannelAPIPolicy extends Policy { + async getAlias(): Promise { + const em: EntityManager = this.ctx.em + return await em.getRepository(PlayerAlias).findOne({ + id: this.ctx.state.currentAliasId, + player: { + game: this.ctx.state.game + } + }) + } + async index(): Promise { return await this.hasScope(APIKeyScope.READ_GAME_CHANNELS) } + async subscriptions(): Promise { + this.ctx.state.alias = await this.getAlias() + if (!this.ctx.state.alias) return new PolicyDenial({ message: 'Player not found' }, 404) + + return await this.hasScope(APIKeyScope.READ_GAME_CHANNELS) + } + async post(): Promise { + this.ctx.state.alias = await this.getAlias() + if (!this.ctx.state.alias) return new PolicyDenial({ message: 'Player not found' }, 404) + return await this.hasScope(APIKeyScope.WRITE_GAME_CHANNELS) } async join(): Promise { - return await this.hasScope(APIKeyScope.READ_GAME_CHANNELS) + this.ctx.state.alias = await this.getAlias() + if (!this.ctx.state.alias) return new PolicyDenial({ message: 'Player not found' }, 404) + + return await this.hasScope(APIKeyScope.WRITE_GAME_CHANNELS) } async leave(): Promise { + this.ctx.state.alias = await this.getAlias() + if (!this.ctx.state.alias) return new PolicyDenial({ message: 'Player not found' }, 404) + + return await this.hasScope(APIKeyScope.WRITE_GAME_CHANNELS) + } + + async put(): Promise { + this.ctx.state.alias = await this.getAlias() + if (!this.ctx.state.alias) return new PolicyDenial({ message: 'Player not found' }, 404) + + return await this.hasScope(APIKeyScope.WRITE_GAME_CHANNELS) + } + + async delete(): Promise { + this.ctx.state.alias = await this.getAlias() + if (!this.ctx.state.alias) return new PolicyDenial({ message: 'Player not found' }, 404) + return await this.hasScope(APIKeyScope.WRITE_GAME_CHANNELS) } } diff --git a/src/services/api/game-channel-api.service.ts b/src/services/api/game-channel-api.service.ts index e65178f7..37506a88 100644 --- a/src/services/api/game-channel-api.service.ts +++ b/src/services/api/game-channel-api.service.ts @@ -1,35 +1,86 @@ -import { forwardRequest, ForwardTo, HasPermission, Request, Response, Validate } from 'koa-clay' +import { forwardRequest, ForwardTo, HasPermission, Request, Response, Routes, Validate } from 'koa-clay' import GameChannelAPIPolicy from '../../policies/api/game-channel-api.policy' import APIService from './api-service' import GameChannel from '../../entities/game-channel' import { EntityManager } from '@mikro-orm/mysql' -import PlayerAlias from '../../entities/player-alias' import sanitiseProps from '../../lib/props/sanitiseProps' import Socket from '../../socket' import { sendMessage, sendMessages, SocketMessageResponse } from '../../socket/messages/socketMessage' +import GameChannelAPIDocs from '../../docs/game-channel-api.docs' +import PlayerAlias from '../../entities/player-alias' -async function getAlias(req: Request): Promise { - const em: EntityManager = req.ctx.em - return await em.getRepository(PlayerAlias).findOne({ - id: req.ctx.state.currentAliasId, - player: { - game: req.ctx.state.game - } - }) -} - -async function sendMessageToChannelMembers(req: Request, channel: GameChannel, res: SocketMessageResponse, data: T) { +function sendMessageToChannelMembers(req: Request, channel: GameChannel, res: SocketMessageResponse, data: T) { const socket: Socket = req.ctx.wss const conns = socket.findConnections((conn) => channel.members.getIdentifiers().includes(conn.playerAlias.id)) sendMessages(conns, res, data) } +function canModifyChannel(channel: GameChannel, alias: PlayerAlias): boolean { + return channel.owner ? channel.owner.id === alias.id : true +} + +@Routes([ + { + method: 'GET', + handler: 'index', + docs: GameChannelAPIDocs.index + }, + { + method: 'GET', + handler: 'subscriptions', + docs: GameChannelAPIDocs.subscriptions + }, + { + method: 'POST', + handler: 'post', + docs: GameChannelAPIDocs.post + }, + { + method: 'POST', + path: '/:id/join', + handler: 'join', + docs: GameChannelAPIDocs.join + }, + { + method: 'POST', + path: '/:id/leave', + handler: 'leave', + docs: GameChannelAPIDocs.leave + }, + { + method: 'PUT', + path: '/:id', + handler: 'put', + docs: GameChannelAPIDocs.put + }, + { + method: 'DELETE', + path: '/:id', + handler: 'delete', + docs: GameChannelAPIDocs.delete + } +]) export default class GameChannelAPIService extends APIService { @ForwardTo('games.game-channels', 'index') async index(req: Request): Promise { return forwardRequest(req) } + @Validate({ + headers: ['x-talo-alias'] + }) + @HasPermission(GameChannelAPIPolicy, 'subscriptions') + async subscriptions(req: Request): Promise { + const channels = await req.ctx.state.alias.channels.loadItems() + + return { + status: 200, + body: { + channels + } + } + } + @Validate({ headers: ['x-talo-alias'], body: [GameChannel] @@ -39,20 +90,10 @@ export default class GameChannelAPIService extends APIService { const { name, props } = req.body const em: EntityManager = req.ctx.em - const alias = await getAlias(req) - if (!alias) { - return { - status: 404, - body: { - error: 'Player not found' - } - } - } - const channel = new GameChannel(req.ctx.state.game) channel.name = name - channel.owner = alias - channel.members.add(alias) + channel.owner = req.ctx.state.alias + channel.members.add(req.ctx.state.alias) if (props) { channel.props = sanitiseProps(props) @@ -61,7 +102,7 @@ export default class GameChannelAPIService extends APIService { await em.persistAndFlush(channel) const socket: Socket = req.ctx.wss - const conn = socket.findConnections((conn) => conn.playerAlias.id === alias.id)[0] + const conn = socket.findConnections((conn) => conn.playerAlias.id === req.ctx.state.alias.id)[0] if (conn) { sendMessage(conn, 'v1.channels.player-joined', { channel }) } @@ -83,16 +124,6 @@ export default class GameChannelAPIService extends APIService { const { name } = req.body const em: EntityManager = req.ctx.em - const alias = await getAlias(req) - if (!alias) { - return { - status: 404, - body: { - error: 'Player not found' - } - } - } - const channel = await em.getRepository(GameChannel).findOne({ game: req.ctx.state.game, name }) if (!channel) { return { @@ -103,11 +134,12 @@ export default class GameChannelAPIService extends APIService { } } - await channel.members.loadItems() - channel.members.add(alias) - await em.flush() + if (!(await channel.members.load()).getIdentifiers().includes(req.ctx.state.alias.id)) { + channel.members.add(req.ctx.state.alias) + await em.flush() + } - sendMessageToChannelMembers(req, channel, 'v1.channels.player-left', { channel }) + sendMessageToChannelMembers(req, channel, 'v1.channels.player-joined', { channel }) return { status: 200, @@ -123,20 +155,49 @@ export default class GameChannelAPIService extends APIService { }) @HasPermission(GameChannelAPIPolicy, 'leave') async leave(req: Request): Promise { - const { name } = req.body + const { id } = req.params const em: EntityManager = req.ctx.em - const alias = await getAlias(req) - if (!alias) { + const channel = await em.getRepository(GameChannel).findOne(Number(id)) + if (!channel) { return { status: 404, body: { - error: 'Player not found' + error: 'Channel not found' } } } - const channel = await em.getRepository(GameChannel).findOne({ game: req.ctx.state.game, name }) + if (channel.autoCleanup && channel.owner.id === req.ctx.state.alias.id) { + await channel.members.removeAll() + await em.removeAndFlush(channel) + + return { + status: 204 + } + } + + (await channel.members.load()).remove(req.ctx.state.alias) + await em.flush() + + sendMessageToChannelMembers(req, channel, 'v1.channels.player-left', { channel }) + + return { + status: 204 + } + } + + @Validate({ + headers: ['x-talo-alias'], + body: [GameChannel] + }) + @HasPermission(GameChannelAPIPolicy, 'put') + async put(req: Request): Promise { + const { id } = req.params + const { name, props, ownerAliasId } = req.body + const em: EntityManager = req.ctx.em + + const channel = await em.getRepository(GameChannel).findOne(Number(id)) if (!channel) { return { status: 404, @@ -146,11 +207,44 @@ export default class GameChannelAPIService extends APIService { } } - await channel.members.loadItems() - channel.members.remove(alias) - await em.flush() + if (canModifyChannel(channel, req.ctx.state.alias)) { + return { + status: 403, + body: { + error: 'This player is not the owner of the channel' + } + } + } - sendMessageToChannelMembers(req, channel, 'v1.channels.player-left', { channel }) + if (name) { + channel.name = name + } + + if (props) { + channel.props = sanitiseProps(props) + } + + if (ownerAliasId) { + const newOwner = await em.getRepository(PlayerAlias).findOne({ + id: ownerAliasId, + player: { + game: req.ctx.state.game + } + }) + + if (!newOwner) { + return { + status: 404, + body: { + error: 'Owner alias not found' + } + } + } + + channel.owner = newOwner + } + + await em.flush() return { status: 200, @@ -159,4 +253,39 @@ export default class GameChannelAPIService extends APIService { } } } + + @Validate({ + headers: ['x-talo-alias'] + }) + @HasPermission(GameChannelAPIPolicy, 'delete') + async delete(req: Request): Promise { + const { id } = req.params + const em: EntityManager = req.ctx.em + + const channel = await em.getRepository(GameChannel).findOne(Number(id)) + if (!channel) { + return { + status: 404, + body: { + error: 'Channel not found' + } + } + } + + if (canModifyChannel(channel, req.ctx.state.alias)) { + return { + status: 403, + body: { + error: 'This player is not the owner of the channel' + } + } + } + + await channel.members.removeAll() + await em.removeAndFlush(channel) + + return { + status: 204 + } + } } diff --git a/src/socket/listeners/gameChannelListeners.ts b/src/socket/listeners/gameChannelListeners.ts index 8fc871ef..6c1a8620 100644 --- a/src/socket/listeners/gameChannelListeners.ts +++ b/src/socket/listeners/gameChannelListeners.ts @@ -30,12 +30,18 @@ const gameChannelListeners: SocketMessageListener[] = [ throw new Error('Player not in channel') } - const conns = socket.findConnections((conn) => channel.members.getIdentifiers().includes(conn.playerAlias.id)) + const conns = socket.findConnections((conn) => { + return conn.scopes.includes(APIKeyScope.READ_GAME_CHANNELS) && + channel.members.getIdentifiers().includes(conn.playerAlias.id) + }) sendMessages(conns, 'v1.channels.message', { channelName: channel.name, message: data.message, fromPlayerAlias: conn.playerAlias }) + + channel.totalMessages++ + await RequestContext.getEntityManager().flush() }, { apiKeyScopes: [APIKeyScope.WRITE_GAME_CHANNELS] From 236b975b008318436dca94260d7c1865f4641f8f Mon Sep 17 00:00:00 2001 From: tudor <7089284+tudddorrr@users.noreply.github.com> Date: Mon, 9 Dec 2024 22:34:34 +0000 Subject: [PATCH 09/15] use types for player auth error codes --- src/entities/player-auth.ts | 28 ++++++------ src/middlewares/player-auth-middleware.ts | 5 +-- src/services/api/player-auth-api.service.ts | 48 ++++++++++----------- src/services/player.service.ts | 3 +- src/socket/messages/socketError.ts | 4 +- 5 files changed, 44 insertions(+), 44 deletions(-) diff --git a/src/entities/player-auth.ts b/src/entities/player-auth.ts index 8ac83dbe..e5e1b76b 100644 --- a/src/entities/player-auth.ts +++ b/src/entities/player-auth.ts @@ -5,19 +5,21 @@ import { promisify } from 'util' import jwt from 'jsonwebtoken' import PlayerAlias from './player-alias' -export enum PlayerAuthErrorCode { - INVALID_CREDENTIALS = 'INVALID_CREDENTIALS', - VERIFICATION_ALIAS_NOT_FOUND = 'VERIFICATION_ALIAS_NOT_FOUND', - VERIFICATION_CODE_INVALID = 'VERIFICATION_CODE_INVALID', - IDENTIFIER_TAKEN = 'IDENTIFIER_TAKEN', - MISSING_SESSION = 'MISSING_SESSION', - INVALID_SESSION = 'INVALID_SESSION', - NEW_PASSWORD_MATCHES_CURRENT_PASSWORD = 'NEW_PASSWORD_MATCHES_CURRENT_PASSWORD', - NEW_EMAIL_MATCHES_CURRENT_EMAIL = 'NEW_EMAIL_MATCHES_CURRENT_EMAIL', - PASSWORD_RESET_CODE_INVALID = 'PASSWORD_RESET_CODE_INVALID', - VERIFICATION_EMAIL_REQUIRED = 'VERIFICATION_EMAIL_REQUIRED', - INVALID_EMAIL = 'INVALID_EMAIL' -} +const errorCodes = [ + 'INVALID_CREDENTIALS', + 'VERIFICATION_ALIAS_NOT_FOUND', + 'VERIFICATION_CODE_INVALID', + 'IDENTIFIER_TAKEN', + 'MISSING_SESSION', + 'INVALID_SESSION', + 'NEW_PASSWORD_MATCHES_CURRENT_PASSWORD', + 'NEW_EMAIL_MATCHES_CURRENT_EMAIL', + 'PASSWORD_RESET_CODE_INVALID', + 'VERIFICATION_EMAIL_REQUIRED', + 'INVALID_EMAIL' +] as const + +export type PlayerAuthErrorCode = typeof errorCodes[number] @Entity() export default class PlayerAuth { diff --git a/src/middlewares/player-auth-middleware.ts b/src/middlewares/player-auth-middleware.ts index 42c53649..655e4088 100644 --- a/src/middlewares/player-auth-middleware.ts +++ b/src/middlewares/player-auth-middleware.ts @@ -4,7 +4,6 @@ import { isAPIRoute } from './route-middleware' import { EntityManager } from '@mikro-orm/mysql' import PlayerAlias, { PlayerAliasService } from '../entities/player-alias' import { promisify } from 'util' -import { PlayerAuthErrorCode } from '../entities/player-auth' export default async function playerAuthMiddleware(ctx: Context, next: Next): Promise { if (isAPIRoute(ctx) && (ctx.state.currentPlayerId || ctx.state.currentAliasId)) { @@ -38,7 +37,7 @@ export async function validateAuthSessionToken(ctx: Context, alias: PlayerAlias) if (!sessionToken) { ctx.throw(401, { message: 'The x-talo-session header is required for this player', - errorCode: PlayerAuthErrorCode.MISSING_SESSION + errorCode: 'MISSING_SESSION' }) } @@ -49,7 +48,7 @@ export async function validateAuthSessionToken(ctx: Context, alias: PlayerAlias) } catch (err) { ctx.throw(401, { message: 'The x-talo-session header is invalid', - errorCode: PlayerAuthErrorCode.INVALID_SESSION + errorCode: 'INVALID_SESSION' }) } } diff --git a/src/services/api/player-auth-api.service.ts b/src/services/api/player-auth-api.service.ts index 413646fe..ce9928b8 100644 --- a/src/services/api/player-auth-api.service.ts +++ b/src/services/api/player-auth-api.service.ts @@ -4,7 +4,7 @@ import APIKey from '../../entities/api-key' import PlayerAlias, { PlayerAliasService } from '../../entities/player-alias' import APIService from './api-service' import { createPlayerFromIdentifyRequest, findAliasFromIdentifyRequest } from './player-api.service' -import PlayerAuth, { PlayerAuthErrorCode } from '../../entities/player-auth' +import PlayerAuth from '../../entities/player-auth' import bcrypt from 'bcrypt' import PlayerAuthAPIPolicy from '../../policies/api/player-auth-api.policy' import PlayerAuthAPIDocs from '../../docs/player-auth-api.docs' @@ -114,7 +114,7 @@ export default class PlayerAuthAPIService extends APIService { } else { req.ctx.throw(400, { message: 'Invalid email address', - errorCode: PlayerAuthErrorCode.INVALID_EMAIL + errorCode: 'INVALID_EMAIL' }) } } else { @@ -147,7 +147,7 @@ export default class PlayerAuthAPIService extends APIService { } private handleFailedLogin(req: Request) { - req.ctx.throw(401, { message: 'Incorrect identifier or password', errorCode: PlayerAuthErrorCode.INVALID_CREDENTIALS }) + req.ctx.throw(401, { message: 'Incorrect identifier or password', errorCode: 'INVALID_CREDENTIALS' }) } private getRedisAuthKey(key: APIKey, alias: PlayerAlias): string { @@ -234,7 +234,7 @@ export default class PlayerAuthAPIService extends APIService { if (!alias) { req.ctx.throw(403, { message: 'Player alias not found', - errorCode: PlayerAuthErrorCode.VERIFICATION_ALIAS_NOT_FOUND + errorCode: 'VERIFICATION_ALIAS_NOT_FOUND' }) } @@ -249,7 +249,7 @@ export default class PlayerAuthAPIService extends APIService { req.ctx.throw(403, { message: 'Invalid code', - errorCode: PlayerAuthErrorCode.VERIFICATION_CODE_INVALID + errorCode: 'VERIFICATION_CODE_INVALID' }) } @@ -316,14 +316,14 @@ export default class PlayerAuthAPIService extends APIService { createPlayerAuthActivity(req, alias.player, { type: PlayerAuthActivityType.CHANGE_PASSWORD_FAILED, extra: { - errorCode: PlayerAuthErrorCode.INVALID_CREDENTIALS + errorCode: 'INVALID_CREDENTIALS' } }) await em.flush() req.ctx.throw(403, { message: 'Current password is incorrect', - errorCode: PlayerAuthErrorCode.INVALID_CREDENTIALS + errorCode: 'INVALID_CREDENTIALS' }) } @@ -332,14 +332,14 @@ export default class PlayerAuthAPIService extends APIService { createPlayerAuthActivity(req, alias.player, { type: PlayerAuthActivityType.CHANGE_PASSWORD_FAILED, extra: { - errorCode: PlayerAuthErrorCode.NEW_PASSWORD_MATCHES_CURRENT_PASSWORD + errorCode: 'NEW_PASSWORD_MATCHES_CURRENT_PASSWORD' } }) await em.flush() req.ctx.throw(400, { message: 'Please choose a different password', - errorCode: PlayerAuthErrorCode.NEW_PASSWORD_MATCHES_CURRENT_PASSWORD + errorCode: 'NEW_PASSWORD_MATCHES_CURRENT_PASSWORD' }) } @@ -374,14 +374,14 @@ export default class PlayerAuthAPIService extends APIService { createPlayerAuthActivity(req, alias.player, { type: PlayerAuthActivityType.CHANGE_EMAIL_FAILED, extra: { - errorCode: PlayerAuthErrorCode.INVALID_CREDENTIALS + errorCode: 'INVALID_CREDENTIALS' } }) await em.flush() req.ctx.throw(403, { message: 'Current password is incorrect', - errorCode: PlayerAuthErrorCode.INVALID_CREDENTIALS + errorCode: 'INVALID_CREDENTIALS' }) } @@ -390,14 +390,14 @@ export default class PlayerAuthAPIService extends APIService { createPlayerAuthActivity(req, alias.player, { type: PlayerAuthActivityType.CHANGE_EMAIL_FAILED, extra: { - errorCode: PlayerAuthErrorCode.NEW_EMAIL_MATCHES_CURRENT_EMAIL + errorCode: 'NEW_EMAIL_MATCHES_CURRENT_EMAIL' } }) await em.flush() req.ctx.throw(400, { message: 'Please choose a different email address', - errorCode: PlayerAuthErrorCode.NEW_EMAIL_MATCHES_CURRENT_EMAIL + errorCode: 'NEW_EMAIL_MATCHES_CURRENT_EMAIL' }) } @@ -409,14 +409,14 @@ export default class PlayerAuthAPIService extends APIService { createPlayerAuthActivity(req, alias.player, { type: PlayerAuthActivityType.CHANGE_EMAIL_FAILED, extra: { - errorCode: PlayerAuthErrorCode.INVALID_EMAIL + errorCode: 'INVALID_EMAIL' } }) await em.flush() req.ctx.throw(400, { message: 'Invalid email address', - errorCode: PlayerAuthErrorCode.INVALID_EMAIL + errorCode: 'INVALID_EMAIL' }) } @@ -498,7 +498,7 @@ export default class PlayerAuthAPIService extends APIService { if (!aliasId || !alias) { req.ctx.throw(401, { message: 'This code is either invalid or has expired', - errorCode: PlayerAuthErrorCode.PASSWORD_RESET_CODE_INVALID + errorCode: 'PASSWORD_RESET_CODE_INVALID' }) } @@ -536,7 +536,7 @@ export default class PlayerAuthAPIService extends APIService { createPlayerAuthActivity(req, alias.player, { type: PlayerAuthActivityType.TOGGLE_VERIFICATION_FAILED, extra: { - errorCode: PlayerAuthErrorCode.VERIFICATION_EMAIL_REQUIRED, + errorCode: 'VERIFICATION_EMAIL_REQUIRED', verificationEnabled: Boolean(verificationEnabled) } }) @@ -544,7 +544,7 @@ export default class PlayerAuthAPIService extends APIService { req.ctx.throw(400, { message: 'An email address is required to enable verification', - errorCode: PlayerAuthErrorCode.VERIFICATION_EMAIL_REQUIRED + errorCode: 'VERIFICATION_EMAIL_REQUIRED' }) } @@ -553,7 +553,7 @@ export default class PlayerAuthAPIService extends APIService { createPlayerAuthActivity(req, alias.player, { type: PlayerAuthActivityType.TOGGLE_VERIFICATION_FAILED, extra: { - errorCode: PlayerAuthErrorCode.INVALID_CREDENTIALS, + errorCode: 'INVALID_CREDENTIALS', verificationEnabled: Boolean(verificationEnabled) } }) @@ -561,7 +561,7 @@ export default class PlayerAuthAPIService extends APIService { req.ctx.throw(403, { message: 'Current password is incorrect', - errorCode: PlayerAuthErrorCode.INVALID_CREDENTIALS + errorCode: 'INVALID_CREDENTIALS' }) } @@ -574,7 +574,7 @@ export default class PlayerAuthAPIService extends APIService { createPlayerAuthActivity(req, alias.player, { type: PlayerAuthActivityType.TOGGLE_VERIFICATION_FAILED, extra: { - errorCode: PlayerAuthErrorCode.INVALID_EMAIL, + errorCode: 'INVALID_EMAIL', verificationEnabled: Boolean(verificationEnabled) } }) @@ -582,7 +582,7 @@ export default class PlayerAuthAPIService extends APIService { req.ctx.throw(400, { message: 'Invalid email address', - errorCode: PlayerAuthErrorCode.INVALID_EMAIL + errorCode: 'INVALID_EMAIL' }) } } @@ -619,14 +619,14 @@ export default class PlayerAuthAPIService extends APIService { createPlayerAuthActivity(req, alias.player, { type: PlayerAuthActivityType.DELETE_AUTH_FAILED, extra: { - errorCode: PlayerAuthErrorCode.INVALID_CREDENTIALS + errorCode: 'INVALID_CREDENTIALS' } }) await em.flush() req.ctx.throw(403, { message: 'Current password is incorrect', - errorCode: PlayerAuthErrorCode.INVALID_CREDENTIALS + errorCode: 'INVALID_CREDENTIALS' }) } diff --git a/src/services/player.service.ts b/src/services/player.service.ts index 7878e91b..ba05b178 100644 --- a/src/services/player.service.ts +++ b/src/services/player.service.ts @@ -15,7 +15,6 @@ import { devDataPlayerFilter } from '../middlewares/dev-data-middleware' import PlayerProp from '../entities/player-prop' import PlayerGroup from '../entities/player-group' import GameSave from '../entities/game-save' -import { PlayerAuthErrorCode } from '../entities/player-auth' import PlayerAuthActivity from '../entities/player-auth-activity' import createClickhouseClient from '../lib/clickhouse/createClient' @@ -95,7 +94,7 @@ export default class PlayerService extends Service { if (count > 0) { req.ctx.throw(400, { message: `Player with identifier '${alias.identifier}' already exists`, - errorCode: PlayerAuthErrorCode.IDENTIFIER_TAKEN + errorCode: 'IDENTIFIER_TAKEN' }) } } diff --git a/src/socket/messages/socketError.ts b/src/socket/messages/socketError.ts index 836c259b..3be75dd2 100644 --- a/src/socket/messages/socketError.ts +++ b/src/socket/messages/socketError.ts @@ -2,7 +2,7 @@ import { captureException, setTag } from '@sentry/node' import { sendMessage, SocketMessageRequest } from './socketMessage' import SocketConnection from '../socketConnection' -const codes = [ +const errorCodes = [ 'INVALID_MESSAGE', 'INVALID_MESSAGE_DATA', 'NO_PLAYER_FOUND', @@ -14,7 +14,7 @@ const codes = [ 'MISSING_ACCESS_KEY_SCOPES' ] as const -export type SocketErrorCode = typeof codes[number] +export type SocketErrorCode = typeof errorCodes[number] export default class SocketError { constructor(public code: SocketErrorCode, public message: string, public cause?: string) {} From 72455b1f68bd4f3010b9c225ac8e2d124183cd7b Mon Sep 17 00:00:00 2001 From: tudor <7089284+tudddorrr@users.noreply.github.com> Date: Tue, 10 Dec 2024 10:57:08 +0000 Subject: [PATCH 10/15] test fixes --- src/docs/game-channel-api.docs.ts | 77 +++---------------- src/entities/game-channel.ts | 4 +- src/middlewares/player-auth-middleware.ts | 17 +++- src/services/api/game-channel-api.service.ts | 8 +- src/socket/index.ts | 2 +- src/socket/listeners/gameChannelListeners.ts | 8 +- src/socket/listeners/playerListeners.ts | 23 ++++-- src/socket/socketConnection.ts | 13 +++- .../gameChannelListeners/message.test.ts | 32 ++++++-- .../playerListeners/identify.test.ts | 4 + tests/socket/router.test.ts | 12 ++- 11 files changed, 104 insertions(+), 96 deletions(-) diff --git a/src/docs/game-channel-api.docs.ts b/src/docs/game-channel-api.docs.ts index 7b88896c..6ae1b514 100644 --- a/src/docs/game-channel-api.docs.ts +++ b/src/docs/game-channel-api.docs.ts @@ -12,7 +12,9 @@ const GameChannelAPIDocs: APIDocs = { { id: 1, name: 'general-chat', - owner: null, + ownerAliasId: null, + totalMessages: 308, + memberCount: 42, props: [ { key: 'channelType', value: 'public' } ], @@ -22,29 +24,7 @@ const GameChannelAPIDocs: APIDocs = { { id: 2, name: 'guild-chat', - owner: { - id: 1, - service: 'username', - identifier: 'guild_admin_bob', - player: { - id: '7a4e70ec-6ee6-418e-923d-b3a45051b7f9', - props: [ - { key: 'currentLevel', value: '58' }, - { key: 'xPos', value: '13.29' }, - { key: 'yPos', value: '26.44' }, - { key: 'zoneId', value: '3' } - ], - aliases: [ - '/* [Circular] */' - ], - devBuild: false, - createdAt: '2022-01-15T13:20:32.133Z', - lastSeenAt: '2022-04-12T15:09:43.066Z', - groups: [ - { id: '5826ca71-1964-4a1b-abcb-a61ffbe003be', name: 'Winners' } - ] - } - }, + ownerAliasId: 1, props: [ { key: 'channelType', value: 'guild' }, { key: 'guildId', value: '5912' } @@ -84,29 +64,9 @@ const GameChannelAPIDocs: APIDocs = { channel: { id: 1, name: 'general-chat', - owner: { - id: 1, - service: 'username', - identifier: 'guild_admin_bob', - player: { - id: '7a4e70ec-6ee6-418e-923d-b3a45051b7f9', - props: [ - { key: 'currentLevel', value: '58' }, - { key: 'xPos', value: '13.29' }, - { key: 'yPos', value: '26.44' }, - { key: 'zoneId', value: '3' } - ], - aliases: [ - '/* [Circular] */' - ], - devBuild: false, - createdAt: '2022-01-15T13:20:32.133Z', - lastSeenAt: '2022-04-12T15:09:43.066Z', - groups: [ - { id: '5826ca71-1964-4a1b-abcb-a61ffbe003be', name: 'Winners' } - ] - } - }, + ownerAliasId: 1, + totalMessages: 0, + memberCount: 1, props: [ { key: 'channelType', value: 'public' } ], @@ -141,6 +101,8 @@ const GameChannelAPIDocs: APIDocs = { id: 1, name: 'general-chat', owner: null, + totalMessages: 308, + memberCount: 42, props: [ { key: 'channelType', value: 'public' } ], @@ -195,24 +157,9 @@ const GameChannelAPIDocs: APIDocs = { channel: { id: 1, name: 'new-general-chat', - owner: { - id: 2, - service: 'username', - identifier: 'new_admin_john', - player: { - id: '7a4e70ec-6ee6-418e-923d-b3a45051b7f9', - props: [], - aliases: [ - '/* [Circular] */' - ], - devBuild: false, - createdAt: '2022-01-15T13:20:32.133Z', - lastSeenAt: '2022-04-12T15:09:43.066Z', - groups: [ - { id: '5826ca71-1964-4a1b-abcb-a61ffbe003be', name: 'Winners' } - ] - } - }, + ownerAliasId: 2, + totalMessages: 308, + memberCount: 42, props: [ { key: 'channelType', value: 'public' }, { key: 'recentlyUpdated', value: 'true' } diff --git a/src/entities/game-channel.ts b/src/entities/game-channel.ts index 5a602aea..bb75fdb1 100644 --- a/src/entities/game-channel.ts +++ b/src/entities/game-channel.ts @@ -62,7 +62,7 @@ export default class GameChannel { return { id: this.id, name: this.name, - owner: this.owner, + ownerAliasId: this.owner.id, totalMessages: this.totalMessages, props: this.props, createdAt: this.createdAt, @@ -73,7 +73,7 @@ export default class GameChannel { async toJSONWithCount(em: EntityManager, includeDevData: boolean) { return { ...this.toJSON(), - count: await this.members.loadCount({ + memberCount: await this.members.loadCount({ where: { player: includeDevData ? {} : devDataPlayerFilter(em) } diff --git a/src/middlewares/player-auth-middleware.ts b/src/middlewares/player-auth-middleware.ts index 655e4088..5241ab2a 100644 --- a/src/middlewares/player-auth-middleware.ts +++ b/src/middlewares/player-auth-middleware.ts @@ -42,7 +42,13 @@ export async function validateAuthSessionToken(ctx: Context, alias: PlayerAlias) } try { - if (!await validateSessionTokenJWT(sessionToken as string, alias)) { + const valid = await validateSessionTokenJWT( + sessionToken as string, + alias, + ctx.state.currentPlayerId, + ctx.state.currentAliasId + ) + if (!valid) { throw new Error() } } catch (err) { @@ -53,7 +59,12 @@ export async function validateAuthSessionToken(ctx: Context, alias: PlayerAlias) } } -export async function validateSessionTokenJWT(sessionToken: string, alias: PlayerAlias): Promise { +export async function validateSessionTokenJWT( + sessionToken: string, + alias: PlayerAlias, + expectedPlayerId: string, + expectedAliasId: number +): Promise { const payload = await promisify(jwt.verify)(sessionToken, alias.player.auth.sessionKey) - return payload.playerId === alias.player.id && payload.aliasId === alias.id + return payload.playerId === expectedPlayerId && payload.aliasId === expectedAliasId } diff --git a/src/services/api/game-channel-api.service.ts b/src/services/api/game-channel-api.service.ts index 37506a88..45cd73bd 100644 --- a/src/services/api/game-channel-api.service.ts +++ b/src/services/api/game-channel-api.service.ts @@ -76,7 +76,7 @@ export default class GameChannelAPIService extends APIService { return { status: 200, body: { - channels + channels: await Promise.all(channels.map((channel) => channel.toJSONWithCount(req.ctx.em, req.ctx.state.includeDevData))) } } } @@ -110,7 +110,7 @@ export default class GameChannelAPIService extends APIService { return { status: 200, body: { - channel + channel: await channel.toJSONWithCount(em, req.ctx.state.includeDevData) } } } @@ -144,7 +144,7 @@ export default class GameChannelAPIService extends APIService { return { status: 200, body: { - channel + channel: await channel.toJSONWithCount(em, req.ctx.state.includeDevData) } } } @@ -249,7 +249,7 @@ export default class GameChannelAPIService extends APIService { return { status: 200, body: { - channel + channel: await channel.toJSONWithCount(em, req.ctx.state.includeDevData) } } } diff --git a/src/socket/index.ts b/src/socket/index.ts index 068c161b..f1300169 100644 --- a/src/socket/index.ts +++ b/src/socket/index.ts @@ -54,7 +54,7 @@ export default class Socket { await RequestContext.create(this.em, async () => { const key = await authenticateSocket(req.headers?.authorization ?? '', ws) if (key) { - this.connections.push(new SocketConnection(ws, key)) + this.connections.push(new SocketConnection(ws, key, req)) sendMessage(this.connections.at(-1), 'v1.connected', {}) } }) diff --git a/src/socket/listeners/gameChannelListeners.ts b/src/socket/listeners/gameChannelListeners.ts index 6c1a8620..1def7314 100644 --- a/src/socket/listeners/gameChannelListeners.ts +++ b/src/socket/listeners/gameChannelListeners.ts @@ -10,14 +10,16 @@ const gameChannelListeners: SocketMessageListener[] = [ createListener( 'v1.channels.message', z.object({ - channelName: z.string(), + channel: z.object({ + id: z.number() + }), message: z.string() }), async ({ conn, data, socket }) => { const channel = await (RequestContext.getEntityManager() .getRepository(GameChannel) .findOne({ - name: data.channelName, + id: data.channel.id, game: conn.game }, { populate: ['members'] @@ -35,7 +37,7 @@ const gameChannelListeners: SocketMessageListener[] = [ channel.members.getIdentifiers().includes(conn.playerAlias.id) }) sendMessages(conns, 'v1.channels.message', { - channelName: channel.name, + channel, message: data.message, fromPlayerAlias: conn.playerAlias }) diff --git a/src/socket/listeners/playerListeners.ts b/src/socket/listeners/playerListeners.ts index 7b3e5e8c..897cca86 100644 --- a/src/socket/listeners/playerListeners.ts +++ b/src/socket/listeners/playerListeners.ts @@ -21,9 +21,12 @@ const playerListeners: SocketMessageListener[] = [ async ({ conn, req, data }) => { const redis = new Redis(redisConfig) const token = await redis.get(`socketTokens.${data.playerAliasId}`) + await redis.quit() + + let alias: PlayerAlias if (token === data.socketToken) { - conn.playerAlias = await (RequestContext.getEntityManager() + alias = await (RequestContext.getEntityManager() .getRepository(PlayerAlias) .findOne({ id: data.playerAliasId, @@ -34,23 +37,29 @@ const playerListeners: SocketMessageListener[] = [ populate: ['player.auth'] })) - if (conn.playerAlias.service === PlayerAliasService.TALO) { + if (alias.service === PlayerAliasService.TALO) { try { - if (!await validateSessionTokenJWT(data.sessionToken, conn.playerAlias)) { + const valid = await validateSessionTokenJWT( + data.sessionToken, + alias, + conn.getPlayerFromHeader(), + conn.getAliasFromHeader() + ) + if (!valid) { throw new Error() } - sendMessage(conn, 'v1.players.identify.success', conn.playerAlias) } catch (err) { sendError(conn, req, new SocketError('INVALID_SESSION', 'Session token is invalid')) + return } - } else { - sendMessage(conn, 'v1.players.identify.success', conn.playerAlias) } } else { sendError(conn, req, new SocketError('INVALID_SOCKET_TOKEN', 'Invalid socket token')) + return } - await redis.quit() + conn.playerAlias = alias + sendMessage(conn, 'v1.players.identify.success', alias) }, { requirePlayer: false, diff --git a/src/socket/socketConnection.ts b/src/socket/socketConnection.ts index d4038a15..a272c6ea 100644 --- a/src/socket/socketConnection.ts +++ b/src/socket/socketConnection.ts @@ -2,15 +2,26 @@ 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' export default class SocketConnection { alive: boolean = true playerAlias: PlayerAlias | null = null game: Game | null = null scopes: APIKeyScope[] = [] + headers: IncomingHttpHeaders = {} - constructor(readonly ws: WebSocket, apiKey: APIKey) { + constructor(readonly ws: WebSocket, apiKey: APIKey, req: IncomingMessage) { this.game = apiKey.game this.scopes = apiKey.scopes + this.headers = req.headers + } + + getPlayerFromHeader(): string | null { + return this.headers['x-talo-player'] as string ?? null + } + + getAliasFromHeader(): number | null { + return this.headers['x-talo-alias'] ? Number(this.headers['x-talo-alias']) : null } } diff --git a/tests/socket/listeners/gameChannelListeners/message.test.ts b/tests/socket/listeners/gameChannelListeners/message.test.ts index 37278d6f..88b4056f 100644 --- a/tests/socket/listeners/gameChannelListeners/message.test.ts +++ b/tests/socket/listeners/gameChannelListeners/message.test.ts @@ -17,7 +17,11 @@ describe('Game channel listeners - message', () => { }) it('should successfully send a message', async () => { - const [identifyMessage, token, player] = await createSocketIdentifyMessage([APIKeyScope.READ_PLAYERS, APIKeyScope.WRITE_GAME_CHANNELS]) + const [identifyMessage, token, player] = await createSocketIdentifyMessage([ + APIKeyScope.READ_PLAYERS, + APIKeyScope.READ_GAME_CHANNELS, + APIKeyScope.WRITE_GAME_CHANNELS + ]) const channel = await new GameChannelFactory(player.game).one() channel.members.add(player.aliases[0]) await (global.em).persistAndFlush(channel) @@ -31,20 +35,26 @@ describe('Game channel listeners - message', () => { .sendJson({ req: 'v1.channels.message', data: { - channelName: channel.name, + channel: { + id: channel.id + }, message: 'Hello world' } }) .expectJson((actual) => { expect(actual.res).toBe('v1.channels.message') - expect(actual.data.channelName).toBe(channel.name) + expect(actual.data.channel.id).toBe(channel.id) expect(actual.data.message).toBe('Hello world') expect(actual.data.fromPlayerAlias.id).toBe(player.aliases[0].id) }) }) it('should receive an error if the player is not in the channel', async () => { - const [identifyMessage, token, player] = await createSocketIdentifyMessage([APIKeyScope.READ_PLAYERS, APIKeyScope.WRITE_GAME_CHANNELS]) + const [identifyMessage, token, player] = await createSocketIdentifyMessage([ + APIKeyScope.READ_PLAYERS, + APIKeyScope.READ_GAME_CHANNELS, + APIKeyScope.WRITE_GAME_CHANNELS + ]) const channel = await new GameChannelFactory(player.game).one() await (global.em).persistAndFlush(channel) @@ -57,7 +67,9 @@ describe('Game channel listeners - message', () => { .sendJson({ req: 'v1.channels.message', data: { - channelName: channel.name, + channel: { + id: channel.id + }, message: 'Hello world' } }) @@ -74,7 +86,11 @@ describe('Game channel listeners - message', () => { }) it('should receive an error if the channel does not exist', async () => { - const [identifyMessage, token] = await createSocketIdentifyMessage([APIKeyScope.READ_PLAYERS, APIKeyScope.WRITE_GAME_CHANNELS]) + const [identifyMessage, token] = await createSocketIdentifyMessage([ + APIKeyScope.READ_PLAYERS, + APIKeyScope.READ_GAME_CHANNELS, + APIKeyScope.WRITE_GAME_CHANNELS + ]) await request(global.server) .ws('/') @@ -85,7 +101,9 @@ describe('Game channel listeners - message', () => { .sendJson({ req: 'v1.channels.message', data: { - channelName: 'Guild chat', + channel: { + id: 999 + }, message: 'Hello world' } }) diff --git a/tests/socket/listeners/playerListeners/identify.test.ts b/tests/socket/listeners/playerListeners/identify.test.ts index 084eaed3..4ba79ddf 100644 --- a/tests/socket/listeners/playerListeners/identify.test.ts +++ b/tests/socket/listeners/playerListeners/identify.test.ts @@ -82,6 +82,8 @@ describe('Player listeners - identify', () => { await request(global.server) .ws('/') .set('authorization', `Bearer ${token}`) + .set('x-talo-player', player.id) + .set('x-talo-alias', player.aliases[0].id.toString()) .expectJson({ res: 'v1.connected', data: {} @@ -115,6 +117,8 @@ describe('Player listeners - identify', () => { await request(global.server) .ws('/') .set('authorization', `Bearer ${token}`) + .set('x-talo-player', player.id) + .set('x-talo-alias', player.aliases[0].id.toString()) .expectJson({ res: 'v1.connected', data: {} diff --git a/tests/socket/router.test.ts b/tests/socket/router.test.ts index a8565a50..28f4ee25 100644 --- a/tests/socket/router.test.ts +++ b/tests/socket/router.test.ts @@ -77,7 +77,9 @@ describe('Socket router', () => { .sendJson({ req: 'v1.channels.message', data: { - channelName: 'general', + channel: { + id: 1 + }, message: 'Hello, world!' } }) @@ -107,7 +109,9 @@ describe('Socket router', () => { .sendJson({ req: 'v1.channels.message', data: { - channelName: 'general', + channel: { + id: 1 + }, message: 'Hello, world!' } }) @@ -137,7 +141,9 @@ describe('Socket router', () => { .sendJson({ req: 'v1.channels.message', data: { - channelName: 'general', + channel: { + id: 1 + }, myMessageToTheChannelIsGoingToBeThis: 'Hello, world!' } }) From a51acbfaea4a5d30a198c18c544a1de959a988a9 Mon Sep 17 00:00:00 2001 From: tudor <7089284+tudddorrr@users.noreply.github.com> Date: Tue, 10 Dec 2024 23:25:02 +0000 Subject: [PATCH 11/15] api changes + frontend index handler --- src/docs/game-channel-api.docs.ts | 92 +++++++++++++++---- src/entities/game-channel.ts | 6 +- src/entities/player-alias.ts | 2 +- src/migrations/.snapshot-gs_dev.json | 3 +- .../20241206233511CreateGameChannelTables.ts | 2 +- src/policies/api/game-channel-api.policy.ts | 33 +++++-- src/services/api/game-channel-api.service.ts | 62 +++---------- src/services/game-channel.service.ts | 37 +++++++- src/socket/listeners/gameChannelListeners.ts | 8 +- src/socket/listeners/playerListeners.ts | 2 +- src/socket/router/socketRouter.ts | 16 ++-- src/socket/socketConnection.ts | 22 ++++- .../player-api/steamworksIdentify.test.ts | 12 +-- .../gameChannelListeners/message.test.ts | 2 +- 14 files changed, 189 insertions(+), 110 deletions(-) diff --git a/src/docs/game-channel-api.docs.ts b/src/docs/game-channel-api.docs.ts index 6ae1b514..4bca1861 100644 --- a/src/docs/game-channel-api.docs.ts +++ b/src/docs/game-channel-api.docs.ts @@ -4,6 +4,11 @@ import APIDocs from './api-docs' const GameChannelAPIDocs: APIDocs = { index: { description: 'List game channels', + params: { + query: { + page: 'The current pagination index (starting at 0)' + } + }, samples: [ { title: 'Sample response', @@ -12,27 +17,44 @@ const GameChannelAPIDocs: APIDocs = { { id: 1, name: 'general-chat', - ownerAliasId: null, + owner: null, totalMessages: 308, memberCount: 42, props: [ { key: 'channelType', value: 'public' } ], - createdAt: '2024-12-09T12:00:00Z', - updatedAt: '2024-12-09T12:00:00Z' + createdAt: '2024-12-09T12:00:00.000Z', + updatedAt: '2024-12-09T12:00:00.000Z' }, { id: 2, name: 'guild-chat', - ownerAliasId: 1, + owner: { + id: 105, + service: 'username', + identifier: 'johnny_the_admin', + player: { + id: '85d67584-1346-4fad-a17f-fd7bd6c85364', + props: [], + devBuild: false, + createdAt: '2024-10-25T18:18:28.000Z', + lastSeenAt: '2024-12-04T07:15:13.000Z', + groups: [] + }, + lastSeenAt: '2024-12-04T07:15:13.000Z', + createdAt: '2024-10-25T18:18:28.000Z', + updatedAt: '2024-12-04T07:15:13.000Z' + }, props: [ { key: 'channelType', value: 'guild' }, { key: 'guildId', value: '5912' } ], - createdAt: '2024-12-09T12:00:00Z', - updatedAt: '2024-12-09T12:00:00Z' + createdAt: '2024-12-09T12:00:00.000Z', + updatedAt: '2024-12-09T12:00:00.000Z' } - ] + ], + count: 2, + itemsPerPage: 50 } } ] @@ -52,9 +74,10 @@ const GameChannelAPIDocs: APIDocs = { { title: 'Sample request', sample: { - name: 'general-chat', + name: 'guild-chat', props: [ - { key: 'channelType', value: 'public' } + { key: 'channelType', value: 'guild' }, + { key: 'guildId', value: '5912' } ] } }, @@ -63,15 +86,31 @@ const GameChannelAPIDocs: APIDocs = { sample: { channel: { id: 1, - name: 'general-chat', - ownerAliasId: 1, + name: 'guild-chat', + owner: { + id: 105, + service: 'username', + identifier: 'johnny_the_admin', + player: { + id: '85d67584-1346-4fad-a17f-fd7bd6c85364', + props: [], + devBuild: false, + createdAt: '2024-10-25T18:18:28.000Z', + lastSeenAt: '2024-12-04T07:15:13.000Z', + groups: [] + }, + lastSeenAt: '2024-12-04T07:15:13.000Z', + createdAt: '2024-10-25T18:18:28.000Z', + updatedAt: '2024-12-04T07:15:13.000Z' + }, totalMessages: 0, memberCount: 1, props: [ - { key: 'channelType', value: 'public' } + { key: 'channelType', value: 'guild' }, + { key: 'guildId', value: '5912' } ], - createdAt: '2024-12-09T12:00:00Z', - updatedAt: '2024-12-09T12:00:00Z' + createdAt: '2024-12-09T12:00:00.000Z', + updatedAt: '2024-12-09T12:00:00.000Z' } } } @@ -106,8 +145,8 @@ const GameChannelAPIDocs: APIDocs = { props: [ { key: 'channelType', value: 'public' } ], - createdAt: '2024-12-09T12:00:00Z', - updatedAt: '2024-12-09T12:00:00Z' + createdAt: '2024-12-09T12:00:00.000Z', + updatedAt: '2024-12-09T12:00:00.000Z' } } } @@ -157,15 +196,30 @@ const GameChannelAPIDocs: APIDocs = { channel: { id: 1, name: 'new-general-chat', - ownerAliasId: 2, + owner: { + id: 2, + service: 'username', + identifier: 'general_chat_admin', + player: { + id: '85d67584-1346-4fad-a17f-fd7bd6c85364', + props: [], + devBuild: false, + createdAt: '2024-10-25T18:18:28.000Z', + lastSeenAt: '2024-12-04T07:15:13.000Z', + groups: [] + }, + lastSeenAt: '2024-12-04T07:15:13.000Z', + createdAt: '2024-10-25T18:18:28.000Z', + updatedAt: '2024-12-04T07:15:13.000Z' + }, totalMessages: 308, memberCount: 42, props: [ { key: 'channelType', value: 'public' }, { key: 'recentlyUpdated', value: 'true' } ], - createdAt: '2024-12-09T12:00:00Z', - updatedAt: '2024-12-09T12:01:00Z' + createdAt: '2024-12-09T12:00:00.000Z', + updatedAt: '2024-12-09T12:01:00.000Z' } } } diff --git a/src/entities/game-channel.ts b/src/entities/game-channel.ts index bb75fdb1..c152e622 100644 --- a/src/entities/game-channel.ts +++ b/src/entities/game-channel.ts @@ -1,4 +1,4 @@ -import { Cascade, Collection, Embedded, Entity, EntityManager, ManyToMany, ManyToOne, PrimaryKey, Property } from '@mikro-orm/mysql' +import { Collection, Embedded, Entity, EntityManager, ManyToMany, ManyToOne, PrimaryKey, Property } from '@mikro-orm/mysql' import PlayerAlias from './player-alias' import Game from './game' import Prop from './prop' @@ -29,7 +29,7 @@ export default class GameChannel { @Property() name: string - @ManyToOne(() => PlayerAlias, { cascade: [Cascade.REMOVE] }) + @ManyToOne(() => PlayerAlias, { nullable: true, eager: true }) owner: PlayerAlias @ManyToMany(() => PlayerAlias, (alias) => alias.channels, { owner: true }) @@ -62,7 +62,7 @@ export default class GameChannel { return { id: this.id, name: this.name, - ownerAliasId: this.owner.id, + owner: this.owner, totalMessages: this.totalMessages, props: this.props, createdAt: this.createdAt, diff --git a/src/entities/player-alias.ts b/src/entities/player-alias.ts index 007fdf87..9fc87279 100644 --- a/src/entities/player-alias.ts +++ b/src/entities/player-alias.ts @@ -45,7 +45,7 @@ export default class PlayerAlias { async createSocketToken(redis: Redis): Promise { const token = v4() - await redis.set(`socketTokens.${this.id}`, token) + await redis.set(`socketTokens.${this.id}`, token, 'EX', 3600) return token } diff --git a/src/migrations/.snapshot-gs_dev.json b/src/migrations/.snapshot-gs_dev.json index 992e2ec2..5806f6dc 100644 --- a/src/migrations/.snapshot-gs_dev.json +++ b/src/migrations/.snapshot-gs_dev.json @@ -2608,7 +2608,8 @@ "id" ], "referencedTableName": "player_alias", - "deleteRule": "cascade" + "deleteRule": "set null", + "updateRule": "cascade" }, "game_channel_game_id_foreign": { "constraintName": "game_channel_game_id_foreign", diff --git a/src/migrations/20241206233511CreateGameChannelTables.ts b/src/migrations/20241206233511CreateGameChannelTables.ts index 4e248def..fa45d8ee 100644 --- a/src/migrations/20241206233511CreateGameChannelTables.ts +++ b/src/migrations/20241206233511CreateGameChannelTables.ts @@ -11,7 +11,7 @@ export class CreateGameChannelTables extends Migration { this.addSql('alter table `game_channel_members` add index `game_channel_members_game_channel_id_index`(`game_channel_id`);') this.addSql('alter table `game_channel_members` add index `game_channel_members_player_alias_id_index`(`player_alias_id`);') - this.addSql('alter table `game_channel` add constraint `game_channel_owner_id_foreign` foreign key (`owner_id`) references `player_alias` (`id`) on delete cascade;') + this.addSql('alter table `game_channel` add constraint `game_channel_owner_id_foreign` foreign key (`owner_id`) references `player_alias` (`id`) on update cascade on delete set null;') this.addSql('alter table `game_channel` add constraint `game_channel_game_id_foreign` foreign key (`game_id`) references `game` (`id`) on update cascade;') this.addSql('alter table `game_channel_members` add constraint `game_channel_members_game_channel_id_foreign` foreign key (`game_channel_id`) references `game_channel` (`id`) on update cascade on delete cascade;') diff --git a/src/policies/api/game-channel-api.policy.ts b/src/policies/api/game-channel-api.policy.ts index 57b11956..9cd1c068 100644 --- a/src/policies/api/game-channel-api.policy.ts +++ b/src/policies/api/game-channel-api.policy.ts @@ -1,13 +1,14 @@ import Policy from '../policy' -import { PolicyDenial, PolicyResponse } from 'koa-clay' +import { PolicyDenial, PolicyResponse, Request } from 'koa-clay' import { APIKeyScope } from '../../entities/api-key' import PlayerAlias from '../../entities/player-alias' import { EntityManager } from '@mikro-orm/mysql' +import GameChannel from '../../entities/game-channel' export default class GameChannelAPIPolicy extends Policy { async getAlias(): Promise { const em: EntityManager = this.ctx.em - return await em.getRepository(PlayerAlias).findOne({ + return em.getRepository(PlayerAlias).findOne({ id: this.ctx.state.currentAliasId, player: { game: this.ctx.state.game @@ -15,6 +16,14 @@ export default class GameChannelAPIPolicy extends Policy { }) } + async getChannel(req: Request): Promise { + const em: EntityManager = this.ctx.em + return em.getRepository(GameChannel).findOne({ + id: Number(req.params.id), + game: this.ctx.state.alias.player.game + }) + } + async index(): Promise { return await this.hasScope(APIKeyScope.READ_GAME_CHANNELS) } @@ -33,31 +42,43 @@ export default class GameChannelAPIPolicy extends Policy { return await this.hasScope(APIKeyScope.WRITE_GAME_CHANNELS) } - async join(): Promise { + async join(req: Request): Promise { this.ctx.state.alias = await this.getAlias() if (!this.ctx.state.alias) return new PolicyDenial({ message: 'Player not found' }, 404) + this.ctx.state.channel = await this.getChannel(req) + if (!this.ctx.state.channel) return new PolicyDenial({ message: 'Channel not found' }, 404) + return await this.hasScope(APIKeyScope.WRITE_GAME_CHANNELS) } - async leave(): Promise { + async leave(req: Request): Promise { this.ctx.state.alias = await this.getAlias() if (!this.ctx.state.alias) return new PolicyDenial({ message: 'Player not found' }, 404) + this.ctx.state.channel = await this.getChannel(req) + if (!this.ctx.state.channel) return new PolicyDenial({ message: 'Channel not found' }, 404) + return await this.hasScope(APIKeyScope.WRITE_GAME_CHANNELS) } - async put(): Promise { + async put(req: Request): Promise { this.ctx.state.alias = await this.getAlias() if (!this.ctx.state.alias) return new PolicyDenial({ message: 'Player not found' }, 404) + this.ctx.state.channel = await this.getChannel(req) + if (!this.ctx.state.channel) return new PolicyDenial({ message: 'Channel not found' }, 404) + return await this.hasScope(APIKeyScope.WRITE_GAME_CHANNELS) } - async delete(): Promise { + async delete(req: Request): Promise { this.ctx.state.alias = await this.getAlias() if (!this.ctx.state.alias) return new PolicyDenial({ message: 'Player not found' }, 404) + this.ctx.state.channel = await this.getChannel(req) + if (!this.ctx.state.channel) return new PolicyDenial({ message: 'Channel not found' }, 404) + return await this.hasScope(APIKeyScope.WRITE_GAME_CHANNELS) } } diff --git a/src/services/api/game-channel-api.service.ts b/src/services/api/game-channel-api.service.ts index 45cd73bd..a59a9196 100644 --- a/src/services/api/game-channel-api.service.ts +++ b/src/services/api/game-channel-api.service.ts @@ -11,7 +11,7 @@ import PlayerAlias from '../../entities/player-alias' function sendMessageToChannelMembers(req: Request, channel: GameChannel, res: SocketMessageResponse, data: T) { const socket: Socket = req.ctx.wss - const conns = socket.findConnections((conn) => channel.members.getIdentifiers().includes(conn.playerAlias.id)) + const conns = socket.findConnections((conn) => channel.members.getIdentifiers().includes(conn.playerAliasId)) sendMessages(conns, res, data) } @@ -27,6 +27,7 @@ function canModifyChannel(channel: GameChannel, alias: PlayerAlias): boolean { }, { method: 'GET', + path: '/subscriptions', handler: 'subscriptions', docs: GameChannelAPIDocs.subscriptions }, @@ -61,6 +62,8 @@ function canModifyChannel(channel: GameChannel, alias: PlayerAlias): boolean { } ]) export default class GameChannelAPIService extends APIService { + @Validate({ query: ['page'] }) + @HasPermission(GameChannelAPIPolicy, 'index') @ForwardTo('games.game-channels', 'index') async index(req: Request): Promise { return forwardRequest(req) @@ -102,7 +105,7 @@ export default class GameChannelAPIService extends APIService { await em.persistAndFlush(channel) const socket: Socket = req.ctx.wss - const conn = socket.findConnections((conn) => conn.playerAlias.id === req.ctx.state.alias.id)[0] + const conn = socket.findConnections((conn) => conn.playerAliasId === req.ctx.state.alias.id)[0] if (conn) { sendMessage(conn, 'v1.channels.player-joined', { channel }) } @@ -116,23 +119,12 @@ export default class GameChannelAPIService extends APIService { } @Validate({ - headers: ['x-talo-alias'], - body: ['name'] + headers: ['x-talo-alias'] }) @HasPermission(GameChannelAPIPolicy, 'join') async join(req: Request): Promise { - const { name } = req.body const em: EntityManager = req.ctx.em - - const channel = await em.getRepository(GameChannel).findOne({ game: req.ctx.state.game, name }) - if (!channel) { - return { - status: 404, - body: { - error: 'Channel not found' - } - } - } + const channel: GameChannel = req.ctx.state.channel if (!(await channel.members.load()).getIdentifiers().includes(req.ctx.state.alias.id)) { channel.members.add(req.ctx.state.alias) @@ -150,26 +142,14 @@ export default class GameChannelAPIService extends APIService { } @Validate({ - headers: ['x-talo-alias'], - body: ['name'] + headers: ['x-talo-alias'] }) @HasPermission(GameChannelAPIPolicy, 'leave') async leave(req: Request): Promise { - const { id } = req.params const em: EntityManager = req.ctx.em - - const channel = await em.getRepository(GameChannel).findOne(Number(id)) - if (!channel) { - return { - status: 404, - body: { - error: 'Channel not found' - } - } - } + const channel: GameChannel = req.ctx.state.channel if (channel.autoCleanup && channel.owner.id === req.ctx.state.alias.id) { - await channel.members.removeAll() await em.removeAndFlush(channel) return { @@ -193,19 +173,9 @@ export default class GameChannelAPIService extends APIService { }) @HasPermission(GameChannelAPIPolicy, 'put') async put(req: Request): Promise { - const { id } = req.params const { name, props, ownerAliasId } = req.body const em: EntityManager = req.ctx.em - - const channel = await em.getRepository(GameChannel).findOne(Number(id)) - if (!channel) { - return { - status: 404, - body: { - error: 'Channel not found' - } - } - } + const channel: GameChannel = req.ctx.state.channel if (canModifyChannel(channel, req.ctx.state.alias)) { return { @@ -259,18 +229,8 @@ export default class GameChannelAPIService extends APIService { }) @HasPermission(GameChannelAPIPolicy, 'delete') async delete(req: Request): Promise { - const { id } = req.params const em: EntityManager = req.ctx.em - - const channel = await em.getRepository(GameChannel).findOne(Number(id)) - if (!channel) { - return { - status: 404, - body: { - error: 'Channel not found' - } - } - } + const channel: GameChannel = req.ctx.state.channel if (canModifyChannel(channel, req.ctx.state.alias)) { return { diff --git a/src/services/game-channel.service.ts b/src/services/game-channel.service.ts index 3f1145d1..508394ba 100644 --- a/src/services/game-channel.service.ts +++ b/src/services/game-channel.service.ts @@ -1,19 +1,48 @@ -import { EntityManager } from '@mikro-orm/mysql' -import { HasPermission, Service, Request, Response } from 'koa-clay' +import { EntityManager, QueryOrder } from '@mikro-orm/mysql' +import { HasPermission, Service, Request, Response, Validate } from 'koa-clay' import GameChannel from '../entities/game-channel' import GameChannelPolicy from '../policies/game-channel.policy' +const itemsPerPage = 50 + export default class GameChannelService extends Service { + @Validate({ query: ['page'] }) @HasPermission(GameChannelPolicy, 'index') async index(req: Request): Promise { + const { search, page } = req.query const em: EntityManager = req.ctx.em - const channels = await em.getRepository(GameChannel).find({ game: req.ctx.state.game }) + const query = em.qb(GameChannel, 'gc') + .select('gc.*') + .orderBy({ totalMessages: QueryOrder.DESC }) + .limit(itemsPerPage) + .offset(Number(page) * itemsPerPage) + + if (search) { + query.andWhere({ + $or: [ + { name: { $like: `%${search}%` } }, + { + owner: { identifier: { $like: `%${search}%` } } + } + ] + }) + } + + const [channels, count] = await query + .andWhere({ + game: req.ctx.state.game + }) + .getResultAndCount() + + await em.populate(channels, ['owner']) return { status: 200, body: { - channels: await Promise.all(channels.map((channel) => channel.toJSONWithCount(em, req.ctx.state.includeDevData))) + channels: await Promise.all(channels.map((channel) => channel.toJSONWithCount(em, req.ctx.state.includeDevData))), + count, + itemsPerPage } } } diff --git a/src/socket/listeners/gameChannelListeners.ts b/src/socket/listeners/gameChannelListeners.ts index 1def7314..8a502ae2 100644 --- a/src/socket/listeners/gameChannelListeners.ts +++ b/src/socket/listeners/gameChannelListeners.ts @@ -28,18 +28,18 @@ const gameChannelListeners: SocketMessageListener[] = [ if (!channel) { throw new Error('Channel not found') } - if (!channel.members.getIdentifiers().includes(conn.playerAlias.id)) { + if (!channel.members.getIdentifiers().includes(conn.playerAliasId)) { throw new Error('Player not in channel') } const conns = socket.findConnections((conn) => { - return conn.scopes.includes(APIKeyScope.READ_GAME_CHANNELS) && - channel.members.getIdentifiers().includes(conn.playerAlias.id) + return conn.hasScope(APIKeyScope.READ_GAME_CHANNELS) && + channel.members.getIdentifiers().includes(conn.playerAliasId) }) sendMessages(conns, 'v1.channels.message', { channel, message: data.message, - fromPlayerAlias: conn.playerAlias + playerAlias: await conn.getPlayerAlias() }) channel.totalMessages++ diff --git a/src/socket/listeners/playerListeners.ts b/src/socket/listeners/playerListeners.ts index 897cca86..d9c86f26 100644 --- a/src/socket/listeners/playerListeners.ts +++ b/src/socket/listeners/playerListeners.ts @@ -58,7 +58,7 @@ const playerListeners: SocketMessageListener[] = [ return } - conn.playerAlias = alias + conn.playerAliasId = alias.id sendMessage(conn, 'v1.players.identify.success', alias) }, { diff --git a/src/socket/router/socketRouter.ts b/src/socket/router/socketRouter.ts index 02ba5fde..b1692bbe 100644 --- a/src/socket/router/socketRouter.ts +++ b/src/socket/router/socketRouter.ts @@ -45,20 +45,16 @@ export default class SocketRouter { if (err instanceof ZodError) { sendError(conn, 'unknown', new SocketError('INVALID_MESSAGE', 'Invalid message request')) } else { - sendError(conn, message.req, new SocketError('ROUTING_ERROR', 'An error occurred while routing the message')) + sendError(conn, message?.req ?? 'unknown', new SocketError('ROUTING_ERROR', 'An error occurred while routing the message')) } } } async routeMessage(conn: SocketConnection, message: SocketMessage): Promise { - let handled = false - for (const route of routes) { for await (const listener of route) { if (listener.req === message.req) { try { - handled = true - if (!this.meetsPlayerRequirement(conn, listener)) { sendError(conn, message.req, new SocketError('NO_PLAYER_FOUND', 'You must identify a player before sending this request')) } else if (!this.meetsScopeRequirements(conn, listener)) { @@ -69,29 +65,29 @@ export default class SocketRouter { await listener.handler({ conn, req: listener.req, data, socket: this.socket }) } - break + return true } catch (err) { if (err instanceof ZodError) { sendError(conn, message.req, new SocketError('INVALID_MESSAGE', 'Invalid message data for request')) } else { - sendError(conn, message.req, new SocketError('LISTENER_ERROR', 'An error occurred while processing the message', err.message)) + sendError(conn, message?.req, new SocketError('LISTENER_ERROR', 'An error occurred while processing the message', err.message)) } } } } } - return handled + return false } meetsPlayerRequirement(conn: SocketConnection, listener: SocketMessageListener): boolean { const requirePlayer = listener.options.requirePlayer ?? true - return Boolean(conn.playerAlias) || !requirePlayer + return Boolean(conn.playerAliasId) || !requirePlayer } meetsScopeRequirements(conn: SocketConnection, listener: SocketMessageListener): boolean { const requiredScopes = listener.options.apiKeyScopes ?? [] - return requiredScopes.every((scope) => conn.scopes.includes(scope as APIKeyScope)) + return conn.hasScopes(requiredScopes) } getMissingScopes(conn: SocketConnection, listener: SocketMessageListener): APIKeyScope[] { diff --git a/src/socket/socketConnection.ts b/src/socket/socketConnection.ts index a272c6ea..502bd745 100644 --- a/src/socket/socketConnection.ts +++ b/src/socket/socketConnection.ts @@ -3,13 +3,14 @@ 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' export default class SocketConnection { alive: boolean = true - playerAlias: PlayerAlias | null = null + playerAliasId: number | null = null game: Game | null = null scopes: APIKeyScope[] = [] - headers: IncomingHttpHeaders = {} + private headers: IncomingHttpHeaders = {} constructor(readonly ws: WebSocket, apiKey: APIKey, req: IncomingMessage) { this.game = apiKey.game @@ -24,4 +25,21 @@ export default class SocketConnection { getAliasFromHeader(): number | null { return this.headers['x-talo-alias'] ? Number(this.headers['x-talo-alias']) : null } + + async getPlayerAlias(): Promise { + return RequestContext.getEntityManager() + .getRepository(PlayerAlias) + .findOne(this.playerAliasId, { refresh: true }) + } + + hasScope(scope: APIKeyScope): boolean { + return this.scopes.includes(APIKeyScope.FULL_ACCESS) || this.scopes.includes(scope) + } + + hasScopes(scopes: APIKeyScope[]): boolean { + if (this.hasScope(APIKeyScope.FULL_ACCESS)) { + return true + } + return scopes.every((scope) => this.hasScope(scope)) + } } diff --git a/tests/services/_api/player-api/steamworksIdentify.test.ts b/tests/services/_api/player-api/steamworksIdentify.test.ts index 9a5477f1..16013b0e 100644 --- a/tests/services/_api/player-api/steamworksIdentify.test.ts +++ b/tests/services/_api/player-api/steamworksIdentify.test.ts @@ -42,7 +42,7 @@ describe('Player API service - identify - steamworks auth', () => { appid: appId, ownsapp: true, permanent: true, - timestamp: '2021-08-01T00:00:00Z', + timestamp: '2021-08-01T00:00:00.000Z', ownersteamid: steamId, usercanceled: false } @@ -90,7 +90,7 @@ describe('Player API service - identify - steamworks auth', () => { }, { key: 'META_STEAMWORKS_OWNS_APP_FROM_DATE', - value: '2021-08-01T00:00:00Z' + value: '2021-08-01T00:00:00.000Z' } ]) }) @@ -118,7 +118,7 @@ describe('Player API service - identify - steamworks auth', () => { appid: appId, ownsapp: true, permanent: true, - timestamp: '2021-08-01T00:00:00Z', + timestamp: '2021-08-01T00:00:00.000Z', ownersteamid: steamId, usercanceled: false } @@ -160,7 +160,7 @@ describe('Player API service - identify - steamworks auth', () => { }, { key: 'META_STEAMWORKS_OWNS_APP_FROM_DATE', - value: '2021-08-01T00:00:00Z' + value: '2021-08-01T00:00:00.000Z' } ]) }) @@ -187,7 +187,7 @@ describe('Player API service - identify - steamworks auth', () => { appid: appId, ownsapp: true, permanent: true, - timestamp: '2021-08-01T00:00:00Z', + timestamp: '2021-08-01T00:00:00.000Z', ownersteamid: steamId, usercanceled: false } @@ -229,7 +229,7 @@ describe('Player API service - identify - steamworks auth', () => { appid: appId, ownsapp: true, permanent: true, - timestamp: '2021-08-01T00:00:00Z', + timestamp: '2021-08-01T00:00:00.000Z', ownersteamid: steamId, usercanceled: false } diff --git a/tests/socket/listeners/gameChannelListeners/message.test.ts b/tests/socket/listeners/gameChannelListeners/message.test.ts index 88b4056f..86bf6113 100644 --- a/tests/socket/listeners/gameChannelListeners/message.test.ts +++ b/tests/socket/listeners/gameChannelListeners/message.test.ts @@ -45,7 +45,7 @@ describe('Game channel listeners - message', () => { expect(actual.res).toBe('v1.channels.message') expect(actual.data.channel.id).toBe(channel.id) expect(actual.data.message).toBe('Hello world') - expect(actual.data.fromPlayerAlias.id).toBe(player.aliases[0].id) + expect(actual.data.playerAlias.id).toBe(player.aliases[0].id) }) }) From 0e4a53e2837633373aa9b47fc6bdda3800c06142 Mon Sep 17 00:00:00 2001 From: tudor <7089284+tudddorrr@users.noreply.github.com> Date: Wed, 11 Dec 2024 11:50:57 +0000 Subject: [PATCH 12/15] channel api tests --- src/docs/game-channel-api.docs.ts | 47 +++- src/entities/game-channel.ts | 11 +- src/services/api-key.service.ts | 7 + src/services/api/game-channel-api.service.ts | 79 +++--- src/socket/index.ts | 6 + src/socket/listeners/playerListeners.ts | 6 +- src/socket/messages/socketError.ts | 2 +- src/socket/router/socketRouter.ts | 6 +- src/socket/socketConnection.ts | 15 +- .../_api/game-channel-api/delete.test.ts | 125 +++++++++ .../_api/game-channel-api/index.test.ts | 37 +++ .../_api/game-channel-api/join.test.ts | 92 +++++++ .../_api/game-channel-api/leave.test.ts | 165 ++++++++++++ .../_api/game-channel-api/post.test.ts | 59 ++++- .../_api/game-channel-api/put.test.ts | 242 ++++++++++++++++++ .../game-channel-api/subscriptions.test.ts | 62 +++++ tests/services/_api/player-api/merge.test.ts | 9 +- tests/services/game-channel/index.test.ts | 144 ++++++++++- .../playerListeners/identify.test.ts | 8 +- tests/socket/router.test.ts | 50 +++- tests/socket/server.test.ts | 19 ++ 21 files changed, 1117 insertions(+), 74 deletions(-) create mode 100644 tests/services/_api/game-channel-api/delete.test.ts create mode 100644 tests/services/_api/game-channel-api/index.test.ts create mode 100644 tests/services/_api/game-channel-api/join.test.ts create mode 100644 tests/services/_api/game-channel-api/leave.test.ts create mode 100644 tests/services/_api/game-channel-api/put.test.ts create mode 100644 tests/services/_api/game-channel-api/subscriptions.test.ts diff --git a/src/docs/game-channel-api.docs.ts b/src/docs/game-channel-api.docs.ts index 4bca1861..a5d9521b 100644 --- a/src/docs/game-channel-api.docs.ts +++ b/src/docs/game-channel-api.docs.ts @@ -59,6 +59,47 @@ const GameChannelAPIDocs: APIDocs = { } ] }, + subscriptions: { + description: 'List game channels that the player is subscribed to', + params: { + headers: { + 'x-talo-alias': 'The ID of the player\'s alias' + } + }, + samples: [ + { + title: 'Sample response', + sample: { + channels: [ + { + id: 1, + name: 'general-chat', + owner: null, + totalMessages: 308, + memberCount: 42, + props: [ + { key: 'channelType', value: 'public' } + ], + createdAt: '2024-12-09T12:00:00.000Z', + updatedAt: '2024-12-09T12:00:00.000Z' + }, + { + id: 2, + name: 'trade-chat', + owner: null, + totalMessages: 23439, + memberCount: 124, + props: [ + { key: 'channelType', value: 'public' } + ], + createdAt: '2024-12-09T12:00:00.000Z', + updatedAt: '2024-12-09T12:00:00.000Z' + } + ] + } + } + ] + }, post: { description: 'Create a game channel', params: { @@ -67,7 +108,8 @@ const GameChannelAPIDocs: APIDocs = { }, body: { name: 'The name of the channel', - props: 'An array of @type(Props:prop)' + props: 'An array of @type(Props:prop)', + autoCleanup: 'Whether the channel should be automatically deleted when the owner leaves or the channel is empty (default is false)' } }, samples: [ @@ -78,7 +120,8 @@ const GameChannelAPIDocs: APIDocs = { props: [ { key: 'channelType', value: 'guild' }, { key: 'guildId', value: '5912' } - ] + ], + autoCleanup: true } }, { diff --git a/src/entities/game-channel.ts b/src/entities/game-channel.ts index c152e622..65215965 100644 --- a/src/entities/game-channel.ts +++ b/src/entities/game-channel.ts @@ -12,6 +12,7 @@ export default class GameChannel { id: number @Required({ + methods: ['POST'], validation: async (val: unknown, req: Request): Promise => { const duplicateName = await (req.ctx.em).getRepository(GameChannel).findOne({ name: val, @@ -38,13 +39,21 @@ export default class GameChannel { @Property() totalMessages: number = 0 - @Required() @Property() autoCleanup: boolean = false @ManyToOne(() => Game) game: Game + @Required({ + methods: [], + validation: async (val: unknown): Promise => [ + { + check: Array.isArray(val), + error: 'Props must be an array' + } + ] + }) @Embedded(() => Prop, { array: true }) props: Prop[] = [] diff --git a/src/services/api-key.service.ts b/src/services/api-key.service.ts index 1cc86650..7aa6ef48 100644 --- a/src/services/api-key.service.ts +++ b/src/services/api-key.service.ts @@ -7,6 +7,7 @@ import { groupBy } from 'lodash' import { promisify } from 'util' import createGameActivity from '../lib/logging/createGameActivity' import { GameActivityType } from '../entities/game-activity' +import Socket from '../socket' export async function createToken(em: EntityManager, apiKey: APIKey): Promise { await em.populate(apiKey, ['game.apiSecret']) @@ -109,6 +110,12 @@ export default class APIKeyService extends Service { } }) + const socket: Socket = req.ctx.wss + const conns = socket.findConnections((conn) => conn.getAPIKeyId() === apiKey.id) + for (const conn of conns) { + conn.ws.close(3000) + } + await em.flush() return { diff --git a/src/services/api/game-channel-api.service.ts b/src/services/api/game-channel-api.service.ts index a59a9196..4fdb9cba 100644 --- a/src/services/api/game-channel-api.service.ts +++ b/src/services/api/game-channel-api.service.ts @@ -5,18 +5,23 @@ import GameChannel from '../../entities/game-channel' import { EntityManager } from '@mikro-orm/mysql' import sanitiseProps from '../../lib/props/sanitiseProps' import Socket from '../../socket' -import { sendMessage, sendMessages, SocketMessageResponse } from '../../socket/messages/socketMessage' +import { sendMessages, SocketMessageResponse } from '../../socket/messages/socketMessage' import GameChannelAPIDocs from '../../docs/game-channel-api.docs' import PlayerAlias from '../../entities/player-alias' +import { uniqWith } from 'lodash' +import { APIKeyScope } from '../../entities/api-key' function sendMessageToChannelMembers(req: Request, channel: GameChannel, res: SocketMessageResponse, data: T) { const socket: Socket = req.ctx.wss - const conns = socket.findConnections((conn) => channel.members.getIdentifiers().includes(conn.playerAliasId)) + const conns = socket.findConnections((conn) => { + return conn.hasScope(APIKeyScope.READ_GAME_CHANNELS) && + channel.members.getIdentifiers().includes(conn.playerAliasId) + }) sendMessages(conns, res, data) } function canModifyChannel(channel: GameChannel, alias: PlayerAlias): boolean { - return channel.owner ? channel.owner.id === alias.id : true + return channel.owner ? channel.owner.id === alias.id : false } @Routes([ @@ -90,13 +95,14 @@ export default class GameChannelAPIService extends APIService { }) @HasPermission(GameChannelAPIPolicy, 'post') async post(req: Request): Promise { - const { name, props } = req.body + const { name, props, autoCleanup } = req.body const em: EntityManager = req.ctx.em const channel = new GameChannel(req.ctx.state.game) channel.name = name channel.owner = req.ctx.state.alias channel.members.add(req.ctx.state.alias) + channel.autoCleanup = autoCleanup ?? false if (props) { channel.props = sanitiseProps(props) @@ -104,11 +110,10 @@ export default class GameChannelAPIService extends APIService { await em.persistAndFlush(channel) - const socket: Socket = req.ctx.wss - const conn = socket.findConnections((conn) => conn.playerAliasId === req.ctx.state.alias.id)[0] - if (conn) { - sendMessage(conn, 'v1.channels.player-joined', { channel }) - } + sendMessageToChannelMembers(req, channel, 'v1.channels.player-joined', { + channel, + playerAlias: req.ctx.state.alias + }) return { status: 200, @@ -127,12 +132,15 @@ export default class GameChannelAPIService extends APIService { const channel: GameChannel = req.ctx.state.channel if (!(await channel.members.load()).getIdentifiers().includes(req.ctx.state.alias.id)) { + sendMessageToChannelMembers(req, channel, 'v1.channels.player-joined', { + channel, + playerAlias: req.ctx.state.alias + }) + channel.members.add(req.ctx.state.alias) await em.flush() } - sendMessageToChannelMembers(req, channel, 'v1.channels.player-joined', { channel }) - return { status: 200, body: { @@ -149,7 +157,7 @@ export default class GameChannelAPIService extends APIService { const em: EntityManager = req.ctx.em const channel: GameChannel = req.ctx.state.channel - if (channel.autoCleanup && channel.owner.id === req.ctx.state.alias.id) { + if (channel.autoCleanup && (channel.owner.id === req.ctx.state.alias.id || await channel.members.loadCount() === 1)) { await em.removeAndFlush(channel) return { @@ -157,10 +165,19 @@ export default class GameChannelAPIService extends APIService { } } - (await channel.members.load()).remove(req.ctx.state.alias) - await em.flush() + if ((await channel.members.load()).getIdentifiers().includes(req.ctx.state.alias.id)) { + if (channel.owner.id === req.ctx.state.alias.id) { + channel.owner = null + } - sendMessageToChannelMembers(req, channel, 'v1.channels.player-left', { channel }) + channel.members.remove(req.ctx.state.alias) + sendMessageToChannelMembers(req, channel, 'v1.channels.player-left', { + channel, + playerAlias: req.ctx.state.alias + }) + + await em.flush() + } return { status: 204 @@ -177,13 +194,8 @@ export default class GameChannelAPIService extends APIService { const em: EntityManager = req.ctx.em const channel: GameChannel = req.ctx.state.channel - if (canModifyChannel(channel, req.ctx.state.alias)) { - return { - status: 403, - body: { - error: 'This player is not the owner of the channel' - } - } + if (!canModifyChannel(channel, req.ctx.state.alias)) { + req.ctx.throw(403, 'This player is not the owner of the channel') } if (name) { @@ -191,7 +203,12 @@ export default class GameChannelAPIService extends APIService { } if (props) { - channel.props = sanitiseProps(props) + const mergedProps = uniqWith([ + ...sanitiseProps(props), + ...channel.props + ], (a, b) => a.key === b.key) + + channel.props = sanitiseProps(mergedProps, true) } if (ownerAliasId) { @@ -203,12 +220,7 @@ export default class GameChannelAPIService extends APIService { }) if (!newOwner) { - return { - status: 404, - body: { - error: 'Owner alias not found' - } - } + req.ctx.throw(404, 'New owner not found') } channel.owner = newOwner @@ -232,13 +244,8 @@ export default class GameChannelAPIService extends APIService { const em: EntityManager = req.ctx.em const channel: GameChannel = req.ctx.state.channel - if (canModifyChannel(channel, req.ctx.state.alias)) { - return { - status: 403, - body: { - error: 'This player is not the owner of the channel' - } - } + if (!canModifyChannel(channel, req.ctx.state.alias)) { + req.ctx.throw(403, 'This player is not the owner of the channel') } await channel.members.removeAll() diff --git a/src/socket/index.ts b/src/socket/index.ts index f1300169..141f0e83 100644 --- a/src/socket/index.ts +++ b/src/socket/index.ts @@ -35,6 +35,7 @@ export default class Socket { heartbeat(): void { const interval = setInterval(() => { this.connections.forEach((conn) => { + /* v8 ignore start */ if (!conn.alive) { conn.ws.terminate() return @@ -42,6 +43,7 @@ export default class Socket { conn.alive = false conn.ws.ping() + /* v8 ignore end */ }) }, 30_000) @@ -66,12 +68,14 @@ export default class Socket { }) } + /* v8 ignore start */ handlePong(ws: WebSocket): void { const connection = this.findConnectionBySocket(ws) if (!connection) return connection.alive = true } + /* v8 ignore end */ handleCloseConnection(ws: WebSocket): void { this.connections = this.connections.filter((conn) => conn.ws !== ws) @@ -79,10 +83,12 @@ export default class Socket { findConnectionBySocket(ws: WebSocket): SocketConnection | undefined { const connection = this.connections.find((conn) => conn.ws === ws) + /* v8 ignore start */ if (!connection) { ws.close(3000) return } + /* v8 ignore end */ return connection } diff --git a/src/socket/listeners/playerListeners.ts b/src/socket/listeners/playerListeners.ts index d9c86f26..0a19e9f0 100644 --- a/src/socket/listeners/playerListeners.ts +++ b/src/socket/listeners/playerListeners.ts @@ -42,14 +42,14 @@ const playerListeners: SocketMessageListener[] = [ const valid = await validateSessionTokenJWT( data.sessionToken, alias, - conn.getPlayerFromHeader(), - conn.getAliasFromHeader() + alias.player.id, + alias.id ) if (!valid) { throw new Error() } } catch (err) { - sendError(conn, req, new SocketError('INVALID_SESSION', 'Session token is invalid')) + sendError(conn, req, new SocketError('INVALID_SESSION_TOKEN', 'Invalid session token')) return } } diff --git a/src/socket/messages/socketError.ts b/src/socket/messages/socketError.ts index 3be75dd2..8592f3c9 100644 --- a/src/socket/messages/socketError.ts +++ b/src/socket/messages/socketError.ts @@ -10,7 +10,7 @@ const errorCodes = [ 'ROUTING_ERROR', 'LISTENER_ERROR', 'INVALID_SOCKET_TOKEN', - 'INVALID_SESSION', + 'INVALID_SESSION_TOKEN', 'MISSING_ACCESS_KEY_SCOPES' ] as const diff --git a/src/socket/router/socketRouter.ts b/src/socket/router/socketRouter.ts index b1692bbe..9f08dd39 100644 --- a/src/socket/router/socketRouter.ts +++ b/src/socket/router/socketRouter.ts @@ -43,7 +43,7 @@ export default class SocketRouter { } } catch (err) { if (err instanceof ZodError) { - sendError(conn, 'unknown', new SocketError('INVALID_MESSAGE', 'Invalid message request')) + sendError(conn, 'unknown', new SocketError('INVALID_MESSAGE', 'Invalid message request', rawData.toString())) } else { sendError(conn, message?.req ?? 'unknown', new SocketError('ROUTING_ERROR', 'An error occurred while routing the message')) } @@ -68,9 +68,9 @@ export default class SocketRouter { return true } catch (err) { if (err instanceof ZodError) { - sendError(conn, message.req, new SocketError('INVALID_MESSAGE', 'Invalid message data for request')) + sendError(conn, message.req, new SocketError('INVALID_MESSAGE_DATA', 'Invalid message data for request', JSON.stringify(message.data))) } else { - sendError(conn, message?.req, new SocketError('LISTENER_ERROR', 'An error occurred while processing the message', err.message)) + sendError(conn, message?.req ?? 'unknown', new SocketError('LISTENER_ERROR', 'An error occurred while processing the message', err.message)) } } } diff --git a/src/socket/socketConnection.ts b/src/socket/socketConnection.ts index 502bd745..67a92b96 100644 --- a/src/socket/socketConnection.ts +++ b/src/socket/socketConnection.ts @@ -4,6 +4,7 @@ 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' export default class SocketConnection { alive: boolean = true @@ -18,20 +19,18 @@ export default class SocketConnection { this.headers = req.headers } - getPlayerFromHeader(): string | null { - return this.headers['x-talo-player'] as string ?? null - } - - getAliasFromHeader(): number | null { - return this.headers['x-talo-alias'] ? Number(this.headers['x-talo-alias']) : null - } - async getPlayerAlias(): Promise { return RequestContext.getEntityManager() .getRepository(PlayerAlias) .findOne(this.playerAliasId, { refresh: true }) } + getAPIKeyId(): number { + const token = this.headers.authorization.split('Bearer ')[1] + const decodedToken = jwt.decode(token) + return decodedToken.sub + } + hasScope(scope: APIKeyScope): boolean { return this.scopes.includes(APIKeyScope.FULL_ACCESS) || this.scopes.includes(scope) } diff --git a/tests/services/_api/game-channel-api/delete.test.ts b/tests/services/_api/game-channel-api/delete.test.ts new file mode 100644 index 00000000..c34f33ff --- /dev/null +++ b/tests/services/_api/game-channel-api/delete.test.ts @@ -0,0 +1,125 @@ +import request from 'supertest' +import { EntityManager } from '@mikro-orm/mysql' +import GameChannelFactory from '../../../fixtures/GameChannelFactory' +import { APIKeyScope } from '../../../../src/entities/api-key' +import createAPIKeyAndToken from '../../../utils/createAPIKeyAndToken' +import PlayerFactory from '../../../fixtures/PlayerFactory' +import GameChannel from '../../../../src/entities/game-channel' + +describe('Game channel API service - delete', () => { + it('should delete a channel if the scope is valid', async () => { + const em: EntityManager = global.em + + const [apiKey, token] = await createAPIKeyAndToken([APIKeyScope.WRITE_GAME_CHANNELS]) + + const channel = await new GameChannelFactory(apiKey.game).one() + const player = await new PlayerFactory([apiKey.game]).one() + channel.owner = player.aliases[0] + channel.members.add(player.aliases[0]) + await em.persistAndFlush(channel) + + await request(global.app) + .delete(`/v1/game-channels/${channel.id}`) + .auth(token, { type: 'bearer' }) + .set('x-talo-alias', String(player.aliases[0].id)) + .expect(204) + + em.clear() + expect(await em.getRepository(GameChannel).findOne(channel.id)).toBeNull() + }) + + it('should not delete a channel if the scope is not valid', async () => { + const [apiKey, token] = await createAPIKeyAndToken([]) + + const channel = await new GameChannelFactory(apiKey.game).one() + const player = await new PlayerFactory([apiKey.game]).one() + channel.owner = player.aliases[0] + channel.members.add(player.aliases[0]) + await (global.em).persistAndFlush(channel) + + await request(global.app) + .delete(`/v1/game-channels/${channel.id}`) + .auth(token, { type: 'bearer' }) + .set('x-talo-alias', String(player.aliases[0].id)) + .expect(403) + }) + + it('should not delete a channel if it does not have an owner', async () => { + const em: EntityManager = global.em + + const [apiKey, token] = await createAPIKeyAndToken([APIKeyScope.WRITE_GAME_CHANNELS]) + + const channel = await new GameChannelFactory(apiKey.game).state(() => ({ owner: null })).one() + const player = await new PlayerFactory([apiKey.game]).one() + channel.members.add(player.aliases[0]) + await em.persistAndFlush(channel) + + const res = await request(global.app) + .delete(`/v1/game-channels/${channel.id}`) + .auth(token, { type: 'bearer' }) + .set('x-talo-alias', String(player.aliases[0].id)) + .expect(403) + + expect(res.body).toStrictEqual({ message: 'This player is not the owner of the channel' }) + }) + + it('should not delete a channel if the current alias is not the owner', async () => { + const em: EntityManager = global.em + + const [apiKey, token] = await createAPIKeyAndToken([APIKeyScope.WRITE_GAME_CHANNELS]) + + const channel = await new GameChannelFactory(apiKey.game).one() + const player = await new PlayerFactory([apiKey.game]).one() + channel.owner = (await new PlayerFactory([apiKey.game]).one()).aliases[0] + channel.members.add(player.aliases[0]) + await em.persistAndFlush(channel) + + const res = await request(global.app) + .delete(`/v1/game-channels/${channel.id}`) + .auth(token, { type: 'bearer' }) + .set('x-talo-alias', String(player.aliases[0].id)) + .expect(403) + + expect(res.body).toStrictEqual({ message: 'This player is not the owner of the channel' }) + }) + + it('should not delete a channel with an invalid alias', async () => { + const [apiKey, token] = await createAPIKeyAndToken([APIKeyScope.WRITE_GAME_CHANNELS]) + + const channel = await new GameChannelFactory(apiKey.game).one() + const player = await new PlayerFactory([apiKey.game]).one() + channel.owner = player.aliases[0] + channel.members.add(player.aliases[0]) + await (global.em).persistAndFlush(channel) + + const res = await request(global.app) + .delete(`/v1/game-channels/${channel.id}`) + .auth(token, { type: 'bearer' }) + .set('x-talo-alias', '32144') + .expect(404) + + expect(res.body).toStrictEqual({ + message: 'Player not found' + }) + }) + + it('should not delete a channel that does not exist', async () => { + const [apiKey, token] = await createAPIKeyAndToken([APIKeyScope.WRITE_GAME_CHANNELS]) + + const channel = await new GameChannelFactory(apiKey.game).one() + const player = await new PlayerFactory([apiKey.game]).one() + channel.owner = player.aliases[0] + channel.members.add(player.aliases[0]) + await (global.em).persistAndFlush(channel) + + const res = await request(global.app) + .delete('/v1/game-channels/54252') + .auth(token, { type: 'bearer' }) + .set('x-talo-alias', String(player.aliases[0].id)) + .expect(404) + + expect(res.body).toStrictEqual({ + message: 'Channel not found' + }) + }) +}) diff --git a/tests/services/_api/game-channel-api/index.test.ts b/tests/services/_api/game-channel-api/index.test.ts new file mode 100644 index 00000000..f4cde1d9 --- /dev/null +++ b/tests/services/_api/game-channel-api/index.test.ts @@ -0,0 +1,37 @@ +import request from 'supertest' +import { EntityManager } from '@mikro-orm/mysql' +import GameChannelFactory from '../../../fixtures/GameChannelFactory' +import { APIKeyScope } from '../../../../src/entities/api-key' +import createAPIKeyAndToken from '../../../utils/createAPIKeyAndToken' + +describe('Game channel API service - index', () => { + it('should return a list of game channels if the scope is valid', async () => { + const [apiKey, token] = await createAPIKeyAndToken([APIKeyScope.READ_GAME_CHANNELS]) + + const channels = await new GameChannelFactory(apiKey.game).many(10) + await (global.em).persistAndFlush(channels) + + const res = await request(global.app) + .get('/v1/game-channels') + .query({ page: 0 }) + .auth(token, { type: 'bearer' }) + .expect(200) + + res.body.channels.forEach((item, idx) => { + expect(item.id).toBe(channels[idx].id) + }) + }) + + it('should not return game channels if the scope is not valid', async () => { + const [apiKey, token] = await createAPIKeyAndToken([]) + + const channels = await new GameChannelFactory(apiKey.game).many(10) + await (global.em).persistAndFlush(channels) + + await request(global.app) + .get('/v1/game-channels') + .query({ page: 0 }) + .auth(token, { type: 'bearer' }) + .expect(403) + }) +}) diff --git a/tests/services/_api/game-channel-api/join.test.ts b/tests/services/_api/game-channel-api/join.test.ts new file mode 100644 index 00000000..36de850f --- /dev/null +++ b/tests/services/_api/game-channel-api/join.test.ts @@ -0,0 +1,92 @@ +import request from 'supertest' +import { EntityManager } from '@mikro-orm/mysql' +import GameChannelFactory from '../../../fixtures/GameChannelFactory' +import { APIKeyScope } from '../../../../src/entities/api-key' +import createAPIKeyAndToken from '../../../utils/createAPIKeyAndToken' +import PlayerFactory from '../../../fixtures/PlayerFactory' + +describe('Game channel API service - join', () => { + it('should join a channel if the scope is valid', async () => { + const [apiKey, token] = await createAPIKeyAndToken([APIKeyScope.WRITE_GAME_CHANNELS]) + + const channel = await new GameChannelFactory(apiKey.game).one() + const player = await new PlayerFactory([apiKey.game]).one() + await (global.em).persistAndFlush([channel, player]) + + const res = await request(global.app) + .post(`/v1/game-channels/${channel.id}/join`) + .auth(token, { type: 'bearer' }) + .set('x-talo-alias', String(player.aliases[0].id)) + .expect(200) + + expect(res.body.channel.id).toBe(channel.id) + expect(res.body.channel.memberCount).toBe(1) + }) + + it('should not join a channel if the scope is not valid', async () => { + const [apiKey, token] = await createAPIKeyAndToken([]) + + const channel = await new GameChannelFactory(apiKey.game).one() + const player = await new PlayerFactory([apiKey.game]).one() + await (global.em).persistAndFlush([channel, player]) + + await request(global.app) + .post(`/v1/game-channels/${channel.id}/join`) + .auth(token, { type: 'bearer' }) + .set('x-talo-alias', String(player.aliases[0].id)) + .expect(403) + }) + + it('should join a channel even if the player is already subscribed to it', async () => { + const [apiKey, token] = await createAPIKeyAndToken([APIKeyScope.WRITE_GAME_CHANNELS]) + + const channel = await new GameChannelFactory(apiKey.game).one() + const player = await new PlayerFactory([apiKey.game]).one() + channel.members.add(player.aliases[0]) + await (global.em).persistAndFlush(channel) + + const res = await request(global.app) + .post(`/v1/game-channels/${channel.id}/join`) + .auth(token, { type: 'bearer' }) + .set('x-talo-alias', String(player.aliases[0].id)) + .expect(200) + + expect(res.body.channel.id).toBe(channel.id) + expect(res.body.channel.memberCount).toBe(1) + }) + + it('should not join a channel with an invalid alias', async () => { + const [apiKey, token] = await createAPIKeyAndToken([APIKeyScope.WRITE_GAME_CHANNELS]) + + const channel = await new GameChannelFactory(apiKey.game).one() + await (global.em).persistAndFlush(channel) + + const res = await request(global.app) + .post(`/v1/game-channels/${channel.id}/join`) + .auth(token, { type: 'bearer' }) + .set('x-talo-alias', '32144') + .expect(404) + + expect(res.body).toStrictEqual({ + message: 'Player not found' + }) + }) + + it('should not join a channel that does not exist', async () => { + const [apiKey, token] = await createAPIKeyAndToken([APIKeyScope.WRITE_GAME_CHANNELS]) + + const channel = await new GameChannelFactory(apiKey.game).one() + const player = await new PlayerFactory([apiKey.game]).one() + await (global.em).persistAndFlush(channel) + + const res = await request(global.app) + .post('/v1/game-channels/54252/join') + .auth(token, { type: 'bearer' }) + .set('x-talo-alias', String(player.aliases[0].id)) + .expect(404) + + expect(res.body).toStrictEqual({ + message: 'Channel not found' + }) + }) +}) diff --git a/tests/services/_api/game-channel-api/leave.test.ts b/tests/services/_api/game-channel-api/leave.test.ts new file mode 100644 index 00000000..7dde4635 --- /dev/null +++ b/tests/services/_api/game-channel-api/leave.test.ts @@ -0,0 +1,165 @@ +import request from 'supertest' +import { EntityManager } from '@mikro-orm/mysql' +import GameChannelFactory from '../../../fixtures/GameChannelFactory' +import { APIKeyScope } from '../../../../src/entities/api-key' +import createAPIKeyAndToken from '../../../utils/createAPIKeyAndToken' +import PlayerFactory from '../../../fixtures/PlayerFactory' +import GameChannel from '../../../../src/entities/game-channel' + +describe('Game channel API service - leave', () => { + it('should leave a channel if the scope is valid', async () => { + const [apiKey, token] = await createAPIKeyAndToken([APIKeyScope.WRITE_GAME_CHANNELS]) + + const channel = await new GameChannelFactory(apiKey.game).one() + const player = await new PlayerFactory([apiKey.game]).one() + channel.members.add(player.aliases[0]) + await (global.em).persistAndFlush(channel) + + await request(global.app) + .post(`/v1/game-channels/${channel.id}/leave`) + .auth(token, { type: 'bearer' }) + .set('x-talo-alias', String(player.aliases[0].id)) + .expect(204) + }) + + it('should not leave a channel if the scope is not valid', async () => { + const [apiKey, token] = await createAPIKeyAndToken([]) + + const channel = await new GameChannelFactory(apiKey.game).one() + const player = await new PlayerFactory([apiKey.game]).one() + channel.members.add(player.aliases[0]) + await (global.em).persistAndFlush(channel) + + await request(global.app) + .post(`/v1/game-channels/${channel.id}/leave`) + .auth(token, { type: 'bearer' }) + .set('x-talo-alias', String(player.aliases[0].id)) + .expect(403) + }) + + it('should leave a channel even if the player is not subscribed to it', async () => { + const [apiKey, token] = await createAPIKeyAndToken([APIKeyScope.WRITE_GAME_CHANNELS]) + + const channel = await new GameChannelFactory(apiKey.game).one() + const player = await new PlayerFactory([apiKey.game]).one() + channel.members.add(player.aliases[0]) + await (global.em).persistAndFlush(channel) + + await request(global.app) + .post(`/v1/game-channels/${channel.id}/leave`) + .auth(token, { type: 'bearer' }) + .set('x-talo-alias', String(player.aliases[0].id)) + .expect(204) + }) + + it('should delete a channel if auto cleanup is enabled the owner leaves', async () => { + const em: EntityManager = global.em + + const [apiKey, token] = await createAPIKeyAndToken([APIKeyScope.WRITE_GAME_CHANNELS]) + + const channel = await new GameChannelFactory(apiKey.game).state(() => ({ autoCleanup: true })).one() + const player = await new PlayerFactory([apiKey.game]).one() + channel.owner = player.aliases[0] + channel.members.add(player.aliases[0]) + channel.members.add([ + (await new PlayerFactory([apiKey.game]).one()).aliases[0], + (await new PlayerFactory([apiKey.game]).one()).aliases[0] + ]) + await em.persistAndFlush(channel) + + await request(global.app) + .post(`/v1/game-channels/${channel.id}/leave`) + .auth(token, { type: 'bearer' }) + .set('x-talo-alias', String(player.aliases[0].id)) + .expect(204) + + em.clear() + expect(await em.getRepository(GameChannel).findOne(channel.id)).toBeNull() + }) + + it('should delete a channel if auto cleanup is enabled the last player leaves', async () => { + const em: EntityManager = global.em + + const [apiKey, token] = await createAPIKeyAndToken([APIKeyScope.WRITE_GAME_CHANNELS]) + + const channel = await new GameChannelFactory(apiKey.game).state(() => ({ autoCleanup: true })).one() + const player = await new PlayerFactory([apiKey.game]).one() + channel.members.add(player.aliases[0]) + await em.persistAndFlush(channel) + + await request(global.app) + .post(`/v1/game-channels/${channel.id}/leave`) + .auth(token, { type: 'bearer' }) + .set('x-talo-alias', String(player.aliases[0].id)) + .expect(204) + + em.clear() + expect(await em.getRepository(GameChannel).findOne(channel.id)).toBeNull() + }) + + it('should not delete a channel if auto cleanup is not enabled and the owner leaves', async () => { + const em: EntityManager = global.em + + const [apiKey, token] = await createAPIKeyAndToken([APIKeyScope.WRITE_GAME_CHANNELS]) + + const channel = await new GameChannelFactory(apiKey.game).state(() => ({ autoCleanup: false })).one() + const player = await new PlayerFactory([apiKey.game]).one() + channel.owner = player.aliases[0] + channel.members.add(player.aliases[0]) + channel.members.add([ + (await new PlayerFactory([apiKey.game]).one()).aliases[0], + (await new PlayerFactory([apiKey.game]).one()).aliases[0] + ]) + await em.persistAndFlush(channel) + + await request(global.app) + .post(`/v1/game-channels/${channel.id}/leave`) + .auth(token, { type: 'bearer' }) + .set('x-talo-alias', String(player.aliases[0].id)) + .expect(204) + + em.clear() + const refreshedChannel = await em.getRepository(GameChannel).findOne(channel.id) + expect(refreshedChannel.id).toBe(channel.id) + expect(refreshedChannel.owner).toBe(null) + }) + + it('should not leave a channel with an invalid alias', async () => { + const [apiKey, token] = await createAPIKeyAndToken([APIKeyScope.WRITE_GAME_CHANNELS]) + + const channel = await new GameChannelFactory(apiKey.game).one() + const player = await new PlayerFactory([apiKey.game]).one() + channel.members.add(player.aliases[0]) + await (global.em).persistAndFlush(channel) + + const res = await request(global.app) + .post(`/v1/game-channels/${channel.id}/leave`) + .auth(token, { type: 'bearer' }) + .set('x-talo-alias', '32144') + .expect(404) + + expect(res.body).toStrictEqual({ + message: 'Player not found' + }) + }) + + it('should not leave a channel that does not exist', async () => { + const [apiKey, token] = await createAPIKeyAndToken([APIKeyScope.WRITE_GAME_CHANNELS]) + + const channel = await new GameChannelFactory(apiKey.game).one() + const player = await new PlayerFactory([apiKey.game]).one() + channel.owner = player.aliases[0] + channel.members.add(player.aliases[0]) + await (global.em).persistAndFlush(channel) + + const res = await request(global.app) + .post('/v1/game-channels/54252/leave') + .auth(token, { type: 'bearer' }) + .set('x-talo-alias', String(player.aliases[0].id)) + .expect(404) + + expect(res.body).toStrictEqual({ + message: 'Channel not found' + }) + }) +}) diff --git a/tests/services/_api/game-channel-api/post.test.ts b/tests/services/_api/game-channel-api/post.test.ts index d8ab2c2d..f849bdf0 100644 --- a/tests/services/_api/game-channel-api/post.test.ts +++ b/tests/services/_api/game-channel-api/post.test.ts @@ -1,23 +1,76 @@ import request from 'supertest' import { APIKeyScope } from '../../../../src/entities/api-key' import createAPIKeyAndToken from '../../../utils/createAPIKeyAndToken' +import PlayerFactory from '../../../fixtures/PlayerFactory' +import { EntityManager } from '@mikro-orm/mysql' describe('Game channel API service - post', () => { it('should create a game channel if the scope is valid', async () => { - const [, token] = await createAPIKeyAndToken([APIKeyScope.WRITE_GAME_CHANNELS]) + const [apiKey, token] = await createAPIKeyAndToken([APIKeyScope.WRITE_GAME_CHANNELS]) + const player = await new PlayerFactory([apiKey.game]).one() + await (global.em).persistAndFlush(player) - await request(global.app) + const res = await request(global.app) .post('/v1/game-channels') + .send({ name: 'Guild chat' }) .auth(token, { type: 'bearer' }) + .set('x-talo-alias', String(player.aliases[0].id)) .expect(200) + + expect(res.body.channel.name).toBe('Guild chat') + expect(res.body.channel.owner.id).toBe(player.aliases[0].id) + expect(res.body.channel.totalMessages).toBe(0) + expect(res.body.channel.props).toStrictEqual([]) + expect(res.body.channel.memberCount).toBe(1) }) it('should not create a game channel if the scope is not valid', async () => { - const [, token] = await createAPIKeyAndToken([]) + const [apiKey, token] = await createAPIKeyAndToken([]) + const player = await new PlayerFactory([apiKey.game]).one() + await (global.em).persistAndFlush(player) await request(global.app) .post('/v1/game-channels') + .send({ name: 'Guild chat' }) .auth(token, { type: 'bearer' }) + .set('x-talo-alias', String(player.aliases[0].id)) .expect(403) }) + + it('should not create a game channel if the alias does not exist', async () => { + const [apiKey, token] = await createAPIKeyAndToken([APIKeyScope.WRITE_GAME_CHANNELS]) + const player = await new PlayerFactory([apiKey.game]).one() + await (global.em).persistAndFlush(player) + + const res = await request(global.app) + .post('/v1/game-channels') + .send({ name: 'Guild chat' }) + .auth(token, { type: 'bearer' }) + .set('x-talo-alias', '324') + .expect(404) + + expect(res.body).toStrictEqual({ message: 'Player not found' }) + }) + + it('should create a game channel with props', async () => { + const [apiKey, token] = await createAPIKeyAndToken([APIKeyScope.WRITE_GAME_CHANNELS]) + const player = await new PlayerFactory([apiKey.game]).one() + await (global.em).persistAndFlush(player) + + const res = await request(global.app) + .post('/v1/game-channels') + .send({ + name: 'Guild chat', + props: [ + { key: 'guildId', value: '213432' } + ] + }) + .auth(token, { type: 'bearer' }) + .set('x-talo-alias', String(player.aliases[0].id)) + .expect(200) + + expect(res.body.channel.props).toStrictEqual([ + { key: 'guildId', value: '213432' } + ]) + }) }) diff --git a/tests/services/_api/game-channel-api/put.test.ts b/tests/services/_api/game-channel-api/put.test.ts new file mode 100644 index 00000000..76025abe --- /dev/null +++ b/tests/services/_api/game-channel-api/put.test.ts @@ -0,0 +1,242 @@ +import request from 'supertest' +import { EntityManager } from '@mikro-orm/mysql' +import GameChannelFactory from '../../../fixtures/GameChannelFactory' +import { APIKeyScope } from '../../../../src/entities/api-key' +import createAPIKeyAndToken from '../../../utils/createAPIKeyAndToken' +import PlayerFactory from '../../../fixtures/PlayerFactory' + +describe('Game channel API service - put', () => { + it('should update a channel if the scope is valid', async () => { + const em: EntityManager = global.em + + const [apiKey, token] = await createAPIKeyAndToken([APIKeyScope.WRITE_GAME_CHANNELS]) + + const channel = await new GameChannelFactory(apiKey.game).one() + const player = await new PlayerFactory([apiKey.game]).one() + channel.owner = player.aliases[0] + channel.members.add(player.aliases[0]) + await em.persistAndFlush(channel) + + const res = await request(global.app) + .put(`/v1/game-channels/${channel.id}`) + .send({ name: 'A very interesting chat' }) + .auth(token, { type: 'bearer' }) + .set('x-talo-alias', String(player.aliases[0].id)) + .expect(200) + + expect(res.body.channel.name).toBe('A very interesting chat') + }) + + it('should not update a channel if the scope is not valid', async () => { + const [apiKey, token] = await createAPIKeyAndToken([]) + + const channel = await new GameChannelFactory(apiKey.game).one() + const player = await new PlayerFactory([apiKey.game]).one() + channel.owner = player.aliases[0] + channel.members.add(player.aliases[0]) + await (global.em).persistAndFlush(channel) + + await request(global.app) + .put(`/v1/game-channels/${channel.id}`) + .send({ name: 'A very interesting chat' }) + .auth(token, { type: 'bearer' }) + .set('x-talo-alias', String(player.aliases[0].id)) + .expect(403) + }) + + it('should not update a channel if it does not have an owner', async () => { + const em: EntityManager = global.em + + const [apiKey, token] = await createAPIKeyAndToken([APIKeyScope.WRITE_GAME_CHANNELS]) + + const channel = await new GameChannelFactory(apiKey.game).one() + const player = await new PlayerFactory([apiKey.game]).one() + channel.members.add(player.aliases[0]) + await em.persistAndFlush(channel) + + const res = await request(global.app) + .put(`/v1/game-channels/${channel.id}`) + .send({ name: 'A very interesting chat' }) + .auth(token, { type: 'bearer' }) + .set('x-talo-alias', String(player.aliases[0].id)) + .expect(403) + + expect(res.body).toStrictEqual({ message: 'This player is not the owner of the channel' }) + }) + + it('should not update a channel if the current alias is not the owner', async () => { + const em: EntityManager = global.em + + const [apiKey, token] = await createAPIKeyAndToken([APIKeyScope.WRITE_GAME_CHANNELS]) + + const channel = await new GameChannelFactory(apiKey.game).one() + const player = await new PlayerFactory([apiKey.game]).one() + channel.owner = (await new PlayerFactory([apiKey.game]).one()).aliases[0] + channel.members.add(player.aliases[0]) + await em.persistAndFlush(channel) + + const res = await request(global.app) + .put(`/v1/game-channels/${channel.id}`) + .send({ name: 'A very interesting chat' }) + .auth(token, { type: 'bearer' }) + .set('x-talo-alias', String(player.aliases[0].id)) + .expect(403) + + expect(res.body).toStrictEqual({ message: 'This player is not the owner of the channel' }) + }) + + it('should update the props of a channel', async () => { + const em: EntityManager = global.em + + const [apiKey, token] = await createAPIKeyAndToken([APIKeyScope.WRITE_GAME_CHANNELS]) + + const channel = await new GameChannelFactory(apiKey.game).state(() => ({ + name: 'Guild chat', + props: [ + { key: 'guildId', value: '1234' }, + { key: 'deleteMe', value: 'yes' } + ] + })).one() + const player = await new PlayerFactory([apiKey.game]).one() + channel.owner = player.aliases[0] + channel.members.add(player.aliases[0]) + await em.persistAndFlush(channel) + + const res = await request(global.app) + .put(`/v1/game-channels/${channel.id}`) + .send({ + props: [ + { key: 'guildId', value: '4321' }, + { key: 'deleteMe', value: null } + ] + }) + .auth(token, { type: 'bearer' }) + .set('x-talo-alias', String(player.aliases[0].id)) + .expect(200) + + expect(res.body.channel.props).toStrictEqual([ + { key: 'guildId', value: '4321' } + ]) + }) + + it('should require props to be an array', async () => { + const em: EntityManager = global.em + + const [apiKey, token] = await createAPIKeyAndToken([APIKeyScope.WRITE_GAME_CHANNELS]) + + const channel = await new GameChannelFactory(apiKey.game).state(() => ({ + name: 'Guild chat', + props: [ + { key: 'guildId', value: '1234' } + ] + })).one() + const player = await new PlayerFactory([apiKey.game]).one() + channel.owner = player.aliases[0] + channel.members.add(player.aliases[0]) + await em.persistAndFlush(channel) + + const res = await request(global.app) + .put(`/v1/game-channels/${channel.id}`) + .send({ + props: { + guildId: '4321' + } + }) + .auth(token, { type: 'bearer' }) + .set('x-talo-alias', String(player.aliases[0].id)) + .expect(400) + + expect(res.body).toStrictEqual({ + errors: { + props: ['Props must be an array'] + } + }) + }) + + it('should update the channel owner', async () => { + const em: EntityManager = global.em + + const [apiKey, token] = await createAPIKeyAndToken([APIKeyScope.WRITE_GAME_CHANNELS]) + + const channel = await new GameChannelFactory(apiKey.game).one() + const player = await new PlayerFactory([apiKey.game]).one() + const newOwner = await new PlayerFactory([apiKey.game]).one() + + channel.owner = player.aliases[0] + channel.members.add(player.aliases[0], newOwner.aliases[0]) + + await em.persistAndFlush(channel) + + const res = await request(global.app) + .put(`/v1/game-channels/${channel.id}`) + .send({ ownerAliasId: newOwner.aliases[0].id }) + .auth(token, { type: 'bearer' }) + .set('x-talo-alias', String(player.aliases[0].id)) + .expect(200) + + expect(res.body.channel.owner.id).toBe(newOwner.aliases[0].id) + }) + + it('should not update the channel owner if the provided alias does not exist', async () => { + const em: EntityManager = global.em + + const [apiKey, token] = await createAPIKeyAndToken([APIKeyScope.WRITE_GAME_CHANNELS]) + + const channel = await new GameChannelFactory(apiKey.game).one() + const player = await new PlayerFactory([apiKey.game]).one() + channel.owner = player.aliases[0] + channel.members.add(player.aliases[0]) + await em.persistAndFlush(channel) + + const res = await request(global.app) + .put(`/v1/game-channels/${channel.id}`) + .send({ ownerAliasId: 3123124 }) + .auth(token, { type: 'bearer' }) + .set('x-talo-alias', String(player.aliases[0].id)) + .expect(404) + + expect(res.body).toStrictEqual({ message: 'New owner not found' }) + }) + + it('should not update a channel with an invalid alias', async () => { + const [apiKey, token] = await createAPIKeyAndToken([APIKeyScope.WRITE_GAME_CHANNELS]) + + const channel = await new GameChannelFactory(apiKey.game).one() + const player = await new PlayerFactory([apiKey.game]).one() + channel.owner = player.aliases[0] + channel.members.add(player.aliases[0]) + await (global.em).persistAndFlush(channel) + + const res = await request(global.app) + .put(`/v1/game-channels/${channel.id}`) + .send({ name: 'A very interesting chat' }) + .auth(token, { type: 'bearer' }) + .set('x-talo-alias', '32144') + .expect(404) + + expect(res.body).toStrictEqual({ + message: 'Player not found' + }) + }) + + it('should not update a channel that does not exist', async () => { + const [apiKey, token] = await createAPIKeyAndToken([APIKeyScope.WRITE_GAME_CHANNELS]) + + const channel = await new GameChannelFactory(apiKey.game).one() + const player = await new PlayerFactory([apiKey.game]).one() + channel.owner = player.aliases[0] + channel.members.add(player.aliases[0]) + await (global.em).persistAndFlush(channel) + + const res = await request(global.app) + .put('/v1/game-channels/54252') + .send({ name: 'A very interesting chat' }) + .auth(token, { type: 'bearer' }) + .set('x-talo-alias', String(player.aliases[0].id)) + .expect(404) + + expect(res.body).toStrictEqual({ + message: 'Channel not found' + }) + }) +}) diff --git a/tests/services/_api/game-channel-api/subscriptions.test.ts b/tests/services/_api/game-channel-api/subscriptions.test.ts new file mode 100644 index 00000000..4c4767d9 --- /dev/null +++ b/tests/services/_api/game-channel-api/subscriptions.test.ts @@ -0,0 +1,62 @@ +import request from 'supertest' +import { EntityManager } from '@mikro-orm/mysql' +import GameChannelFactory from '../../../fixtures/GameChannelFactory' +import { APIKeyScope } from '../../../../src/entities/api-key' +import createAPIKeyAndToken from '../../../utils/createAPIKeyAndToken' +import PlayerFactory from '../../../fixtures/PlayerFactory' + +describe('Game channel API service - subscriptions', () => { + it('should return a list of game channel subscriptions if the scope is valid', async () => { + const [apiKey, token] = await createAPIKeyAndToken([APIKeyScope.READ_GAME_CHANNELS]) + + const subscribedChannel = await new GameChannelFactory(apiKey.game).one() + const player = await new PlayerFactory([apiKey.game]).one() + subscribedChannel.members.add(player.aliases[0]) + + const notSubscribedChannels = await new GameChannelFactory(apiKey.game).many(5) + await (global.em).persistAndFlush([subscribedChannel, ...notSubscribedChannels, player]) + + const res = await request(global.app) + .get('/v1/game-channels/subscriptions') + .auth(token, { type: 'bearer' }) + .set('x-talo-alias', String(player.aliases[0].id)) + .expect(200) + + expect(res.body.channels.length).toBe(1) + expect(res.body.channels[0].id).toBe(subscribedChannel.id) + }) + + it('should not return game channel subscriptions if the scope is not valid', async () => { + const [apiKey, token] = await createAPIKeyAndToken([]) + + const channels = await new GameChannelFactory(apiKey.game).many(10) + const player = await new PlayerFactory([apiKey.game]).one() + channels[0].members.add(player.aliases[0]) + await (global.em).persistAndFlush([...channels, player]) + + await request(global.app) + .get('/v1/game-channels/subscriptions') + .auth(token, { type: 'bearer' }) + .set('x-talo-alias', String(player.aliases[0].id)) + .expect(403) + }) + + it('should not return game channel subscriptions for an invalid alias', async () => { + const [apiKey, token] = await createAPIKeyAndToken([APIKeyScope.READ_GAME_CHANNELS]) + + const channel = await new GameChannelFactory(apiKey.game).one() + const player = await new PlayerFactory([apiKey.game]).one() + channel.members.add(player.aliases[0]) + await (global.em).persistAndFlush(channel) + + const res = await request(global.app) + .get('/v1/game-channels/subscriptions') + .auth(token, { type: 'bearer' }) + .set('x-talo-alias', '32144') + .expect(404) + + expect(res.body).toStrictEqual({ + message: 'Player not found' + }) + }) +}) diff --git a/tests/services/_api/player-api/merge.test.ts b/tests/services/_api/player-api/merge.test.ts index d032562c..0927f2c0 100644 --- a/tests/services/_api/player-api/merge.test.ts +++ b/tests/services/_api/player-api/merge.test.ts @@ -48,12 +48,13 @@ describe('Player API service - merge', () => { }) it('should merge player2 into player1', async () => { + const em: EntityManager = global.em const [apiKey, token] = await createAPIKeyAndToken([APIKeyScope.READ_PLAYERS, APIKeyScope.WRITE_PLAYERS]) const player1 = await new PlayerFactory([apiKey.game]).one() const player2 = await new PlayerFactory([apiKey.game]).one() - await (global.em).persistAndFlush([player1, player2]) + await em.persistAndFlush([player1, player2]) const res = await request(global.app) .post('/v1/players/merge') @@ -64,11 +65,11 @@ describe('Player API service - merge', () => { expect(res.body.player.id).toBe(player1.id) const prevId = player2.id - const aliases = await (global.em).getRepository(PlayerAlias).find({ player: prevId }) + const aliases = await em.getRepository(PlayerAlias).find({ player: prevId }) expect(aliases).toHaveLength(0) - global.em.clear() - const mergedPlayer = await (global.em).getRepository(Player).findOne(prevId) + em.clear() + const mergedPlayer = await em.getRepository(Player).findOne(prevId) expect(mergedPlayer).toBeNull() }) diff --git a/tests/services/game-channel/index.test.ts b/tests/services/game-channel/index.test.ts index a023f693..1e51640a 100644 --- a/tests/services/game-channel/index.test.ts +++ b/tests/services/game-channel/index.test.ts @@ -1,13 +1,155 @@ import request from 'supertest' import createUserAndToken from '../../utils/createUserAndToken' +import createOrganisationAndGame from '../../utils/createOrganisationAndGame' +import { EntityManager } from '@mikro-orm/mysql' +import PlayerAliasFactory from '../../fixtures/PlayerAliasFactory' +import PlayerFactory from '../../fixtures/PlayerFactory' +import GameChannelFactory from '../../fixtures/GameChannelFactory' describe('Game channel service - index', () => { it('should return a list of game channels', async () => { + const [organisation, game] = await createOrganisationAndGame() + const [token] = await createUserAndToken({}, organisation) + + const channels = await new GameChannelFactory(game).many(10) + await (global.em).persistAndFlush(channels) + + const res = await request(global.app) + .get(`/games/${game.id}/game-channels`) + .query({ page: 0 }) + .auth(token, { type: 'bearer' }) + .expect(200) + + res.body.channels.forEach((item, idx) => { + expect(item.id).toBe(channels[idx].id) + }) + }) + + it('should not return game channels for a non-existent game', async () => { + const [token] = await createUserAndToken() + + const res = await request(global.app) + .get('/games/99999/game-channels') + .query({ page: 0 }) + .auth(token, { type: 'bearer' }) + .expect(404) + + expect(res.body).toStrictEqual({ message: 'Game not found' }) + }) + + it('should not return game channels for a game the user has no access to', async () => { + const [, game] = await createOrganisationAndGame() const [token] = await createUserAndToken() + await new GameChannelFactory(game).many(10) + await request(global.app) - .get('/game-channels') + .get(`/games/${game.id}/game-channels`) + .query({ page: 0 }) + .auth(token, { type: 'bearer' }) + .expect(403) + }) + + it('should paginate results when getting channels', async () => { + const [organisation, game] = await createOrganisationAndGame() + const [token] = await createUserAndToken({ organisation }) + + const count = 82 + const channels = await new GameChannelFactory(game).many(count) + await (global.em).persistAndFlush(channels) + + const page = Math.floor(count / 50) + + const res = await request(global.app) + .get(`/games/${game.id}/game-channels`) + .query({ page }) + .auth(token, { type: 'bearer' }) + .expect(200) + + expect(res.body.channels).toHaveLength(channels.length % 50) + expect(res.body.count).toBe(channels.length) + expect(res.body.itemsPerPage).toBe(50) + }) + + it('should search by channel name', async () => { + const [organisation, game] = await createOrganisationAndGame() + const [token] = await createUserAndToken({}, organisation) + + const channelsWithName = await new GameChannelFactory(game).state(() => ({ name: 'General chat' })).many(3) + const channelsWithoutName = await new GameChannelFactory(game).state(() => ({ name: 'Guild chat' })).many(3) + await (global.em).persistAndFlush([...channelsWithName, ...channelsWithoutName]) + + const res = await request(global.app) + .get(`/games/${game.id}/game-channels`) + .query({ search: 'General', page: 0 }) + .auth(token, { type: 'bearer' }) + .expect(200) + + expect(res.body.channels).toHaveLength(channelsWithName.length) + }) + + it('should search by owners', async () => { + const [organisation, game] = await createOrganisationAndGame() + const [token] = await createUserAndToken({}, organisation) + + const player = await new PlayerFactory([game]).one() + const playerAlias = await new PlayerAliasFactory(player).state(async () => ({ player, identifier: 'johnny_the_admin' })).one() + + const channelsWithOwner = await new GameChannelFactory(game).state(() => ({ + owner: playerAlias + })).many(3) + + const channelsWithoutOwner = await new GameChannelFactory(game).many(5) + + await (global.em).persistAndFlush([...channelsWithOwner, ...channelsWithoutOwner]) + + const res = await request(global.app) + .get(`/games/${game.id}/game-channels`) + .query({ search: 'johnny_the_admin', page: 0 }) + .auth(token, { type: 'bearer' }) + .expect(200) + + expect(res.body.channels).toHaveLength(channelsWithOwner.length) + }) + + it('should return all players with the member count if the dev data header is sent', async () => { + const [organisation, game] = await createOrganisationAndGame() + const [token] = await createUserAndToken({}, organisation) + + const channel = await new GameChannelFactory(game).one() + channel.members.add( + (await new PlayerFactory([game]).devBuild().one()).aliases[0], + (await new PlayerFactory([game]).one()).aliases[0] + ) + await (global.em).persistAndFlush(channel) + + const res = await request(global.app) + .get(`/games/${game.id}/game-channels`) + .query({ page: 0 }) .auth(token, { type: 'bearer' }) + .set('x-talo-include-dev-data', '1') .expect(200) + + expect(res.body.channels[0].memberCount).toBe(2) + }) + + it('should not return dev build players in the member count if the dev data header is not sent', async () => { + const [organisation, game] = await createOrganisationAndGame() + const [token] = await createUserAndToken({}, organisation) + + const channel = await new GameChannelFactory(game).one() + channel.members.add( + (await new PlayerFactory([game]).devBuild().one()).aliases[0], + (await new PlayerFactory([game]).one()).aliases[0] + ) + await (global.em).persistAndFlush(channel) + + const res = await request(global.app) + .get(`/games/${game.id}/game-channels`) + .query({ page: 0 }) + .auth(token, { type: 'bearer' }) + .expect(200) + + expect(res.body.channels[0].memberCount).toBe(1) }) }) diff --git a/tests/socket/listeners/playerListeners/identify.test.ts b/tests/socket/listeners/playerListeners/identify.test.ts index 4ba79ddf..ff79ac92 100644 --- a/tests/socket/listeners/playerListeners/identify.test.ts +++ b/tests/socket/listeners/playerListeners/identify.test.ts @@ -82,8 +82,6 @@ describe('Player listeners - identify', () => { await request(global.server) .ws('/') .set('authorization', `Bearer ${token}`) - .set('x-talo-player', player.id) - .set('x-talo-alias', player.aliases[0].id.toString()) .expectJson({ res: 'v1.connected', data: {} @@ -117,8 +115,6 @@ describe('Player listeners - identify', () => { await request(global.server) .ws('/') .set('authorization', `Bearer ${token}`) - .set('x-talo-player', player.id) - .set('x-talo-alias', player.aliases[0].id.toString()) .expectJson({ res: 'v1.connected', data: {} @@ -135,8 +131,8 @@ describe('Player listeners - identify', () => { res: 'v1.error', data: { req: 'v1.players.identify', - message: 'Session token is invalid', - errorCode: 'INVALID_SESSION' + message: 'Invalid session token', + errorCode: 'INVALID_SESSION_TOKEN' } }) .close() diff --git a/tests/socket/router.test.ts b/tests/socket/router.test.ts index 28f4ee25..26f7a4e6 100644 --- a/tests/socket/router.test.ts +++ b/tests/socket/router.test.ts @@ -3,6 +3,8 @@ import Socket from '../../src/socket' import createAPIKeyAndToken from '../utils/createAPIKeyAndToken' import { APIKeyScope } from '../../src/entities/api-key' import createSocketIdentifyMessage from '../utils/requestAuthedSocket' +import { EntityManager } from '@mikro-orm/mysql' +import GameChannelFactory from '../fixtures/GameChannelFactory' describe('Socket router', () => { let socket: Socket @@ -33,7 +35,8 @@ describe('Socket router', () => { data: { req: 'unknown', message: 'Invalid message request', - errorCode: 'INVALID_MESSAGE' + errorCode: 'INVALID_MESSAGE', + cause: '{"blah":"blah"}' } }) .close() @@ -58,7 +61,8 @@ describe('Socket router', () => { data: { req: 'unknown', message: 'Invalid message request', - errorCode: 'INVALID_MESSAGE' + errorCode: 'INVALID_MESSAGE', + cause: '{"req":"v1.magic","data":{}}' } }) .close() @@ -80,7 +84,7 @@ describe('Socket router', () => { channel: { id: 1 }, - message: 'Hello, world!' + message: 'Hello world' } }) .expectJson({ @@ -112,7 +116,7 @@ describe('Socket router', () => { channel: { id: 1 }, - message: 'Hello, world!' + message: 'Hello world' } }) .expectJson({ @@ -126,6 +130,39 @@ describe('Socket router', () => { .close() }) + it('should be able to accept requests where a scope is required and the key has the full access scope', async () => { + const [identifyMessage, token, player] = await createSocketIdentifyMessage([APIKeyScope.FULL_ACCESS]) + const channel = await new GameChannelFactory(player.game).one() + channel.members.add(player.aliases[0]) + await (global.em).persistAndFlush(channel) + + await request(global.server) + .ws('/') + .set('authorization', `Bearer ${token}`) + .expectJson({ + res: 'v1.connected', + data: {} + }) + .sendJson(identifyMessage) + .expectJson() + .sendJson({ + req: 'v1.channels.message', + data: { + channel: { + id: 1 + }, + message: 'Hello world' + } + }) + .expectJson((actual) => { + expect(actual.res).toBe('v1.channels.message') + expect(actual.data.channel.id).toBe(channel.id) + expect(actual.data.message).toBe('Hello world') + expect(actual.data.playerAlias.id).toBe(player.aliases[0].id) + }) + .close() + }) + it('should reject requests where the payload fails the listener\'s validation', async () => { const [identifyMessage, token] = await createSocketIdentifyMessage([APIKeyScope.READ_PLAYERS, APIKeyScope.WRITE_GAME_CHANNELS]) @@ -144,7 +181,7 @@ describe('Socket router', () => { channel: { id: 1 }, - myMessageToTheChannelIsGoingToBeThis: 'Hello, world!' + myMessageToTheChannelIsGoingToBeThis: 'Hello world' } }) .expectJson({ @@ -152,7 +189,8 @@ describe('Socket router', () => { data: { req: 'v1.channels.message', message: 'Invalid message data for request', - errorCode: 'INVALID_MESSAGE' + errorCode: 'INVALID_MESSAGE_DATA', + cause: '{"channel":{"id":1},"myMessageToTheChannelIsGoingToBeThis":"Hello world"}' } }) .close() diff --git a/tests/socket/server.test.ts b/tests/socket/server.test.ts index f21033e6..413f611e 100644 --- a/tests/socket/server.test.ts +++ b/tests/socket/server.test.ts @@ -3,6 +3,8 @@ import Socket from '../../src/socket' import createAPIKeyAndToken from '../utils/createAPIKeyAndToken' import { isToday, subDays } from 'date-fns' import { EntityManager } from '@mikro-orm/mysql' +import { promisify } from 'util' +import jwt from 'jsonwebtoken' describe('Socket server', () => { let socket: Socket @@ -38,4 +40,21 @@ describe('Socket server', () => { .ws('/') .expectClosed(3000) }) + + it('should close connections message when sending an invalid auth header', async () => { + const [apiKey] = await createAPIKeyAndToken([]) + + const payload = { + sub: apiKey.id, + api: true, + iat: Math.floor(new Date(apiKey.createdAt).getTime() / 1000) + } + + const token = await promisify(jwt.sign)(payload, 'not_a_real_signature') + + await request(global.server) + .ws('/') + .set('authorization', `Bearer ${token}`) + .expectClosed(3000) + }) }) From e0ac4958751adb31e80cac71f488d1ef0f7205b1 Mon Sep 17 00:00:00 2001 From: tudor <7089284+tudddorrr@users.noreply.github.com> Date: Sat, 14 Dec 2024 17:43:41 +0000 Subject: [PATCH 13/15] rate limiting --- src/docs/game-channel-api.docs.ts | 3 ++- src/lib/errors/checkRateLimitExceeded.ts | 13 ++++++++++ src/middlewares/limiter-middleware.ts | 11 +++----- src/services/game-channel.service.ts | 3 ++- src/socket/index.ts | 1 + src/socket/messages/socketError.ts | 3 ++- src/socket/router/socketRouter.ts | 5 ++++ src/socket/socketConnection.ts | 31 +++++++++++++++++++++++ tests/services/game-channel/index.test.ts | 19 ++++++++++++++ 9 files changed, 78 insertions(+), 11 deletions(-) create mode 100644 src/lib/errors/checkRateLimitExceeded.ts diff --git a/src/docs/game-channel-api.docs.ts b/src/docs/game-channel-api.docs.ts index a5d9521b..07ff89e4 100644 --- a/src/docs/game-channel-api.docs.ts +++ b/src/docs/game-channel-api.docs.ts @@ -54,7 +54,8 @@ const GameChannelAPIDocs: APIDocs = { } ], count: 2, - itemsPerPage: 50 + itemsPerPage: 50, + isLastPage: true } } ] diff --git a/src/lib/errors/checkRateLimitExceeded.ts b/src/lib/errors/checkRateLimitExceeded.ts new file mode 100644 index 00000000..6a9b5dc9 --- /dev/null +++ b/src/lib/errors/checkRateLimitExceeded.ts @@ -0,0 +1,13 @@ +import Redis from 'ioredis' + +export default async function checkRateLimitExceeded(redis: Redis, key: string, maxRequests: number): Promise { + const current = await redis.get(`requests.${key}`) + + if (Number(current) > maxRequests) { + return true + } else { + await redis.set(key, Number(current) + 1, 'EX', 1) + } + + return false +} diff --git a/src/middlewares/limiter-middleware.ts b/src/middlewares/limiter-middleware.ts index d7209306..02832741 100644 --- a/src/middlewares/limiter-middleware.ts +++ b/src/middlewares/limiter-middleware.ts @@ -1,22 +1,17 @@ import { Context, Next } from 'koa' import { createRedisConnection } from '../config/redis.config' import { isAPIRoute } from './route-middleware' +import checkRateLimitExceeded from '../lib/errors/checkRateLimitExceeded' const MAX_REQUESTS = 50 -const EXPIRE_TIME = 1 export default async function limiterMiddleware(ctx: Context, next: Next): Promise { if (isAPIRoute(ctx) && process.env.NODE_ENV !== 'test') { - const key = `requests:${ctx.state.user.sub}` - - // do it in here so redis constructor only gets called if limiter gets called + const key = ctx.state.user.sub const redis = createRedisConnection(ctx) - const current = await redis.get(key) - if (Number(current) > MAX_REQUESTS) { + if (await checkRateLimitExceeded(redis, key, MAX_REQUESTS)) { ctx.throw(429) - } else { - await redis.set(key, Number(current) + 1, 'EX', EXPIRE_TIME) } } diff --git a/src/services/game-channel.service.ts b/src/services/game-channel.service.ts index 508394ba..7889be56 100644 --- a/src/services/game-channel.service.ts +++ b/src/services/game-channel.service.ts @@ -42,7 +42,8 @@ export default class GameChannelService extends Service { body: { channels: await Promise.all(channels.map((channel) => channel.toJSONWithCount(em, req.ctx.state.includeDevData))), count, - itemsPerPage + itemsPerPage, + isLastPage: (Number(page) * itemsPerPage) + itemsPerPage >= count } } } diff --git a/src/socket/index.ts b/src/socket/index.ts index 141f0e83..5db60e2d 100644 --- a/src/socket/index.ts +++ b/src/socket/index.ts @@ -74,6 +74,7 @@ export default class Socket { if (!connection) return connection.alive = true + connection.rateLimitWarnings-- } /* v8 ignore end */ diff --git a/src/socket/messages/socketError.ts b/src/socket/messages/socketError.ts index 8592f3c9..38bbd476 100644 --- a/src/socket/messages/socketError.ts +++ b/src/socket/messages/socketError.ts @@ -11,7 +11,8 @@ const errorCodes = [ 'LISTENER_ERROR', 'INVALID_SOCKET_TOKEN', 'INVALID_SESSION_TOKEN', - 'MISSING_ACCESS_KEY_SCOPES' + 'MISSING_ACCESS_KEY_SCOPES', + 'RATE_LIMIT_EXCEEDED' ] as const export type SocketErrorCode = typeof errorCodes[number] diff --git a/src/socket/router/socketRouter.ts b/src/socket/router/socketRouter.ts index 9f08dd39..825e5d3b 100644 --- a/src/socket/router/socketRouter.ts +++ b/src/socket/router/socketRouter.ts @@ -32,6 +32,11 @@ export default class SocketRouter { level: 'info' }) + const rateLimitExceeded = await conn.checkRateLimitExceeded() + if (rateLimitExceeded) { + return + } + let message: SocketMessage = null try { diff --git a/src/socket/socketConnection.ts b/src/socket/socketConnection.ts index 67a92b96..9aaf158f 100644 --- a/src/socket/socketConnection.ts +++ b/src/socket/socketConnection.ts @@ -5,6 +5,11 @@ 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' +import SocketError, { sendError } from './messages/socketError' +import checkRateLimitExceeded from '../lib/errors/checkRateLimitExceeded' export default class SocketConnection { alive: boolean = true @@ -13,6 +18,9 @@ export default class SocketConnection { scopes: APIKeyScope[] = [] private headers: IncomingHttpHeaders = {} + rateLimitKey: string = v4() + rateLimitWarnings: number = 0 + constructor(readonly ws: WebSocket, apiKey: APIKey, req: IncomingMessage) { this.game = apiKey.game this.scopes = apiKey.scopes @@ -41,4 +49,27 @@ export default class SocketConnection { } return scopes.every((scope) => this.hasScope(scope)) } + + getRateLimitMaxRequests(): number { + if (this.playerAliasId) { + return 100 + } + return 10 + } + + async checkRateLimitExceeded(): Promise { + const redis = new Redis(redisConfig) + const rateLimitExceeded = await checkRateLimitExceeded(redis, this.rateLimitKey, this.getRateLimitMaxRequests()) + await redis.quit() + + if (rateLimitExceeded) { + this.rateLimitWarnings++ + if (this.rateLimitWarnings > 3) { + this.ws.close(1008, 'RATE_LIMIT_EXCEEDED') + } else { + sendError(this, 'unknown', new SocketError('RATE_LIMIT_EXCEEDED', 'Rate limit exceeded')) + } + return + } + } } diff --git a/tests/services/game-channel/index.test.ts b/tests/services/game-channel/index.test.ts index 1e51640a..a3a2a3b6 100644 --- a/tests/services/game-channel/index.test.ts +++ b/tests/services/game-channel/index.test.ts @@ -152,4 +152,23 @@ describe('Game channel service - index', () => { expect(res.body.channels[0].memberCount).toBe(1) }) + + it('should mark the last page of channels', async () => { + const [organisation, game] = await createOrganisationAndGame() + const [token] = await createUserAndToken({}, organisation) + + const channels = await new GameChannelFactory(game).many(208) + await (global.em).persistAndFlush(channels) + + const res = await request(global.app) + .get(`/games/${game.id}/game-channels`) + .query({ page: 4 }) + .auth(token, { type: 'bearer' }) + .expect(200) + + expect(res.body.channels).toHaveLength(8) + expect(res.body.count).toBe(208) + expect(res.body.itemsPerPage).toBe(50) + expect(res.body.isLastPage).toBe(true) + }) }) From 41cfdefb8a2452019382e19155c37db9b4949be2 Mon Sep 17 00:00:00 2001 From: tudor <7089284+tudddorrr@users.noreply.github.com> Date: Sat, 14 Dec 2024 21:50:29 +0000 Subject: [PATCH 14/15] extra tests --- src/lib/errors/checkRateLimitExceeded.ts | 5 +- src/services/api/game-channel-api.service.ts | 4 +- src/socket/index.ts | 4 +- src/socket/listeners/playerListeners.ts | 5 +- .../_api/game-channel-api/join.test.ts | 44 ++++++++ .../_api/game-channel-api/leave.test.ts | 45 ++++++++ tests/services/api-key/delete.test.ts | 38 +++++++ tests/setupTest.ts | 2 + tests/socket/rateLimiting.test.ts | 100 ++++++++++++++++++ 9 files changed, 238 insertions(+), 9 deletions(-) create mode 100644 tests/socket/rateLimiting.test.ts diff --git a/src/lib/errors/checkRateLimitExceeded.ts b/src/lib/errors/checkRateLimitExceeded.ts index 6a9b5dc9..3eebf914 100644 --- a/src/lib/errors/checkRateLimitExceeded.ts +++ b/src/lib/errors/checkRateLimitExceeded.ts @@ -1,12 +1,13 @@ import Redis from 'ioredis' export default async function checkRateLimitExceeded(redis: Redis, key: string, maxRequests: number): Promise { - const current = await redis.get(`requests.${key}`) + const redisKey = `requests.${key}` + const current = await redis.get(redisKey) if (Number(current) > maxRequests) { return true } else { - await redis.set(key, Number(current) + 1, 'EX', 1) + await redis.set(redisKey, Number(current) + 1, 'EX', 1) } return false diff --git a/src/services/api/game-channel-api.service.ts b/src/services/api/game-channel-api.service.ts index 4fdb9cba..488c1676 100644 --- a/src/services/api/game-channel-api.service.ts +++ b/src/services/api/game-channel-api.service.ts @@ -132,12 +132,12 @@ export default class GameChannelAPIService extends APIService { const channel: GameChannel = req.ctx.state.channel if (!(await channel.members.load()).getIdentifiers().includes(req.ctx.state.alias.id)) { + channel.members.add(req.ctx.state.alias) sendMessageToChannelMembers(req, channel, 'v1.channels.player-joined', { channel, playerAlias: req.ctx.state.alias }) - channel.members.add(req.ctx.state.alias) await em.flush() } @@ -170,11 +170,11 @@ export default class GameChannelAPIService extends APIService { channel.owner = null } - channel.members.remove(req.ctx.state.alias) sendMessageToChannelMembers(req, channel, 'v1.channels.player-left', { channel, playerAlias: req.ctx.state.alias }) + channel.members.remove(req.ctx.state.alias) await em.flush() } diff --git a/src/socket/index.ts b/src/socket/index.ts index 5db60e2d..9bf36c92 100644 --- a/src/socket/index.ts +++ b/src/socket/index.ts @@ -74,7 +74,9 @@ export default class Socket { if (!connection) return connection.alive = true - connection.rateLimitWarnings-- + if (connection.rateLimitWarnings > 0) { + connection.rateLimitWarnings-- + } } /* v8 ignore end */ diff --git a/src/socket/listeners/playerListeners.ts b/src/socket/listeners/playerListeners.ts index 0a19e9f0..2e9075aa 100644 --- a/src/socket/listeners/playerListeners.ts +++ b/src/socket/listeners/playerListeners.ts @@ -39,15 +39,12 @@ const playerListeners: SocketMessageListener[] = [ if (alias.service === PlayerAliasService.TALO) { try { - const valid = await validateSessionTokenJWT( + await validateSessionTokenJWT( data.sessionToken, alias, alias.player.id, alias.id ) - if (!valid) { - throw new Error() - } } catch (err) { sendError(conn, req, new SocketError('INVALID_SESSION_TOKEN', 'Invalid session token')) return diff --git a/tests/services/_api/game-channel-api/join.test.ts b/tests/services/_api/game-channel-api/join.test.ts index 36de850f..6f8aff0e 100644 --- a/tests/services/_api/game-channel-api/join.test.ts +++ b/tests/services/_api/game-channel-api/join.test.ts @@ -1,11 +1,25 @@ import request from 'supertest' +import requestWs from 'superwstest' import { EntityManager } from '@mikro-orm/mysql' import GameChannelFactory from '../../../fixtures/GameChannelFactory' import { APIKeyScope } from '../../../../src/entities/api-key' import createAPIKeyAndToken from '../../../utils/createAPIKeyAndToken' import PlayerFactory from '../../../fixtures/PlayerFactory' +import createSocketIdentifyMessage from '../../../utils/requestAuthedSocket' +import Socket from '../../../../src/socket' describe('Game channel API service - join', () => { + let socket: Socket + + beforeAll(() => { + socket = new Socket(global.server, global.em) + global.ctx.wss = socket + }) + + afterAll(() => { + socket.getServer().close() + }) + it('should join a channel if the scope is valid', async () => { const [apiKey, token] = await createAPIKeyAndToken([APIKeyScope.WRITE_GAME_CHANNELS]) @@ -89,4 +103,34 @@ describe('Game channel API service - join', () => { message: 'Channel not found' }) }) + + it('should notify players in the channel when a new player joins', async () => { + const [identifyMessage, token, player] = await createSocketIdentifyMessage([ + APIKeyScope.READ_PLAYERS, + APIKeyScope.READ_GAME_CHANNELS, + APIKeyScope.WRITE_GAME_CHANNELS + ]) + + const channel = await new GameChannelFactory(player.game).one() + await (global.em).persistAndFlush([channel, player]) + + await requestWs(global.server) + .ws('/') + .set('authorization', `Bearer ${token}`) + .expectJson() + .sendJson(identifyMessage) + .expectJson() + .exec(async () => { + await request(global.app) + .post(`/v1/game-channels/${channel.id}/join`) + .auth(token, { type: 'bearer' }) + .set('x-talo-alias', String(player.aliases[0].id)) + .expect(200) + }) + .expectJson((actual) => { + expect(actual.res).toBe('v1.channels.player-joined') + expect(actual.data.channel.id).toBe(channel.id) + expect(actual.data.playerAlias.id).toBe(player.aliases[0].id) + }) + }) }) diff --git a/tests/services/_api/game-channel-api/leave.test.ts b/tests/services/_api/game-channel-api/leave.test.ts index 7dde4635..1b683f2b 100644 --- a/tests/services/_api/game-channel-api/leave.test.ts +++ b/tests/services/_api/game-channel-api/leave.test.ts @@ -1,12 +1,26 @@ import request from 'supertest' +import requestWs from 'superwstest' import { EntityManager } from '@mikro-orm/mysql' import GameChannelFactory from '../../../fixtures/GameChannelFactory' import { APIKeyScope } from '../../../../src/entities/api-key' import createAPIKeyAndToken from '../../../utils/createAPIKeyAndToken' import PlayerFactory from '../../../fixtures/PlayerFactory' import GameChannel from '../../../../src/entities/game-channel' +import Socket from '../../../../src/socket' +import createSocketIdentifyMessage from '../../../utils/requestAuthedSocket' describe('Game channel API service - leave', () => { + let socket: Socket + + beforeAll(() => { + socket = new Socket(global.server, global.em) + global.ctx.wss = socket + }) + + afterAll(() => { + socket.getServer().close() + }) + it('should leave a channel if the scope is valid', async () => { const [apiKey, token] = await createAPIKeyAndToken([APIKeyScope.WRITE_GAME_CHANNELS]) @@ -162,4 +176,35 @@ describe('Game channel API service - leave', () => { message: 'Channel not found' }) }) + + it('should notify players in the channel when a player leaves', async () => { + const [identifyMessage, token, player] = await createSocketIdentifyMessage([ + APIKeyScope.READ_PLAYERS, + APIKeyScope.READ_GAME_CHANNELS, + APIKeyScope.WRITE_GAME_CHANNELS + ]) + + const channel = await new GameChannelFactory(player.game).one() + channel.members.add(player.aliases[0]) + await (global.em).persistAndFlush(channel) + + await requestWs(global.server) + .ws('/') + .set('authorization', `Bearer ${token}`) + .expectJson() + .sendJson(identifyMessage) + .expectJson() + .exec(async () => { + await request(global.app) + .post(`/v1/game-channels/${channel.id}/leave`) + .auth(token, { type: 'bearer' }) + .set('x-talo-alias', String(player.aliases[0].id)) + .expect(204) + }) + .expectJson((actual) => { + expect(actual.res).toBe('v1.channels.player-left') + expect(actual.data.channel.id).toBe(channel.id) + expect(actual.data.playerAlias.id).toBe(player.aliases[0].id) + }) + }) }) diff --git a/tests/services/api-key/delete.test.ts b/tests/services/api-key/delete.test.ts index 73fd3657..ed474205 100644 --- a/tests/services/api-key/delete.test.ts +++ b/tests/services/api-key/delete.test.ts @@ -1,5 +1,6 @@ import { EntityManager } from '@mikro-orm/mysql' import request from 'supertest' +import requestWs from 'superwstest' import { UserType } from '../../../src/entities/user' import APIKey from '../../../src/entities/api-key' import UserFactory from '../../fixtures/UserFactory' @@ -7,8 +8,21 @@ import GameActivity, { GameActivityType } from '../../../src/entities/game-activ import userPermissionProvider from '../../utils/userPermissionProvider' import createUserAndToken from '../../utils/createUserAndToken' import createOrganisationAndGame from '../../utils/createOrganisationAndGame' +import Socket from '../../../src/socket' +import { createToken } from '../../../src/services/api-key.service' describe('API key service - delete', () => { + let socket: Socket + + beforeAll(() => { + socket = new Socket(global.server, global.em) + global.ctx.wss = socket + }) + + afterAll(() => { + socket.getServer().close() + }) + it.each(userPermissionProvider([ UserType.ADMIN ], 204))('should return a %i for a %s user', async (statusCode, _, type) => { @@ -71,4 +85,28 @@ describe('API key service - delete', () => { expect(res.body).toStrictEqual({ message: 'You need to confirm your email address to revoke API keys' }) }) + + it('should disconnect socket connections for the api key', async () => { + const [organisation, game] = await createOrganisationAndGame() + const [token, user] = await createUserAndToken({ type: UserType.ADMIN, emailConfirmed: true }, organisation) + + const key = new APIKey(game, user) + await (global.em).persistAndFlush(key) + const apiToken = await createToken(global.em, key) + + await requestWs(global.server) + .ws('/') + .set('authorization', `Bearer ${apiToken}`) + .expectJson({ + res: 'v1.connected', + data: {} + }) + .exec(async () => { + await request(global.app) + .delete(`/games/${game.id}/api-keys/${key.id}`) + .auth(token, { type: 'bearer' }) + .expect(204) + }) + .expectClosed(3000) + }) }) diff --git a/tests/setupTest.ts b/tests/setupTest.ts index cfdc0208..3aa9e854 100644 --- a/tests/setupTest.ts +++ b/tests/setupTest.ts @@ -15,6 +15,7 @@ beforeAll(async () => { const app = await init() global.app = app.callback() + global.ctx = app.context global.em = app.context.em global.server = createServer() @@ -35,6 +36,7 @@ afterAll(async () => { clickhouse.close() delete global.em + delete global.ctx delete global.app delete global.server delete global.clickhouse diff --git a/tests/socket/rateLimiting.test.ts b/tests/socket/rateLimiting.test.ts new file mode 100644 index 00000000..2079bfc2 --- /dev/null +++ b/tests/socket/rateLimiting.test.ts @@ -0,0 +1,100 @@ +import request from 'superwstest' +import Socket from '../../src/socket' +import { APIKeyScope } from '../../src/entities/api-key' +import createSocketIdentifyMessage from '../utils/requestAuthedSocket' +import GameChannelFactory from '../fixtures/GameChannelFactory' +import { EntityManager } from '@mikro-orm/mysql' +import Redis from 'ioredis' +import redisConfig from '../../src/config/redis.config' + +describe('Socket rate limiting', () => { + let socket: Socket + + beforeAll(() => { + socket = new Socket(global.server, global.em) + global.ctx.wss = socket + }) + + afterAll(() => { + socket.getServer().close() + }) + + it('should return a rate limiting error', async () => { + const [identifyMessage, token, player] = await createSocketIdentifyMessage([ + APIKeyScope.READ_PLAYERS, + APIKeyScope.READ_GAME_CHANNELS, + APIKeyScope.WRITE_GAME_CHANNELS + ]) + const channel = await new GameChannelFactory(player.game).one() + channel.members.add(player.aliases[0]) + await (global.em).persistAndFlush(channel) + + await request(global.server) + .ws('/') + .set('authorization', `Bearer ${token}`) + .expectJson() + .sendJson(identifyMessage) + .expectJson() + .exec(async () => { + const conn = socket.findConnections((conn) => conn.playerAliasId === player.aliases[0].id)[0] + + const redis = new Redis(redisConfig) + await redis.set(`requests.${conn.rateLimitKey}`, 999) + await redis.quit() + }) + .sendJson({ + req: 'v1.channels.message', + data: { + channel: { + id: channel.id + }, + message: 'Hello world' + } + }) + .expectJson({ + res: 'v1.error', + data: { + req: 'unknown', + message: 'Rate limit exceeded', + errorCode: 'RATE_LIMIT_EXCEEDED' + } + }) + .close() + }) + + it('should disconnect connections after 3 warnings', async () => { + const [identifyMessage, token, player] = await createSocketIdentifyMessage([ + APIKeyScope.READ_PLAYERS, + APIKeyScope.READ_GAME_CHANNELS, + APIKeyScope.WRITE_GAME_CHANNELS + ]) + const channel = await new GameChannelFactory(player.game).one() + channel.members.add(player.aliases[0]) + await (global.em).persistAndFlush(channel) + + await request(global.server) + .ws('/') + .set('authorization', `Bearer ${token}`) + .expectJson() + .sendJson(identifyMessage) + .expectJson() + .exec(async () => { + const conn = socket.findConnections((conn) => conn.playerAliasId === player.aliases[0].id)[0] + conn.rateLimitWarnings = 3 + + const redis = new Redis(redisConfig) + await redis.set(`requests.${conn.rateLimitKey}`, 999) + await redis.quit() + }) + .sendJson({ + req: 'v1.channels.message', + data: { + channel: { + id: channel.id + }, + message: 'Hello world' + } + }) + .expectClosed(1008, 'RATE_LIMIT_EXCEEDED') + }) +}) From 483f164289ce8b92bf4aae54332420aa9122a9d6 Mon Sep 17 00:00:00 2001 From: tudor <7089284+tudddorrr@users.noreply.github.com> Date: Sat, 14 Dec 2024 22:46:04 +0000 Subject: [PATCH 15/15] centralise closing sockets --- src/docs/game-channel-api.docs.ts | 2 ++ src/services/api-key.service.ts | 2 +- src/socket/authenticateSocket.ts | 5 +---- src/socket/index.ts | 28 +++++++++++++++++++++++----- src/socket/messages/socketMessage.ts | 6 +++--- src/socket/router/socketRouter.ts | 5 +++++ src/socket/socketConnection.ts | 9 ++------- 7 files changed, 37 insertions(+), 20 deletions(-) diff --git a/src/docs/game-channel-api.docs.ts b/src/docs/game-channel-api.docs.ts index 07ff89e4..f5c6a80d 100644 --- a/src/docs/game-channel-api.docs.ts +++ b/src/docs/game-channel-api.docs.ts @@ -45,6 +45,8 @@ const GameChannelAPIDocs: APIDocs = { createdAt: '2024-10-25T18:18:28.000Z', updatedAt: '2024-12-04T07:15:13.000Z' }, + totalMessages: 36, + memberCount: 8, props: [ { key: 'channelType', value: 'guild' }, { key: 'guildId', value: '5912' } diff --git a/src/services/api-key.service.ts b/src/services/api-key.service.ts index 7aa6ef48..68cdc98e 100644 --- a/src/services/api-key.service.ts +++ b/src/services/api-key.service.ts @@ -113,7 +113,7 @@ export default class APIKeyService extends Service { const socket: Socket = req.ctx.wss const conns = socket.findConnections((conn) => conn.getAPIKeyId() === apiKey.id) for (const conn of conns) { - conn.ws.close(3000) + socket.closeConnection(conn.ws) } await em.flush() diff --git a/src/socket/authenticateSocket.ts b/src/socket/authenticateSocket.ts index 67b3523e..62a82c97 100644 --- a/src/socket/authenticateSocket.ts +++ b/src/socket/authenticateSocket.ts @@ -1,14 +1,12 @@ -import { WebSocket } from 'ws' import getAPIKeyFromToken from '../lib/auth/getAPIKeyFromToken' import { promisify } from 'util' import jwt from 'jsonwebtoken' import { RequestContext } from '@mikro-orm/core' import APIKey from '../entities/api-key' -export default async function authenticateSocket(authHeader: string, ws: WebSocket): Promise { +export default async function authenticateSocket(authHeader: string): Promise { const apiKey = await getAPIKeyFromToken(authHeader) if (!apiKey || apiKey.revokedAt) { - ws.close(3000) return } @@ -20,7 +18,6 @@ export default async function authenticateSocket(authHeader: string, ws: WebSock const secret = apiKey.game.apiSecret.getPlainSecret() await promisify(jwt.verify)(token, secret) } catch (err) { - ws.close(3000) return } diff --git a/src/socket/index.ts b/src/socket/index.ts index 9bf36c92..ff431285 100644 --- a/src/socket/index.ts +++ b/src/socket/index.ts @@ -7,6 +7,13 @@ import SocketConnection from './socketConnection' import SocketRouter from './router/socketRouter' import { sendMessage } from './messages/socketMessage' +type CloseConnectionOptions = { + code?: number + reason?: string + terminate?: boolean + preclosed?: boolean +} + export default class Socket { private readonly wss: WebSocketServer private connections: SocketConnection[] = [] @@ -19,7 +26,7 @@ export default class Socket { ws.on('message', (data) => this.handleMessage(ws, data)) ws.on('pong', () => this.handlePong(ws)) - ws.on('close', () => this.handleCloseConnection(ws)) + ws.on('close', () => this.closeConnection(ws, { preclosed: true })) ws.on('error', captureException) }) @@ -37,7 +44,7 @@ export default class Socket { this.connections.forEach((conn) => { /* v8 ignore start */ if (!conn.alive) { - conn.ws.terminate() + this.closeConnection(conn.ws, { terminate: true }) return } @@ -54,10 +61,12 @@ export default class Socket { async handleConnection(ws: WebSocket, req: IncomingMessage): Promise { await RequestContext.create(this.em, async () => { - const key = await authenticateSocket(req.headers?.authorization ?? '', ws) + const key = await authenticateSocket(req.headers?.authorization ?? '') if (key) { this.connections.push(new SocketConnection(ws, key, req)) sendMessage(this.connections.at(-1), 'v1.connected', {}) + } else { + this.closeConnection(ws) } }) } @@ -80,7 +89,16 @@ export default class Socket { } /* v8 ignore end */ - handleCloseConnection(ws: WebSocket): void { + closeConnection(ws: WebSocket, options: CloseConnectionOptions = {}): void { + const terminate = options.terminate ?? false + const preclosed = options.preclosed ?? false + + if (terminate) { + ws.terminate() + } else if (!preclosed) { + ws.close(options.code ?? 3000, options.reason) + } + this.connections = this.connections.filter((conn) => conn.ws !== ws) } @@ -88,7 +106,7 @@ export default class Socket { const connection = this.connections.find((conn) => conn.ws === ws) /* v8 ignore start */ if (!connection) { - ws.close(3000) + this.closeConnection(ws) return } /* v8 ignore end */ diff --git a/src/socket/messages/socketMessage.ts b/src/socket/messages/socketMessage.ts index 8c846733..bcdd4050 100644 --- a/src/socket/messages/socketMessage.ts +++ b/src/socket/messages/socketMessage.ts @@ -28,9 +28,9 @@ export function sendMessage(conn: SocketConnection, res: SocketMessageRespons } export function sendMessages(conns: SocketConnection[], type: SocketMessageResponse, data: T) { - conns.forEach((ws) => { - if (ws.ws.readyState === ws.ws.OPEN) { - sendMessage(ws, type, data) + conns.forEach((conn) => { + if (conn.ws.readyState === conn.ws.OPEN) { + sendMessage(conn, type, data) } }) } diff --git a/src/socket/router/socketRouter.ts b/src/socket/router/socketRouter.ts index 825e5d3b..3e45d34f 100644 --- a/src/socket/router/socketRouter.ts +++ b/src/socket/router/socketRouter.ts @@ -34,6 +34,11 @@ export default class SocketRouter { const rateLimitExceeded = await conn.checkRateLimitExceeded() if (rateLimitExceeded) { + if (conn.rateLimitWarnings > 3) { + this.socket.closeConnection(conn.ws, { code: 1008, reason: 'RATE_LIMIT_EXCEEDED' }) + } else { + sendError(conn, 'unknown', new SocketError('RATE_LIMIT_EXCEEDED', 'Rate limit exceeded')) + } return } diff --git a/src/socket/socketConnection.ts b/src/socket/socketConnection.ts index 9aaf158f..e32dfe7b 100644 --- a/src/socket/socketConnection.ts +++ b/src/socket/socketConnection.ts @@ -8,7 +8,6 @@ import jwt from 'jsonwebtoken' import { v4 } from 'uuid' import Redis from 'ioredis' import redisConfig from '../config/redis.config' -import SocketError, { sendError } from './messages/socketError' import checkRateLimitExceeded from '../lib/errors/checkRateLimitExceeded' export default class SocketConnection { @@ -64,12 +63,8 @@ export default class SocketConnection { if (rateLimitExceeded) { this.rateLimitWarnings++ - if (this.rateLimitWarnings > 3) { - this.ws.close(1008, 'RATE_LIMIT_EXCEEDED') - } else { - sendError(this, 'unknown', new SocketError('RATE_LIMIT_EXCEEDED', 'Rate limit exceeded')) - } - return } + + return rateLimitExceeded } }