Skip to content
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
1 change: 1 addition & 0 deletions envs/.env.dev
Original file line number Diff line number Diff line change
Expand Up @@ -20,5 +20,6 @@ AUTO_CONFIRM_EMAIL=false
FROM_EMAIL=hello@trytalo.com

RECOVERY_CODES_SECRET=tc0d8e0h0lqv5isajfjw0iivj5pc3d95
STEAM_INTEGRATION_SECRET=PjBw8vy8ZbFqXvZwAABWfbhfXvJ32idf

STRIPE_KEY=
8 changes: 6 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,8 @@
"test": "./tests/setup-tests.sh",
"up": "yarn dc up --build -d",
"down": "yarn dc down",
"restart": "yarn dc restart backend && yarn logs",
"logs": "yarn dc logs --follow backend",
"restart": "yarn down && yarn up",
"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",
Expand All @@ -34,6 +34,7 @@
"@types/supertest": "^2.0.12",
"@typescript-eslint/eslint-plugin": "^5.20.0",
"@typescript-eslint/parser": "^5.20.0",
"axios-mock-adapter": "^1.21.2",
"casual": "^1.6.2",
"eslint": "^8.14.0",
"hefty": "^1.1.0",
Expand All @@ -58,6 +59,7 @@
"@sentry/node": "^6.19.6",
"@sentry/tracing": "^6.19.6",
"adm-zip": "^0.5.6",
"axios": "^0.27.2",
"bcrypt": "^5.0.1",
"bee-queue": "^1.4.0",
"date-fns": "^2.28.0",
Expand All @@ -68,15 +70,17 @@
"jsonwebtoken": "^8.5.1",
"koa": "^2.13.4",
"koa-bodyparser": "^4.3.0",
"koa-clay": "^6.4.0",
"koa-clay": "^6.5.0",
"koa-helmet": "^6.1.0",
"koa-jwt": "^4.0.3",
"koa-logger": "^3.2.1",
"lodash.get": "^4.4.2",
"lodash.groupby": "^4.6.0",
"lodash.pick": "^4.4.0",
"lodash.uniqwith": "^4.5.0",
"otplib": "^12.0.1",
"qrcode": "^1.5.0",
"qs": "^6.11.0",
"stripe": "^9.2.0",
"uuid": "^8.3.2"
},
Expand Down
2 changes: 2 additions & 0 deletions src/config/protected-routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import HeadlineService from '../services/headline.service'
import PlayerService from '../services/player.service'
import UserService from '../services/user.service'
import BillingService from '../services/billing.service'
import IntegrationService from '../services/integration.service'

export default (app: Koa) => {
app.use(async (ctx: Context, next: Next): Promise<void> => {
Expand Down Expand Up @@ -40,6 +41,7 @@ export default (app: Koa) => {
app.use(service('/games/:gameId/events', new EventService(), serviceOpts))
app.use(service('/games/:gameId/players', new PlayerService(), serviceOpts))
app.use(service('/games/:gameId/headlines', new HeadlineService(), serviceOpts))
app.use(service('/games/:gameId/integrations', new IntegrationService(), serviceOpts))
app.use(service('/games', new GameService(), serviceOpts))
app.use(service('/users', new UserService(), serviceOpts))
}
18 changes: 17 additions & 1 deletion src/entities/game-activity.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { Entity, Enum, ManyToOne, PrimaryKey, Property } from '@mikro-orm/core'
import upperFirst from '../lib/lang/upperFirst'
import Game from './game'
import User from './user'

Expand All @@ -16,7 +17,12 @@ export enum GameActivityType {
GAME_STAT_DELETED,
INVITE_CREATED,
INVITE_ACCEPTED,
DATA_EXPORT_REQUESTED
DATA_EXPORT_REQUESTED,
GAME_INTEGRATION_ADDED,
GAME_INTEGRATION_UPDATED,
GAME_INTEGRATION_DELETED,
GAME_INTEGRATION_STEAMWORKS_LEADERBOARDS_SYNCED,
GAME_INTEGRATION_STEAMWORKS_STATS_SYNCED,
}

@Entity()
Expand Down Expand Up @@ -80,6 +86,16 @@ export default class GameActivity {
return `${this.user.username} joined the organisation`
case GameActivityType.DATA_EXPORT_REQUESTED:
return `${this.user.username} requested a data export`
case GameActivityType.GAME_INTEGRATION_ADDED:
return `${this.user.username} enabled the ${upperFirst(this.extra.integrationType as string)}} integration`
case GameActivityType.GAME_INTEGRATION_UPDATED:
return `${this.user.username} updated the ${upperFirst(this.extra.integrationType as string)} integration`
case GameActivityType.GAME_INTEGRATION_DELETED:
return `${this.user.username} disabled the ${upperFirst(this.extra.integrationType as string)} integration`
case GameActivityType.GAME_INTEGRATION_STEAMWORKS_LEADERBOARDS_SYNCED:
return `${this.user.username} initiated a manual sync for Steamworks leaderboards`
case GameActivityType.GAME_INTEGRATION_STEAMWORKS_STATS_SYNCED:
return `${this.user.username} initiated a manual sync for Steamworks stats`
default:
return ''
}
Expand Down
3 changes: 1 addition & 2 deletions src/entities/game-stat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,7 @@ export default class GameStat {
@Required({
validation: async (val: unknown, req: Request): Promise<ValidationCondition[]> => {
const { gameId, id } = req.params
const em: EntityManager = req.ctx.em
const duplicateInternalName = await em.getRepository(GameStat).findOne({
const duplicateInternalName = await (<EntityManager>req.ctx.em).getRepository(GameStat).findOne({
id: { $ne: Number(id ?? null) },
internalName: val,
game: Number(gameId)
Expand Down
6 changes: 6 additions & 0 deletions src/entities/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,14 @@ import PricingPlan from './pricing-plan'
import PricingPlanAction from './pricing-plan-action'
import OrganisationPricingPlan from './organisation-pricing-plan'
import OrganisationPricingPlanAction from './organisation-pricing-plan-action'
import Integration from './integration'
import SteamworksIntegrationEvent from './steamworks-integration-event'
import SteamworksLeaderboardMapping from './steamworks-leaderboard-mapping'

export default [
SteamworksLeaderboardMapping,
SteamworksIntegrationEvent,
Integration,
OrganisationPricingPlanAction,
OrganisationPricingPlan,
PricingPlanAction,
Expand Down
188 changes: 188 additions & 0 deletions src/entities/integration.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,188 @@
import { Entity, EntityManager, Enum, Filter, ManyToOne, PrimaryKey, Property } from '@mikro-orm/core'
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 Leaderboard from './leaderboard'
import pick from 'lodash.pick'
import LeaderboardEntry from './leaderboard-entry'
import { PlayerAliasService } from './player-alias'
import PlayerGameStat from './player-game-stat'

export enum IntegrationType {
STEAMWORKS = 'steamworks'
}

export type SteamIntegrationConfig = {
apiKey: string
appId: number
syncLeaderboards: boolean
syncStats: boolean
}

export type IntegrationConfig = SteamIntegrationConfig

@Entity()
@Filter({ name: 'active', cond: { deletedAt: null }, default: true })
export default class Integration {
@PrimaryKey()
id: number

@Required({
methods: ['POST'],
validation: async (val: unknown, req: Request): Promise<ValidationCondition[]> => {
const keys = Object.keys(IntegrationType).map((key) => IntegrationType[key])

const { gameId, id } = req.params
const duplicateIntegrationType = await (<EntityManager>req.ctx.em).getRepository(Integration).findOne({
id: { $ne: Number(id ?? null) },
type: val,
game: Number(gameId)
})

return [
{
check: keys.includes(val),
error: `Integration type must be one of ${keys.join(', ')}`
},
{
check: !duplicateIntegrationType,
error: `This game already has an integration for ${val}`
}
]
}
})
@Enum(() => IntegrationType)
type: IntegrationType

@ManyToOne(() => Game)
game: Game

@Required({ methods: ['POST', 'PATCH'] })
@Property({ type: 'json' })
private config: IntegrationConfig

@Property({ nullable: true })
deletedAt: Date

@Property()
createdAt: Date = new Date()

@Property({ onUpdate: () => new Date() })
updatedAt: Date = new Date()

constructor(type: IntegrationType, game: Game, config: IntegrationConfig) {
this.type = type
this.game = game

this.config = {
...config,
apiKey: encrypt(config.apiKey, process.env.STEAM_INTEGRATION_SECRET)
}
}

updateConfig(config: Partial<IntegrationConfig>) {
if (config.apiKey) config.apiKey = encrypt(config.apiKey, process.env.STEAM_INTEGRATION_SECRET)

this.config = {
...this.config,
...config
}
}

getConfig(): IntegrationConfig {
switch (this.type) {
case IntegrationType.STEAMWORKS:
return pick(this.config, ['appId', 'syncLeaderboards', 'syncStats'])
}
}

getSteamAPIKey(): string {
return decrypt(this.config.apiKey, process.env.STEAM_INTEGRATION_SECRET)
}

async handleLeaderboardCreated(em: EntityManager, leaderboard: Leaderboard) {
switch (this.type) {
case IntegrationType.STEAMWORKS:
if (this.config.syncLeaderboards) {
await createSteamworksLeaderboard(em, this, leaderboard)
}
}
}

async handleLeaderboardUpdated(em: EntityManager, leaderboard: Leaderboard) {
switch (this.type) {
case IntegrationType.STEAMWORKS:
if (this.config.syncLeaderboards) {
await createSteamworksLeaderboard(em, this, leaderboard) // create if doesn't exist
}
}
}

async handleLeaderboardDeleted(em: EntityManager, leaderboardInternalName: string) {
switch (this.type) {
case IntegrationType.STEAMWORKS:
if (this.config.syncLeaderboards) {
await deleteSteamworksLeaderboard(em, this, leaderboardInternalName)
}
}
}

async handleLeaderboardEntryCreated(em: EntityManager, entry: LeaderboardEntry) {
switch (this.type) {
case IntegrationType.STEAMWORKS:
if (entry.playerAlias.service === PlayerAliasService.STEAM && this.config.syncLeaderboards) {
await createSteamworksLeaderboardEntry(em, this, entry)
}
}
}

async handleLeaderboardEntryVisibilityToggled(em: EntityManager, entry: LeaderboardEntry) {
switch (this.type) {
case IntegrationType.STEAMWORKS:
if (entry.playerAlias.service === PlayerAliasService.STEAM && this.config.syncLeaderboards) {
if (entry.hidden) {
await deleteSteamworksLeaderboardEntry(em, this, entry)
} else {
await createSteamworksLeaderboardEntry(em, this, entry)
}
}
}
}

async handleSyncLeaderboards(em: EntityManager) {
switch (this.type) {
case IntegrationType.STEAMWORKS:
await syncSteamworksLeaderboards(em, this)
}
}

async handleStatUpdated(em: EntityManager, playerStat: PlayerGameStat) {
await playerStat.player.aliases.loadItems()
const steamAlias = playerStat.player.aliases.getItems().find((alias) => alias.service === PlayerAliasService.STEAM)

switch (this.type) {
case IntegrationType.STEAMWORKS:
if (steamAlias && this.config.syncStats) {
await setSteamworksStat(em, this, playerStat, steamAlias)
}
}
}

async handleSyncStats(em: EntityManager) {
switch (this.type) {
case IntegrationType.STEAMWORKS:
await syncSteamworksStats(em, this)
}
}

toJSON() {
return {
id: this.id,
type: this.type,
config: this.getConfig(),
createdAt: this.createdAt,
updatedAt: this.updatedAt
}
}
}
3 changes: 1 addition & 2 deletions src/entities/leaderboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,7 @@ export default class Leaderboard {
@Required({
validation: async (val: unknown, req: Request): Promise<ValidationCondition[]> => {
const { gameId, id } = req.params
const em: EntityManager = req.ctx.em
const duplicateInternalName = await em.getRepository(Leaderboard).findOne({
const duplicateInternalName = await (<EntityManager>req.ctx.em).getRepository(Leaderboard).findOne({
id: { $ne: Number(id ?? null) },
internalName: val,
game: Number(gameId)
Expand Down
14 changes: 11 additions & 3 deletions src/entities/player-alias.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,21 @@
import { Cascade, Entity, ManyToOne, PrimaryKey, Property } from '@mikro-orm/core'
import { Cascade, Entity, Enum, ManyToOne, PrimaryKey, Property } from '@mikro-orm/core'
import Player from './player'

export enum PlayerAliasService {
STEAM = 'steam',
EPIC = 'epic',
USERNAME = 'username',
EMAIL = 'email',
CUSTOM = 'custom'
}

@Entity()
export default class PlayerAlias {
@PrimaryKey()
id: number

@Property()
service: string
@Enum(() => PlayerAliasService)
service: PlayerAliasService

@Property()
identifier: string
Expand Down
41 changes: 41 additions & 0 deletions src/entities/steamworks-integration-event.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { Entity, ManyToOne, PrimaryKey, Property } from '@mikro-orm/core'
import Integration from './integration'

export type SteamworksRequestMethod = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'
export type SteamworksResponseStatusCode = 200 | 400 | 401 | 403 | 404 | 405 | 429 | 500 | 503

export type SteamworksRequest = {
url: string
method: SteamworksRequestMethod
body: string
}

export type SteamworksResponse = {
status: SteamworksResponseStatusCode
body: {
[key: string]: unknown
}
timeTaken: number
}

@Entity()
export default class SteamworksIntegrationEvent {
@PrimaryKey()
id: number

@ManyToOne(() => Integration)
integration: Integration

@Property({ type: 'json' })
request: SteamworksRequest

@Property({ type: 'json' })
response: SteamworksResponse

@Property()
createdAt: Date = new Date()

constructor(integration: Integration) {
this.integration = integration
}
}
Loading