Skip to content

Commit

Permalink
Merge pull request #340 from TaloDev/develop
Browse files Browse the repository at this point in the history
Release 0.44.0
  • Loading branch information
tudddorrr authored Sep 26, 2024
2 parents e1697dc + 1720a47 commit f430ca2
Show file tree
Hide file tree
Showing 17 changed files with 593 additions and 30 deletions.
4 changes: 2 additions & 2 deletions package-lock.json

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

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "game-services",
"version": "0.43.0",
"version": "0.44.0",
"description": "",
"main": "src/index.ts",
"scripts": {
Expand Down
18 changes: 16 additions & 2 deletions src/docs/player-api.docs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,8 @@ const PlayerAPIDocs: APIDocs<PlayerAPIService> = {
sample: {
alias: {
id: 1,
service: 'steam',
identifier: '11133645',
service: 'username',
identifier: 'jimbo',
player: {
id: '7a4e70ec-6ee6-418e-923d-b3a45051b7f9',
props: [
Expand All @@ -38,6 +38,20 @@ const PlayerAPIDocs: APIDocs<PlayerAPIService> = {
}
}
}
},
{
title: 'Steam authentication with identity (identifier format is <identity>:<ticket>)',
sample: {
service: 'steam',
identifier: 'talo:14000000bc9f006804c54b4032b27d0502002002cbfdcf771800000002000000060000004f0957cde6f88aecb090245624000000d8000000480000000500000033b19c0602002002fab015006438f58d8001b9d0000000008c57ef77fce61b780200551002000200f1cf060000000000d4dff043aed3c37739e65db7bc83d0196ecabeed867436df9cafa957ba08e29fe20739e47a3142ef1181e1fae857105545049f2bb6a6e86594fbf675246b5618b297d6535b605160f51650e61f516f05ed62163f5a0616c56c4fcbed3c049d7eedd65e69f23b843d8f92939b6987f9fc6980107079710'
}
},
{
title: 'Steam authentication without identity',
sample: {
service: 'steam',
identifier: '14000000bc9f006804c54b4032b27d0502002002cbfdcf771800000002000000060000004f0957cde6f88aecb090245624000000d8000000480000000500000033b19c0602002002fab015006438f58d8001b9d0000000008c57ef77fce61b780200551002000200f1cf060000000000d4dff043aed3c37739e65db7bc83d0196ecabeed867436df9cafa957ba08e29fe20739e47a3142ef1181e1fae857105545049f2bb6a6e86594fbf675246b5618b297d6535b605160f51650e61f516f05ed62163f5a0616c56c4fcbed3c049d7eedd65e69f23b843d8f92939b6987f9fc6980107079710'
}
}
]
},
Expand Down
9 changes: 8 additions & 1 deletion src/entities/integration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -176,6 +176,13 @@ export default class Integration {
}
}

async getPlayerIdentifier(req: Request, identifier: string): Promise<string> {
switch (this.type) {
case IntegrationType.STEAMWORKS:
return authenticateTicket(req, this, identifier)
}
}

toJSON() {
return {
id: this.id,
Expand Down
7 changes: 6 additions & 1 deletion src/entities/leaderboard-entry.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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

Expand All @@ -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
}
Expand Down
10 changes: 10 additions & 0 deletions src/entities/player.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)))
}
Expand Down
95 changes: 95 additions & 0 deletions src/lib/integrations/steamworks-integration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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<string> {
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<AuthenticateUserTicketResponse>(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<CheckAppOwnershipResponse> {
const config = createSteamworksRequestConfig(integration, 'GET', `/ISteamUser/CheckAppOwnership/v3?appid=${integration.getConfig().appId}&steamid=${steamId}`)
const event = createSteamworksIntegrationEvent(integration, config)
const res = await makeRequest<CheckAppOwnershipResponse>(config, event)
await em.persistAndFlush(event)

return res.data
}
10 changes: 10 additions & 0 deletions src/migrations/.snapshot-gs_dev.json
Original file line number Diff line number Diff line change
Expand Up @@ -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)",
Expand Down
13 changes: 13 additions & 0 deletions src/migrations/20240922222426AddLeaderboardEntryPropsColumn.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { Migration } from '@mikro-orm/migrations'

export class AddLeaderboardEntryPropsColumn extends Migration {

override async up(): Promise<void> {
this.addSql('alter table `leaderboard_entry` add `props` json not null;')
}

override async down(): Promise<void> {
this.addSql('alter table `leaderboard_entry` drop column `props`;')
}

}
5 changes: 5 additions & 0 deletions src/migrations/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 [
{
Expand Down Expand Up @@ -149,5 +150,9 @@ export default [
{
name: 'AddPlayerAliasAnonymisedColumn',
class: AddPlayerAliasAnonymisedColumn
},
{
name: 'AddLeaderboardEntryPropsColumn',
class: AddLeaderboardEntryPropsColumn
}
]
37 changes: 31 additions & 6 deletions src/services/api/leaderboard-api.service.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -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([
{
Expand All @@ -31,7 +33,7 @@ export default class LeaderboardAPIService extends APIService {
})
}

async createEntry(req: Request): Promise<LeaderboardEntry> {
async createEntry(req: Request, props?: { key: string, value: string }[]): Promise<LeaderboardEntry> {
const em: EntityManager = req.ctx.em

const entry = new LeaderboardEntry(req.ctx.state.leaderboard)
Expand All @@ -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)

Expand All @@ -48,12 +53,24 @@ export default class LeaderboardAPIService extends APIService {

@Validate({
headers: ['x-talo-alias'],
body: ['score']
body: {
score: {
required: true
},
props: {
validation: async (val: unknown): Promise<ValidationCondition[]> => [
{
check: val ? Array.isArray(val) : true,
error: 'Props must be an array'
}
]
}
}
})
@HasPermission(LeaderboardAPIPolicy, 'post')
@Docs(LeaderboardAPIDocs.post)
async post(req: Request): Promise<Response> {
const { score } = req.body
const { score, props } = req.body
const em: EntityManager = req.ctx.em

const leaderboard: Leaderboard = req.ctx.state.leaderboard
Expand All @@ -71,15 +88,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) => {
Expand Down
Loading

0 comments on commit f430ca2

Please sign in to comment.