Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Release 0.44.0 #340

Merged
merged 7 commits into from
Sep 26, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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