From 611eb24f3c6f4654029cb5d7eb3bda1185465097 Mon Sep 17 00:00:00 2001 From: tudor <7089284+tudddorrr@users.noreply.github.com> Date: Sun, 22 Sep 2024 16:29:30 +0100 Subject: [PATCH 1/5] allow players to authenticate with steam tickets --- src/entities/integration.ts | 9 +- src/entities/player.ts | 10 + .../integrations/steamworks-integration.ts | 95 +++++++ src/services/api/player-api.service.ts | 55 +++- src/services/api/player-auth-api.service.ts | 2 +- tests/fixtures/PlayerFactory.ts | 6 +- .../_api/game-stat-api/steamworksPut.test.ts | 2 - .../player-api/steamworksIdentify.test.ts | 258 ++++++++++++++++++ 8 files changed, 419 insertions(+), 18 deletions(-) create mode 100644 tests/services/_api/player-api/steamworksIdentify.test.ts diff --git a/src/entities/integration.ts b/src/entities/integration.ts index f27c3921..9f533304 100644 --- a/src/entities/integration.ts +++ b/src/entities/integration.ts @@ -2,7 +2,7 @@ import { Entity, EntityManager, Enum, Filter, ManyToOne, PrimaryKey, Property } import { Request, Required, ValidationCondition } from 'koa-clay' import { decrypt, encrypt } from '../lib/crypto/string-encryption' import Game from './game' -import { createSteamworksLeaderboard, createSteamworksLeaderboardEntry, deleteSteamworksLeaderboard, deleteSteamworksLeaderboardEntry, setSteamworksStat, syncSteamworksLeaderboards, syncSteamworksStats } from '../lib/integrations/steamworks-integration' +import { authenticateTicket, createSteamworksLeaderboard, createSteamworksLeaderboardEntry, deleteSteamworksLeaderboard, deleteSteamworksLeaderboardEntry, setSteamworksStat, syncSteamworksLeaderboards, syncSteamworksStats } from '../lib/integrations/steamworks-integration' import Leaderboard from './leaderboard' import { pick } from 'lodash' import LeaderboardEntry from './leaderboard-entry' @@ -176,6 +176,13 @@ export default class Integration { } } + async getPlayerIdentifier(req: Request, identifier: string): Promise { + switch (this.type) { + case IntegrationType.STEAMWORKS: + return authenticateTicket(req, this, identifier) + } + } + toJSON() { return { id: this.id, diff --git a/src/entities/player.ts b/src/entities/player.ts index 711596a0..809b3355 100644 --- a/src/entities/player.ts +++ b/src/entities/player.ts @@ -47,6 +47,16 @@ export default class Player { this.props.add(new PlayerProp(this, key, value)) } + upsertProp(key: string, value: string) { + const prop = this.props.getItems().find((prop) => prop.key === key) + + if (prop) { + prop.value = value + } else { + this.addProp(key, value) + } + } + setProps(props: { key: string, value: string }[]) { this.props.set(props.map(({ key, value }) => new PlayerProp(this, key, value))) } diff --git a/src/lib/integrations/steamworks-integration.ts b/src/lib/integrations/steamworks-integration.ts index 9c3d69e9..fcbb7fca 100644 --- a/src/lib/integrations/steamworks-integration.ts +++ b/src/lib/integrations/steamworks-integration.ts @@ -11,6 +11,7 @@ import Player from '../../entities/player' import { performance } from 'perf_hooks' import GameStat from '../../entities/game-stat' import PlayerGameStat from '../../entities/player-game-stat' +import { Request } from 'koa-clay' type SteamworksRequestConfig = { method: SteamworksRequestMethod @@ -101,6 +102,35 @@ export type GetUserStatsForGameResponse = { } } +export type AuthenticateUserTicketResponse = { + response: { + params?: { + result: 'OK' + steamid: string + ownersteamid: string + vacbanned: boolean + publisherbanned: boolean + } + error?: { + errorcode: number + errordesc: string + } + } +} + +export type CheckAppOwnershipResponse = { + appownership: { + ownsapp: boolean + permanent: boolean + timestamp: string + ownersteamid: string + sitelicense: boolean + timedtrial: boolean + usercanceled: boolean + result: 'OK' + } +} + function createSteamworksRequestConfig(integration: Integration, method: SteamworksRequestMethod, url: string, body = ''): SteamworksRequestConfig { return { method, @@ -465,3 +495,68 @@ export async function syncSteamworksStats(em: EntityManager, integration: Integr await setSteamworksStat(em, integration, unsyncedPlayerStat, steamAlias) } } + +export async function authenticateTicket(req: Request, integration: Integration, identifier: string): Promise { + const em: EntityManager = req.ctx.em + + const parts = identifier.split(':') + const identity = parts.length > 1 ? parts[0] : undefined + const ticket = parts.at(-1) + + const config = createSteamworksRequestConfig(integration, 'GET', `/ISteamUserAuth/AuthenticateUserTicket/v1?appid=${integration.getConfig().appId}&ticket=${ticket}${identity ? `&identity=${identity}` : ''}`) + const event = createSteamworksIntegrationEvent(integration, config) + const res = await makeRequest(config, event) + await em.persistAndFlush(event) + + if (res.data.response.error) { + const message = `Failed to authenticate Steamworks ticket: ${res.data.response.error.errordesc} (${res.data.response.error.errorcode})` + throw new Error(message, { cause: 400 }) + } + + const steamId = res.data.response.params.steamid + const alias = await em.getRepository(PlayerAlias).findOne({ + service: PlayerAliasService.STEAM, + identifier: steamId, + player: { + game: integration.game + } + }) + + const { + appownership: { + ownsapp, + permanent, + timestamp + } + } = await verifyOwnership(em, integration, steamId) + + const { vacbanned, publisherbanned } = res.data.response.params + + if (alias) { + alias.player.upsertProp('META_STEAMWORKS_VAC_BANNED', String(vacbanned)) + alias.player.upsertProp('META_STEAMWORKS_PUBLISHER_BANNED', String(publisherbanned)) + alias.player.upsertProp('META_STEAMWORKS_OWNS_APP', String(ownsapp)) + alias.player.upsertProp('META_STEAMWORKS_OWNS_APP_PERMANENTLY', String(permanent)) + alias.player.upsertProp('META_STEAMWORKS_OWNS_APP_FROM_DATE', timestamp) + await em.flush() + } else { + req.ctx.state.initialPlayerProps = [ + { key: 'META_STEAMWORKS_VAC_BANNED', value: String(vacbanned) }, + { key: 'META_STEAMWORKS_PUBLISHER_BANNED', value: String(publisherbanned) }, + { key: 'META_STEAMWORKS_OWNS_APP', value: String(ownsapp) }, + { key: 'META_STEAMWORKS_OWNS_APP_PERMANENTLY', value: String(permanent) }, + { key: 'META_STEAMWORKS_OWNS_APP_FROM_DATE', value: timestamp } + ] + } + + return steamId +} + +export async function verifyOwnership(em: EntityManager, integration: Integration, steamId: string): Promise { + const config = createSteamworksRequestConfig(integration, 'GET', `/ISteamUser/CheckAppOwnership/v3?appid=${integration.getConfig().appId}&steamid=${steamId}`) + const event = createSteamworksIntegrationEvent(integration, config) + const res = await makeRequest(config, event) + await em.persistAndFlush(event) + + return res.data +} diff --git a/src/services/api/player-api.service.ts b/src/services/api/player-api.service.ts index 9eacde85..84eb8f84 100644 --- a/src/services/api/player-api.service.ts +++ b/src/services/api/player-api.service.ts @@ -11,16 +11,37 @@ import PlayerAPIDocs from '../../docs/player-api.docs' import PlayerProp from '../../entities/player-prop' import PlayerGameStat from '../../entities/player-game-stat' import checkScope from '../../policies/checkScope' +import Integration, { IntegrationType } from '../../entities/integration' -export function findAliasFromIdentifyRequest( - em: EntityManager, +async function getRealIdentifier( + req: Request, + key: APIKey, + service: string, + identifier: string +): Promise { + if (service === PlayerAliasService.STEAM) { + const integration = await (req.ctx.em as EntityManager).getRepository(Integration).findOne({ + game: key.game, + type: IntegrationType.STEAMWORKS + }) + + if (integration) { + return integration.getPlayerIdentifier(req, identifier) + } + } + + return identifier +} + +export async function findAliasFromIdentifyRequest( + req: Request, key: APIKey, service: string, identifier: string ): Promise { - return em.getRepository(PlayerAlias).findOne({ + return (req.ctx.em as EntityManager).getRepository(PlayerAlias).findOne({ service, - identifier, + identifier: await getRealIdentifier(req, key, service, identifier), player: { game: key.game } @@ -38,7 +59,8 @@ export async function createPlayerFromIdentifyRequest( if (checkScope(key, APIKeyScope.WRITE_PLAYERS)) { const res = await forwardRequest(req, { body: { - aliases: [{ service, identifier }] + aliases: [{ service, identifier: await getRealIdentifier(req, key, service, identifier) }], + props: req.ctx.state.initialPlayerProps } }) @@ -77,14 +99,23 @@ export default class PlayerAPIService extends APIService { const em: EntityManager = req.ctx.em const key = await this.getAPIKey(req.ctx) - - let alias = await findAliasFromIdentifyRequest(em, key, service, identifier) - if (!alias) { - if (service === PlayerAliasService.TALO) { - req.ctx.throw(404, 'Player not found: Talo aliases must be created using the /v1/players/auth API') + let alias: PlayerAlias = null + + try { + alias = await findAliasFromIdentifyRequest(req, key, service, identifier) + if (!alias) { + if (service === PlayerAliasService.TALO) { + req.ctx.throw(404, 'Player not found: Talo aliases must be created using the /v1/players/auth API') + } else { + const player = await createPlayerFromIdentifyRequest(req, key, service, identifier) + alias = player?.aliases[0] + } + } + } catch (err) { + if (err instanceof Error && err.cause === 400) { + req.ctx.throw(400, err.message) } else { - const player = await createPlayerFromIdentifyRequest(req, key, service, identifier) - alias = player?.aliases[0] + throw err } } diff --git a/src/services/api/player-auth-api.service.ts b/src/services/api/player-auth-api.service.ts index abae7f8d..7afb3755 100644 --- a/src/services/api/player-auth-api.service.ts +++ b/src/services/api/player-auth-api.service.ts @@ -151,7 +151,7 @@ export default class PlayerAuthAPIService extends APIService { const key = await this.getAPIKey(req.ctx) - const alias = await findAliasFromIdentifyRequest(em, key, PlayerAliasService.TALO, identifier) + const alias = await findAliasFromIdentifyRequest(req, key, PlayerAliasService.TALO, identifier) if (!alias) this.handleFailedLogin(req) const passwordMatches = await bcrypt.compare(password, alias.player.auth.password) diff --git a/tests/fixtures/PlayerFactory.ts b/tests/fixtures/PlayerFactory.ts index f7df45f1..131c25b8 100644 --- a/tests/fixtures/PlayerFactory.ts +++ b/tests/fixtures/PlayerFactory.ts @@ -76,9 +76,11 @@ export default class PlayerFactory extends Factory { }) } - withSteamAlias(): this { + withSteamAlias(steamId?: string): this { return this.state(async (player: Player) => { - const alias = await new PlayerAliasFactory(player).steam().one() + const alias = await new PlayerAliasFactory(player).steam().state(() => ({ + identifier: steamId ?? casual.integer(100000, 1000000).toString() + })).one() return { aliases: new Collection(player, [alias]) diff --git a/tests/services/_api/game-stat-api/steamworksPut.test.ts b/tests/services/_api/game-stat-api/steamworksPut.test.ts index f7cf9102..26cf03e9 100644 --- a/tests/services/_api/game-stat-api/steamworksPut.test.ts +++ b/tests/services/_api/game-stat-api/steamworksPut.test.ts @@ -80,7 +80,5 @@ describe('Game stats API service - put - steamworks integration', () => { const event = await (global.em).getRepository(SteamworksIntegrationEvent).findOne({ integration }) expect(event).toBeNull() - - axiosMock.reset() }) }) diff --git a/tests/services/_api/player-api/steamworksIdentify.test.ts b/tests/services/_api/player-api/steamworksIdentify.test.ts new file mode 100644 index 00000000..78973ed3 --- /dev/null +++ b/tests/services/_api/player-api/steamworksIdentify.test.ts @@ -0,0 +1,258 @@ +import { Collection, EntityManager } from '@mikro-orm/mysql' +import request from 'supertest' +import { APIKeyScope } from '../../../../src/entities/api-key' +import PlayerFactory from '../../../fixtures/PlayerFactory' +import createAPIKeyAndToken from '../../../utils/createAPIKeyAndToken' +import AxiosMockAdapter from 'axios-mock-adapter' +import axios from 'axios' +import { IntegrationType } from '../../../../src/entities/integration' +import IntegrationFactory from '../../../fixtures/IntegrationFactory' +import IntegrationConfigFactory from '../../../fixtures/IntegrationConfigFactory' +import { PlayerAliasService } from '../../../../src/entities/player-alias' +import casual from 'casual' +import PlayerProp from '../../../../src/entities/player-prop' + +describe('Player API service - identify - steamworks auth', () => { + const axiosMock = new AxiosMockAdapter(axios) + + afterEach(async () => { + axiosMock.reset() + }) + + it('should identify a steamworks player', async () => { + const appId = casual.integer(1000, 1000000) + const steamId = casual.integer(100000, 1000000).toString() + const ticket = '000validticket' + const identity = 'talo' + + const authenticateTicketMock = vi.fn(() => [200, { + response: { + params: { + steamid: steamId, + ownersteamid: steamId, + vacbanned: false, + publisherbanned: false + } + } + }]) + axiosMock.onGet(`https://partner.steam-api.com/ISteamUserAuth/AuthenticateUserTicket/v1?appid=${appId}&ticket=${ticket}&identity=${identity}`).reply(authenticateTicketMock) + + const verifyOwnershipMock = vi.fn(() => [200, { + appownership: { + appid: appId, + ownsapp: true, + permanent: true, + timestamp: '2021-08-01T00:00:00Z', + ownersteamid: steamId, + usercanceled: false + } + }]) + axiosMock.onGet(`https://partner.steam-api.com/ISteamUser/CheckAppOwnership/v3?appid=${appId}&steamid=${steamId}`).reply(verifyOwnershipMock) + + const [apiKey, token] = await createAPIKeyAndToken([APIKeyScope.READ_PLAYERS]) + const player = await new PlayerFactory([apiKey.game]).withSteamAlias(steamId).state((player) => ({ + props: new Collection(player, [ + new PlayerProp(player, 'META_STEAMWORKS_OWNS_APP_PERMANENTLY', 'false') + ]) + })).one() + + const config = await new IntegrationConfigFactory().state(() => ({ appId })).one() + const integration = await new IntegrationFactory().construct(IntegrationType.STEAMWORKS, apiKey.game, config).one() + await (global.em).persistAndFlush([integration, player]) + + const res = await request(global.app) + .get('/v1/players/identify') + .query({ service: PlayerAliasService.STEAM, identifier: `${identity}:${ticket}` }) + .auth(token, { type: 'bearer' }) + .expect(200) + + expect(authenticateTicketMock).toHaveBeenCalledTimes(1) + expect(verifyOwnershipMock).toHaveBeenCalledTimes(1) + + expect(res.body.alias.identifier).toBe(steamId) + expect(res.body.alias.player.id).toBe(player.id) + expect(res.body.alias.player.props).toStrictEqual([ + { + key: 'META_STEAMWORKS_OWNS_APP_PERMANENTLY', + value: 'true' + }, + { + key: 'META_STEAMWORKS_VAC_BANNED', + value: 'false' + }, + { + key: 'META_STEAMWORKS_PUBLISHER_BANNED', + value: 'false' + }, + { + key: 'META_STEAMWORKS_OWNS_APP', + value: 'true' + }, + { + key: 'META_STEAMWORKS_OWNS_APP_FROM_DATE', + value: '2021-08-01T00:00:00Z' + } + ]) + }) + + it('should identify a non-existent steamworks player by creating a new player with the write scope', async () => { + const appId = casual.integer(1000, 1000000) + const steamId = casual.integer(100000, 1000000).toString() + const ticket = '000validticket' + const identity = 'talo' + + const authenticateTicketMock = vi.fn(() => [200, { + response: { + params: { + steamid: steamId, + ownersteamid: steamId, + vacbanned: false, + publisherbanned: false + } + } + }]) + axiosMock.onGet(`https://partner.steam-api.com/ISteamUserAuth/AuthenticateUserTicket/v1?appid=${appId}&ticket=${ticket}&identity=${identity}`).reply(authenticateTicketMock) + + const verifyOwnershipMock = vi.fn(() => [200, { + appownership: { + appid: appId, + ownsapp: true, + permanent: true, + timestamp: '2021-08-01T00:00:00Z', + ownersteamid: steamId, + usercanceled: false + } + }]) + axiosMock.onGet(`https://partner.steam-api.com/ISteamUser/CheckAppOwnership/v3?appid=${appId}&steamid=${steamId}`).reply(verifyOwnershipMock) + + const [apiKey, token] = await createAPIKeyAndToken([APIKeyScope.READ_PLAYERS, APIKeyScope.WRITE_PLAYERS]) + + const config = await new IntegrationConfigFactory().state(() => ({ appId })).one() + const integration = await new IntegrationFactory().construct(IntegrationType.STEAMWORKS, apiKey.game, config).one() + await (global.em).persistAndFlush(integration) + + const res = await request(global.app) + .get('/v1/players/identify') + .query({ service: PlayerAliasService.STEAM, identifier: `${identity}:${ticket}` }) + .auth(token, { type: 'bearer' }) + .expect(200) + + expect(authenticateTicketMock).toHaveBeenCalledTimes(2) // check + create + expect(verifyOwnershipMock).toHaveBeenCalledTimes(2) + + expect(res.body.alias.identifier).toBe(steamId) + expect(res.body.alias.player.props).toStrictEqual([ + { + key: 'META_STEAMWORKS_VAC_BANNED', + value: 'false' + }, + { + key: 'META_STEAMWORKS_PUBLISHER_BANNED', + value: 'false' + }, + { + key: 'META_STEAMWORKS_OWNS_APP', + value: 'true' + }, + { + key: 'META_STEAMWORKS_OWNS_APP_PERMANENTLY', + value: 'true' + }, + { + key: 'META_STEAMWORKS_OWNS_APP_FROM_DATE', + value: '2021-08-01T00:00:00Z' + } + ]) + }) + + it('should identify without a ticket identity', async () => { + const appId = casual.integer(1000, 1000000) + const steamId = casual.integer(100000, 1000000).toString() + const ticket = '000validticket' + + const authenticateTicketMock = vi.fn(() => [200, { + response: { + params: { + steamid: steamId, + ownersteamid: steamId, + vacbanned: false, + publisherbanned: false + } + } + }]) + axiosMock.onGet(`https://partner.steam-api.com/ISteamUserAuth/AuthenticateUserTicket/v1?appid=${appId}&ticket=${ticket}`).reply(authenticateTicketMock) + + const verifyOwnershipMock = vi.fn(() => [200, { + appownership: { + appid: appId, + ownsapp: true, + permanent: true, + timestamp: '2021-08-01T00:00:00Z', + ownersteamid: steamId, + usercanceled: false + } + }]) + axiosMock.onGet(`https://partner.steam-api.com/ISteamUser/CheckAppOwnership/v3?appid=${appId}&steamid=${steamId}`).reply(verifyOwnershipMock) + + const [apiKey, token] = await createAPIKeyAndToken([APIKeyScope.READ_PLAYERS, APIKeyScope.WRITE_PLAYERS]) + + const config = await new IntegrationConfigFactory().state(() => ({ appId })).one() + const integration = await new IntegrationFactory().construct(IntegrationType.STEAMWORKS, apiKey.game, config).one() + await (global.em).persistAndFlush(integration) + + const res = await request(global.app) + .get('/v1/players/identify') + .query({ service: PlayerAliasService.STEAM, identifier: ticket }) + .auth(token, { type: 'bearer' }) + .expect(200) + + expect(res.body.alias.identifier).toBe(steamId) + }) + + it('should catch ticket validation errors', async () => { + const appId = casual.integer(1000, 1000000) + const steamId = casual.integer(100000, 1000000).toString() + const ticket = '000validticket' + + const authenticateTicketMock = vi.fn(() => [200, { + response: { + error: { + errorcode: 101, + errordesc: 'Invalid ticket' + } + } + }]) + axiosMock.onGet(`https://partner.steam-api.com/ISteamUserAuth/AuthenticateUserTicket/v1?appid=${appId}&ticket=${ticket}`).reply(authenticateTicketMock) + + const verifyOwnershipMock = vi.fn(() => [200, { + appownership: { + appid: appId, + ownsapp: true, + permanent: true, + timestamp: '2021-08-01T00:00:00Z', + ownersteamid: steamId, + usercanceled: false + } + }]) + axiosMock.onGet(`https://partner.steam-api.com/ISteamUser/CheckAppOwnership/v3?appid=${appId}&steamid=${steamId}`).reply(verifyOwnershipMock) + + const [apiKey, token] = await createAPIKeyAndToken([APIKeyScope.READ_PLAYERS, APIKeyScope.WRITE_PLAYERS]) + + const config = await new IntegrationConfigFactory().state(() => ({ appId })).one() + const integration = await new IntegrationFactory().construct(IntegrationType.STEAMWORKS, apiKey.game, config).one() + await (global.em).persistAndFlush(integration) + + const res = await request(global.app) + .get('/v1/players/identify') + .query({ service: PlayerAliasService.STEAM, identifier: ticket }) + .auth(token, { type: 'bearer' }) + .expect(400) + + expect(authenticateTicketMock).toHaveBeenCalledTimes(1) + expect(verifyOwnershipMock).toHaveBeenCalledTimes(0) + + expect(res.body).toStrictEqual({ + message: 'Failed to authenticate Steamworks ticket: Invalid ticket (101)' + }) + }) +}) From 691f0008dedf1d28fff4539036209e4ba9021698 Mon Sep 17 00:00:00 2001 From: tudor <7089284+tudddorrr@users.noreply.github.com> Date: Sun, 22 Sep 2024 19:14:38 +0100 Subject: [PATCH 2/5] steam auth examples --- src/docs/player-api.docs.ts | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/src/docs/player-api.docs.ts b/src/docs/player-api.docs.ts index 9e7fcf8b..b6bbf308 100644 --- a/src/docs/player-api.docs.ts +++ b/src/docs/player-api.docs.ts @@ -16,8 +16,8 @@ const PlayerAPIDocs: APIDocs = { sample: { alias: { id: 1, - service: 'steam', - identifier: '11133645', + service: 'username', + identifier: 'jimbo', player: { id: '7a4e70ec-6ee6-418e-923d-b3a45051b7f9', props: [ @@ -38,6 +38,20 @@ const PlayerAPIDocs: APIDocs = { } } } + }, + { + title: 'Steam authentication with identity (identifier format is :)', + sample: { + service: 'steam', + identifier: 'talo:14000000bc9f006804c54b4032b27d0502002002cbfdcf771800000002000000060000004f0957cde6f88aecb090245624000000d8000000480000000500000033b19c0602002002fab015006438f58d8001b9d0000000008c57ef77fce61b780200551002000200f1cf060000000000d4dff043aed3c37739e65db7bc83d0196ecabeed867436df9cafa957ba08e29fe20739e47a3142ef1181e1fae857105545049f2bb6a6e86594fbf675246b5618b297d6535b605160f51650e61f516f05ed62163f5a0616c56c4fcbed3c049d7eedd65e69f23b843d8f92939b6987f9fc6980107079710' + } + }, + { + title: 'Steam authentication without identity', + sample: { + service: 'steam', + identifier: '14000000bc9f006804c54b4032b27d0502002002cbfdcf771800000002000000060000004f0957cde6f88aecb090245624000000d8000000480000000500000033b19c0602002002fab015006438f58d8001b9d0000000008c57ef77fce61b780200551002000200f1cf060000000000d4dff043aed3c37739e65db7bc83d0196ecabeed867436df9cafa957ba08e29fe20739e47a3142ef1181e1fae857105545049f2bb6a6e86594fbf675246b5618b297d6535b605160f51650e61f516f05ed62163f5a0616c56c4fcbed3c049d7eedd65e69f23b843d8f92939b6987f9fc6980107079710' + } } ] }, From 3d64a69160eb8634917575c80ee54b9df07f1845 Mon Sep 17 00:00:00 2001 From: tudor <7089284+tudddorrr@users.noreply.github.com> Date: Mon, 23 Sep 2024 17:52:38 +0100 Subject: [PATCH 3/5] add support for props in leaderboard entries --- src/entities/leaderboard-entry.ts | 7 +- src/migrations/.snapshot-gs_dev.json | 10 +++ ...922222426AddLeaderboardEntryPropsColumn.ts | 13 ++++ src/migrations/index.ts | 5 ++ src/services/api/leaderboard-api.service.ts | 25 ++++++-- .../_api/leaderboard-api/post.test.ts | 64 +++++++++++++++++++ 6 files changed, 119 insertions(+), 5 deletions(-) create mode 100644 src/migrations/20240922222426AddLeaderboardEntryPropsColumn.ts diff --git a/src/entities/leaderboard-entry.ts b/src/entities/leaderboard-entry.ts index add8988a..999a1168 100644 --- a/src/entities/leaderboard-entry.ts +++ b/src/entities/leaderboard-entry.ts @@ -1,6 +1,7 @@ -import { Cascade, Entity, ManyToOne, PrimaryKey, Property } from '@mikro-orm/mysql' +import { Cascade, Embedded, Entity, ManyToOne, PrimaryKey, Property } from '@mikro-orm/mysql' import Leaderboard from './leaderboard' import PlayerAlias from './player-alias' +import Prop from './prop' @Entity() export default class LeaderboardEntry { @@ -16,6 +17,9 @@ export default class LeaderboardEntry { @ManyToOne(() => PlayerAlias, { cascade: [Cascade.REMOVE], eager: true }) playerAlias: PlayerAlias + @Embedded(() => Prop, { array: true }) + props: Prop[] = [] + @Property({ default: false }) hidden: boolean @@ -37,6 +41,7 @@ export default class LeaderboardEntry { leaderboardInternalName: this.leaderboard.internalName, playerAlias: this.playerAlias, hidden: this.hidden, + 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 6a5010d6..b21c58c6 100644 --- a/src/migrations/.snapshot-gs_dev.json +++ b/src/migrations/.snapshot-gs_dev.json @@ -1662,6 +1662,16 @@ "length": null, "mappedType": "integer" }, + "props": { + "name": "props", + "type": "json", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "length": null, + "mappedType": "json" + }, "hidden": { "name": "hidden", "type": "tinyint(1)", diff --git a/src/migrations/20240922222426AddLeaderboardEntryPropsColumn.ts b/src/migrations/20240922222426AddLeaderboardEntryPropsColumn.ts new file mode 100644 index 00000000..a46c91a2 --- /dev/null +++ b/src/migrations/20240922222426AddLeaderboardEntryPropsColumn.ts @@ -0,0 +1,13 @@ +import { Migration } from '@mikro-orm/migrations' + +export class AddLeaderboardEntryPropsColumn extends Migration { + + override async up(): Promise { + this.addSql('alter table `leaderboard_entry` add `props` json not null;') + } + + override async down(): Promise { + this.addSql('alter table `leaderboard_entry` drop column `props`;') + } + +} diff --git a/src/migrations/index.ts b/src/migrations/index.ts index cd22a0a2..9edf9d9f 100644 --- a/src/migrations/index.ts +++ b/src/migrations/index.ts @@ -28,6 +28,7 @@ import { CreatePlayerAuthTable } from './20240628155142CreatePlayerAuthTable' import { CreatePlayerAuthActivityTable } from './20240725183402CreatePlayerAuthActivityTable' import { UpdatePlayerAliasServiceColumn } from './20240916213402UpdatePlayerAliasServiceColumn' import { AddPlayerAliasAnonymisedColumn } from './20240920121232AddPlayerAliasAnonymisedColumn' +import { AddLeaderboardEntryPropsColumn } from './20240922222426AddLeaderboardEntryPropsColumn' export default [ { @@ -149,5 +150,9 @@ export default [ { name: 'AddPlayerAliasAnonymisedColumn', class: AddPlayerAliasAnonymisedColumn + }, + { + name: 'AddLeaderboardEntryPropsColumn', + class: AddLeaderboardEntryPropsColumn } ] diff --git a/src/services/api/leaderboard-api.service.ts b/src/services/api/leaderboard-api.service.ts index 423b90e1..bfab5256 100644 --- a/src/services/api/leaderboard-api.service.ts +++ b/src/services/api/leaderboard-api.service.ts @@ -7,6 +7,8 @@ import Leaderboard, { LeaderboardSortMode } from '../../entities/leaderboard' import LeaderboardAPIDocs from '../../docs/leaderboard-api.docs' import triggerIntegrations from '../../lib/integrations/triggerIntegrations' import { devDataPlayerFilter } from '../../middlewares/dev-data-middleware' +import sanitiseProps from '../../lib/props/sanitiseProps' +import { uniqWith } from 'lodash' @Routes([ { @@ -31,7 +33,7 @@ export default class LeaderboardAPIService extends APIService { }) } - async createEntry(req: Request): Promise { + async createEntry(req: Request, props?: { key: string, value: string }[]): Promise { const em: EntityManager = req.ctx.em const entry = new LeaderboardEntry(req.ctx.state.leaderboard) @@ -40,6 +42,9 @@ export default class LeaderboardAPIService extends APIService { if (req.ctx.state.continuityDate) { entry.createdAt = req.ctx.state.continuityDate } + if (props) { + entry.props = sanitiseProps(props) + } await em.persistAndFlush(entry) @@ -53,9 +58,13 @@ export default class LeaderboardAPIService extends APIService { @HasPermission(LeaderboardAPIPolicy, 'post') @Docs(LeaderboardAPIDocs.post) async post(req: Request): Promise { - const { score } = req.body + const { score, props } = req.body const em: EntityManager = req.ctx.em + if (props && !Array.isArray(props)) { + req.ctx.throw(400, 'Props must be an array') + } + const leaderboard: Leaderboard = req.ctx.state.leaderboard let entry: LeaderboardEntry = null @@ -71,15 +80,23 @@ export default class LeaderboardAPIService extends APIService { if ((leaderboard.sortMode === LeaderboardSortMode.ASC && score < entry.score) || (leaderboard.sortMode === LeaderboardSortMode.DESC && score > entry.score)) { entry.score = score entry.createdAt = req.ctx.state.continuityDate ?? new Date() + if (props) { + const mergedProps = uniqWith([ + ...sanitiseProps(props), + ...entry.props + ], (a, b) => a.key === b.key) + + entry.props = sanitiseProps(mergedProps, true) + } await em.flush() updated = true } } else { - entry = await this.createEntry(req) + entry = await this.createEntry(req, props) } } catch (err) { - entry = await this.createEntry(req) + entry = await this.createEntry(req, props) } await triggerIntegrations(em, leaderboard.game, (integration) => { diff --git a/tests/services/_api/leaderboard-api/post.test.ts b/tests/services/_api/leaderboard-api/post.test.ts index 83cb8daa..c2ba5184 100644 --- a/tests/services/_api/leaderboard-api/post.test.ts +++ b/tests/services/_api/leaderboard-api/post.test.ts @@ -266,4 +266,68 @@ describe('Leaderboard API service - post', () => { expect(new Date(res.body.entry.createdAt).getHours()).toBe(continuityDate.getHours()) }) + + it('should create entries with props', async () => { + const [apiKey, token] = await createAPIKeyAndToken([APIKeyScope.WRITE_LEADERBOARDS]) + const player = await new PlayerFactory([apiKey.game]).one() + const leaderboard = await new LeaderboardFactory([apiKey.game]).state(() => ({ unique: false })).one() + await (global.em).persistAndFlush([player, leaderboard]) + + const res = await request(global.app) + .post(`/v1/leaderboards/${leaderboard.internalName}/entries`) + .send({ + score: 300, + props: [ + { key: 'key1', value: 'value1' }, + { key: 'key2', value: 'value2' } + ] + }) + .auth(token, { type: 'bearer' }) + .set('x-talo-alias', String(player.aliases[0].id)) + .expect(200) + + expect(res.body.entry.score).toBe(300) + expect(res.body.entry.props).toStrictEqual([ + { key: 'key1', value: 'value1' }, + { key: 'key2', value: 'value2' } + ]) + }) + + it('should update an existing entry\'s props', async () => { + const [apiKey, token] = await createAPIKeyAndToken([APIKeyScope.WRITE_LEADERBOARDS]) + const player = await new PlayerFactory([apiKey.game]).one() + const leaderboard = await new LeaderboardFactory([apiKey.game]).state(() => ({ unique: true, sortMode: LeaderboardSortMode.DESC })).one() + + const entry = await new LeaderboardEntryFactory(leaderboard, [player]).state(() => ({ + score: 100, + playerAlias: player.aliases[0], + props: [ + { key: 'key1', value: 'value1' }, + { key: 'delete-me', value: 'delete-me' } + ] + })).one() + + await (global.em).persistAndFlush([player, leaderboard, entry]) + + const res = await request(global.app) + .post(`/v1/leaderboards/${leaderboard.internalName}/entries`) + .send({ + score: 300, + props: [ + { key: 'key2', value: 'value2' }, + { key: 'delete-me', value: null } + ] + }) + .auth(token, { type: 'bearer' }) + .set('x-talo-alias', String(player.aliases[0].id)) + .expect(200) + + expect(res.body.entry.score).toBe(300) + expect(res.body.updated).toBe(true) + + expect(res.body.entry.props).toStrictEqual([ + { key: 'key2', value: 'value2' }, + { key: 'key1', value: 'value1' } + ]) + }) }) From 310e2b54578585862a8b1d9b0a570eb3e26cf639 Mon Sep 17 00:00:00 2001 From: tudor <7089284+tudddorrr@users.noreply.github.com> Date: Mon, 23 Sep 2024 18:12:34 +0100 Subject: [PATCH 4/5] better props validation --- src/services/api/leaderboard-api.service.ts | 20 +++++++++----- .../_api/leaderboard-api/post.test.ts | 26 +++++++++++++++++++ 2 files changed, 40 insertions(+), 6 deletions(-) diff --git a/src/services/api/leaderboard-api.service.ts b/src/services/api/leaderboard-api.service.ts index bfab5256..191946b8 100644 --- a/src/services/api/leaderboard-api.service.ts +++ b/src/services/api/leaderboard-api.service.ts @@ -1,4 +1,4 @@ -import { HasPermission, Routes, Request, Response, Validate, ForwardTo, forwardRequest, Docs } from 'koa-clay' +import { HasPermission, Routes, Request, Response, Validate, ForwardTo, forwardRequest, Docs, ValidationCondition } from 'koa-clay' import LeaderboardAPIPolicy from '../../policies/api/leaderboard-api.policy' import APIService from './api-service' import { EntityManager } from '@mikro-orm/mysql' @@ -53,7 +53,19 @@ export default class LeaderboardAPIService extends APIService { @Validate({ headers: ['x-talo-alias'], - body: ['score'] + body: { + score: { + required: true + }, + props: { + validation: async (val: unknown): Promise => [ + { + check: val ? Array.isArray(val) : true, + error: 'Props must be an array' + } + ] + } + } }) @HasPermission(LeaderboardAPIPolicy, 'post') @Docs(LeaderboardAPIDocs.post) @@ -61,10 +73,6 @@ export default class LeaderboardAPIService extends APIService { const { score, props } = req.body const em: EntityManager = req.ctx.em - if (props && !Array.isArray(props)) { - req.ctx.throw(400, 'Props must be an array') - } - const leaderboard: Leaderboard = req.ctx.state.leaderboard let entry: LeaderboardEntry = null diff --git a/tests/services/_api/leaderboard-api/post.test.ts b/tests/services/_api/leaderboard-api/post.test.ts index c2ba5184..bff28eb9 100644 --- a/tests/services/_api/leaderboard-api/post.test.ts +++ b/tests/services/_api/leaderboard-api/post.test.ts @@ -330,4 +330,30 @@ describe('Leaderboard API service - post', () => { { key: 'key1', value: 'value1' } ]) }) + + it('should return a 400 if props are not an array', async () => { + const [apiKey, token] = await createAPIKeyAndToken([APIKeyScope.WRITE_LEADERBOARDS]) + const player = await new PlayerFactory([apiKey.game]).one() + const leaderboard = await new LeaderboardFactory([apiKey.game]).state(() => ({ unique: false })).one() + await (global.em).persistAndFlush([player, leaderboard]) + + const res = await request(global.app) + .post(`/v1/leaderboards/${leaderboard.internalName}/entries`) + .send({ + score: 300, + props: { + key1: 'value1', + key2: 'value2' + } + }) + .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'] + } + }) + }) }) From 1720a47deb986e65a313c00318a8c51cc96bfbb5 Mon Sep 17 00:00:00 2001 From: tudor <7089284+tudddorrr@users.noreply.github.com> Date: Thu, 26 Sep 2024 11:53:13 +0100 Subject: [PATCH 5/5] 0.44.0 --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 42abb865..99b01ea2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "game-services", - "version": "0.43.0", + "version": "0.44.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "game-services", - "version": "0.43.0", + "version": "0.44.0", "license": "MIT", "dependencies": { "@clickhouse/client": "^1.4.1", diff --git a/package.json b/package.json index 29bafb90..fdd1016d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "game-services", - "version": "0.43.0", + "version": "0.44.0", "description": "", "main": "src/index.ts", "scripts": {