From a297c868302ef3449b4f59ba41b0c7847d8ad8f3 Mon Sep 17 00:00:00 2001 From: legonzaur Date: Sun, 9 Apr 2023 14:47:56 +0200 Subject: [PATCH 1/9] process rework, some tests and instant update --- src/client/process.ts | 227 ------------------ src/client/process/processPlayer.ts | 42 ---- src/client/process/processWeapon.ts | 42 ---- .../migrations/2023-04-09-01 kill watcher.ts | 34 +++ src/process/onKill.ts | 71 ++++++ src/process/process.ts | 184 ++++++++++++++ src/processMain.ts | 4 +- tests/client.test.ts | 36 +++ tests/realtime.test.ts | 110 +++++++++ 9 files changed, 438 insertions(+), 312 deletions(-) delete mode 100644 src/client/process.ts delete mode 100644 src/client/process/processPlayer.ts delete mode 100644 src/client/process/processWeapon.ts create mode 100644 src/db/migrations/2023-04-09-01 kill watcher.ts create mode 100644 src/process/onKill.ts create mode 100644 src/process/process.ts create mode 100644 tests/realtime.test.ts diff --git a/src/client/process.ts b/src/client/process.ts deleted file mode 100644 index 6df4ecc..0000000 --- a/src/client/process.ts +++ /dev/null @@ -1,227 +0,0 @@ -import db from '../db/db' -const { count, max, sum } = db.fn -import client from '../cache/redis' -import { createWeaponJson } from './process/processWeapon' -import { createPlayerJson } from './process/processPlayer' - -/** - * Starts the process of populating the REDIS database globally - * @returns - */ -async function processAll() { - console.log('Starting data calculation...') - const timeStart = new Date() - - await Promise.all([processGlobalStats(), processServerStats()]) - console.log( - 'Data calculation finished. Took + ' + - Math.abs(new Date().getTime() - timeStart.getTime()) / 1000 + - ' seconds' - ) - if (process.env.ENVIRONMENT == 'production') { - return - } - setTimeout(processAll, 3600000) -} - -async function processGlobalStats() { - let promises: Promise[] = [] - //Global kills - promises.push(db.selectFrom('kill') - .select([count('id').as('kills'), max('distance').as('max_distance'), sum('distance').as('total_distance')]) - .execute().then(data => { - let transaction = client.multi() - data.forEach(({ kills, max_distance, total_distance }) => { - transaction = processGlobalData({ kills, max_distance, total_distance }, transaction) - }) - return transaction.exec() - })) - - //Global weapon kill - promises.push(db.selectFrom('kill') - .select([count('id').as('kills'), max('distance').as('max_distance'), sum('distance').as('total_distance'), 'cause_of_death']) - .groupBy('cause_of_death').execute().then(async (data) => { - let transaction = client.multi() - data.forEach(({ kills, max_distance, total_distance, cause_of_death }) => { - transaction = processWeaponData({ kills, max_distance, total_distance, cause_of_death }, transaction) - }) - await transaction.exec() - })) - - //Player weapon kills - promises.push(db.selectFrom('kill') - .select([count('id').as('kills'), max('distance').as('max_distance'), sum('distance').as('total_distance'), 'cause_of_death', 'attacker_id']) - .groupBy(['cause_of_death', 'attacker_id']).execute().then(async (data) => { - let transaction = client.multi() - data.forEach(({ kills, max_distance, total_distance, cause_of_death, attacker_id }) => { - transaction = processWeaponData({ kills, max_distance, total_distance, cause_of_death, attacker_id }, transaction) - }) - await transaction.exec() - })) - //Player weapon deaths - promises.push(db.selectFrom('kill').select([count('id').as('deaths'), 'victim_id', 'cause_of_death']) - .groupBy(['victim_id', 'cause_of_death']).execute().then(data => { - let transaction = client.multi() - data.forEach(({ deaths, victim_id, cause_of_death }) => { - transaction = transaction.HSET(`weapons:${cause_of_death}:players:${victim_id}`, "deaths", deaths.toString()) - }) - return transaction.exec() - })) - - //Global player kills - promises.push(db.selectFrom('kill') - .select([count('id').as('kills'), max('distance').as('max_distance'), sum('distance').as('total_distance'), 'attacker_id']) - .groupBy('attacker_id').execute().then(data => { - let transaction = client.multi() - data.forEach(({ kills, max_distance, total_distance, attacker_id }) => { - processPlayerData({ kills, max_distance, total_distance, attacker_id }, transaction) - }) - return transaction.exec() - }) - ) - //Global player deaths - promises.push(db.selectFrom('kill').select([count('id').as('deaths'), 'victim_id']) - .groupBy('victim_id').execute().then(data => { - let transaction = client.multi() - data.forEach(({ deaths, victim_id }) => { - transaction = transaction.HSET('players:' + victim_id, "deaths", deaths.toString()) - }) - return transaction.exec() - })) - - await Promise.all(promises) - promises = []; - promises.push(createWeaponJson()); - promises.push(createPlayerJson()); - (await client.sMembers('players')).forEach((player: string) => { - promises.push(createWeaponJson(undefined, player)) - }); - (await client.sMembers('weapons')).forEach((weapon: string) => { - promises.push(createPlayerJson(undefined, weapon)) - }); - await Promise.all(promises) -} - -async function processServerStats() { - let promises: Promise[] = [] - //Server global kills - promises.push(db.selectFrom('kill') - .select([count('id').as('kills'), max('distance').as('max_distance'), sum('distance').as('total_distance'), 'server']) - .groupBy(['server']).execute().then(data => { - let transaction = client.multi() - data.forEach(({ kills, max_distance, total_distance, server }) => { - transaction = processGlobalData({ kills, max_distance, total_distance, server }, transaction) - }) - return transaction.exec() - })) - //Server weapon kills - promises.push(db.selectFrom('kill') - .select([count('id').as('kills'), max('distance').as('max_distance'), sum('distance').as('total_distance'), 'cause_of_death', 'server']) - .groupBy(['cause_of_death', 'server']).execute().then(data => { - let transaction = client.multi() - data.forEach(({ kills, max_distance, total_distance, cause_of_death, server }) => { - transaction = processWeaponData({ kills, max_distance, total_distance, cause_of_death, server }, transaction) - }) - return transaction.exec() - })) - - //Server Player weapon kills - promises.push(db.selectFrom('kill') - .select([count('id').as('kills'), max('distance').as('max_distance'), sum('distance').as('total_distance'), 'cause_of_death', 'attacker_id', 'server']) - .groupBy(['cause_of_death', 'attacker_id', 'server']).execute().then(data => { - let transaction = client.multi() - data.forEach(({ kills, max_distance, total_distance, cause_of_death, attacker_id, server }) => { - transaction = processWeaponData({ kills, max_distance, total_distance, cause_of_death, attacker_id, server }, transaction) - }) - return transaction.exec() - })) - //Server Player weapon deaths - promises.push(db.selectFrom('kill').select([count('id').as('deaths'), 'victim_id', 'cause_of_death', 'server']) - .groupBy(['victim_id', 'cause_of_death', 'server']).execute().then(data => { - let transaction = client.multi() - data.forEach(({ deaths, victim_id, cause_of_death, server }) => { - transaction = transaction.HSET(`servers:${server}:weapons:${cause_of_death}:players:${victim_id}`, "deaths", deaths.toString()) - }) - return transaction.exec() - })) - - //server player kills - promises.push(db.selectFrom('kill') - .select([count('id').as('kills'), max('distance').as('max_distance'), sum('distance').as('total_distance'), 'attacker_id', 'server']) - .groupBy(['attacker_id', 'server']).execute().then(data => { - let transaction = client.multi() - data.forEach(({ kills, max_distance, total_distance, attacker_id, server }) => { - transaction = processPlayerData({ kills, max_distance, total_distance, attacker_id, server }, transaction) - }) - return transaction.exec() - }) - ) - - //server player deaths - promises.push(db.selectFrom('kill').select([count('id').as('deaths'), 'victim_id', 'server']) - .groupBy(['victim_id', 'server']).execute().then(data => { - let transaction = client.multi() - data.forEach(({ deaths, victim_id, server }) => { - transaction = transaction.HSET('servers:' + server + ':players:' + victim_id, "deaths", deaths.toString()) - }) - return transaction.exec() - })) - - await Promise.all(promises) - promises = []; - ((await client.sMembers('servers')).forEach((server: string) => { - if (isNaN(Number(server))) return - promises.push(createWeaponJson(Number(server))); - promises.push(createPlayerJson(Number(server))); - promises.push(client.sMembers('servers:' + server + ':players').then(data => { - data.forEach((player: string) => { - promises.push(createWeaponJson(Number(server), player)) - }) - })) - promises.push(client.sMembers('servers:' + server + 'weapons').then(data => { - data.forEach((weapon: string) => { - promises.push(createPlayerJson(Number(server), weapon)) - }) - })) - })); - - await Promise.all(promises) -} - -function processGlobalData({ kills, max_distance, total_distance, server }: { kills: string | number | bigint, max_distance: number, total_distance: string | number | bigint, server?: number }, transaction: any) { - const serverPrefix = server ? `servers:${server}:` : '' - const cacheLocation = serverPrefix + 'global' - transaction = transaction.HSET(cacheLocation, "total_distance", total_distance.toString()) - transaction = transaction.HSET(cacheLocation, "max_distance", max_distance.toString()) - transaction = transaction.HSET(cacheLocation, "kills", kills.toString()) - if (server) { - transaction = transaction.SADD('servers', server.toString()) - } - - return transaction -} - -function processWeaponData({ kills, max_distance, total_distance, cause_of_death, attacker_id, server }: { kills: string | number | bigint, max_distance: number, total_distance: string | number | bigint, cause_of_death: string, attacker_id?: string, server?: number }, transaction: any) { - const serverPrefix = server ? `servers:${server}:` : '' - const playerPrefix = attacker_id ? `players:${attacker_id}:` : '' - const cacheLocation = serverPrefix + playerPrefix + `weapons:` + cause_of_death - transaction = transaction.HSET(cacheLocation, "total_distance", total_distance.toString()) - transaction = transaction.HSET(cacheLocation, "max_distance", max_distance.toString()) - transaction = transaction.HSET(cacheLocation, "kills", kills.toString()) - transaction = transaction.SADD(serverPrefix + playerPrefix + `weapons`, cause_of_death.toString()) - return transaction -} - -function processPlayerData({ kills, max_distance, total_distance, attacker_id, cause_of_death, server }: { kills: string | number | bigint, max_distance: number, total_distance: string | number | bigint, attacker_id: string, server?: number, cause_of_death?: string }, transaction: any) { - const serverPrefix = server ? `servers:${server}:` : '' - const weaponPrefix = cause_of_death ? `weapons:${cause_of_death}:` : '' - const cacheLocation = serverPrefix + weaponPrefix + `players:` + attacker_id - - transaction = transaction.HSET(cacheLocation, "total_distance", total_distance.toString()) - transaction = transaction.HSET(cacheLocation, "max_distance", max_distance.toString()) - transaction = transaction.HSET(cacheLocation, "kills", kills.toString()) - transaction = transaction.SADD(serverPrefix + weaponPrefix + `players`, attacker_id.toString()) - return transaction -} - -export default processAll diff --git a/src/client/process/processPlayer.ts b/src/client/process/processPlayer.ts deleted file mode 100644 index 4e8c92c..0000000 --- a/src/client/process/processPlayer.ts +++ /dev/null @@ -1,42 +0,0 @@ -/** - * This file contains all functions related to population of the REDIS database for players. - */ -import cache from '../../cache/redis' -import { getPlayerReport, getPlayerSet } from '../../cache/cacheUtils' - -/** - * Combines player reports in a readable json string. - * - * ProcessPlayerReport must be called manually before this function ! - * - * Inputs `SET` `[servers:{serverId}:][weapons:{weaponId}:]players` - * - * Outputs `STRING` `[servers:{serverId}:][weapons:{weaponId}:]players:processedList` - * @param server optional server filter - * @param weapon optional player filter - * @returns - */ -export async function createPlayerJson(server?: number, weapon?: string) { - const serverPrefix = server ? `servers:${server}:` : '' - const weaponPrefix = weapon ? `weapons:${weapon}:` : '' - const cacheLocation = serverPrefix + weaponPrefix + `players` - const players = await getPlayerSet(server, weapon) - const promises: Promise[] = [] - const data: { - [playerId: string]: { - [playerData: string]: string - } - } = {} - players.forEach((plr) => { - promises.push( - (async () => { - const playerData = await getPlayerReport(plr, server, weapon) - if (Object.keys(playerData).length > 0) { - data[plr] = playerData - } - })() - ) - }) - await Promise.all(promises) - return await cache.SET(cacheLocation + `:processedList`, JSON.stringify(data)) -} diff --git a/src/client/process/processWeapon.ts b/src/client/process/processWeapon.ts deleted file mode 100644 index 2cf87d8..0000000 --- a/src/client/process/processWeapon.ts +++ /dev/null @@ -1,42 +0,0 @@ -/** - * This file contains all functions related to population of the REDIS database for weapons. - */ -import cache from '../../cache/redis' -import { getWeaponReport } from '../../cache/cacheUtils' - -/** - * Combines weapons reports in a readable json string. - * - * ProcessWeaponReports must be called manually before this function ! - * - * Inputs `SET` `[servers:{serverId}:][players:{playerId}:]weapons` - * - * Outputs `STRING` `[servers:{serverId}:][players:{playerId}:]weapons:processedList` - * @param server optional server filter - * @param player optional player filter - * @returns - */ -export async function createWeaponJson(server?: number, player?: string) { - const serverPrefix = server ? `servers:${server}:` : '' - const playerPrefix = player ? `players:${player}:` : '' - const cacheLocation = serverPrefix + playerPrefix + `weapons` - const weapons = await cache.SMEMBERS(cacheLocation) - const promises: Promise[] = [] - const data: { - [weaponId: string]: { - [weaponData: string]: string - } - } = {} - weapons.forEach((wpn) => { - promises.push( - (async () => { - const weaponData = await getWeaponReport(wpn, server, player) - if (Object.keys(weaponData).length > 0) { - data[wpn] = weaponData - } - })() - ) - }) - await Promise.all(promises) - return await cache.SET(cacheLocation + `:processedList`, JSON.stringify(data)) -} diff --git a/src/db/migrations/2023-04-09-01 kill watcher.ts b/src/db/migrations/2023-04-09-01 kill watcher.ts new file mode 100644 index 0000000..c3438ac --- /dev/null +++ b/src/db/migrations/2023-04-09-01 kill watcher.ts @@ -0,0 +1,34 @@ +import { Kysely, sql } from 'kysely' +import { Client } from 'pg' + +const notify_newkill = ` +CREATE OR REPLACE FUNCTION notify_new_kill() + RETURNS trigger +AS +$$ +BEGIN + PERFORM pg_notify('new_kill', row_to_json(NEW)::text); + RETURN NULL; +END; +$$ +LANGUAGE plpgsql; +` + +const pgClient = new Client({ + host: process.env.POSTGRES_HOST, + database: process.env.POSTGRES_DATABASE, + user: process.env.POSTGRES_USER, + password: process.env.POSTGRES_PASSWORD +}) + +export async function up(db: Kysely): Promise { + await pgClient.connect() + await pgClient.query(notify_newkill) + await pgClient.query(`CREATE TRIGGER insert_kills_notify AFTER INSERT ON kill FOR EACH ROW EXECUTE PROCEDURE notify_new_kill();`) +} + +export async function down(db: Kysely): Promise { + await pgClient.query(`DROP FUNCTION IF EXISTS notify_new_kill;`) + await pgClient.query(`DROP TRIGGER IF EXISTS insert_kills_notify;`) + await pgClient.end() +} diff --git a/src/process/onKill.ts b/src/process/onKill.ts new file mode 100644 index 0000000..d847b56 --- /dev/null +++ b/src/process/onKill.ts @@ -0,0 +1,71 @@ +import { Client } from 'pg' +import client from '../cache/redis' + +const pgClient = new Client({ + host: process.env.POSTGRES_HOST, + database: process.env.POSTGRES_DATABASE, + user: process.env.POSTGRES_USER, + password: process.env.POSTGRES_PASSWORD +}) + +export default async function listenKills() { + await pgClient.connect() + await pgClient.query('LISTEN new_kill') + + pgClient.on('notification', async (data) => { + if (!data.payload) return + const payload = JSON.parse(data.payload) + console.log(JSON.parse(data.payload)) + + await updateGlobal(payload) + + }) +} + +async function updateGlobal({ cause_of_death, distance, attacker_id, victim_id, server }: { cause_of_death: string, distance: number, attacker_id: string, victim_id: string, server: number }) { + const promises: Promise[] = [] + + promises.push(updatePath(`data`, { distance })) + //servers.1 + promises.push(updatePath(`servers.${server}`, { distance }).then(() => Promise.all([ + //servers.1.players.123456789 + updatePath(`servers.${server}.players.${attacker_id}`, { distance }).then(() => + //servers.1.players.123456789.weapons.epg + updatePath(`servers.${server}.players.${attacker_id}.weapons.${cause_of_death}`, { distance }) + ), + //servers.1.weapons.epg + updatePath(`servers.${server}.weapons.${cause_of_death}`, { distance }).then(() => + //servers.1.weapons.epg.players.123456789 + updatePath(`servers.${server}.weapons.${cause_of_death}.players.${attacker_id}`, { distance }) + )]) + )) + + + //players.123456789 + promises.push(updatePath(`players.${attacker_id}`, { distance }).then(async () => { + await client.json.numIncrBy('kills', `players.${victim_id}.deaths`, 1) + //players.123456789.weapons.epg + await updatePath(`players.${attacker_id}.weapons.${cause_of_death}`, { distance }) + return await client.json.numIncrBy('kills', `server.${server}.players.${victim_id}.deaths`, 1) + })) + + + //weapons.epg + promises.push(updatePath(`weapons.${cause_of_death}`, { distance }).then(() => + //weapons.epg.players.123456789 + updatePath(`weapons.${cause_of_death}.players.${attacker_id}`, { distance }) + )) + + + await Promise.all(promises) +} + +async function updatePath(path: string, { distance }: { distance: number }) { + if (!await client.json.type('kills', path)) await client.json.set('kills', path, {}) + let transaction = client.multi() + transaction = transaction.json.numIncrBy('kills', `${path}.total_distance`, distance) + let max_distance = Number(await client.json.get('kills', { path: `${path}.max_distance` })) + if (isNaN(max_distance) || max_distance < distance) transaction = transaction.json.set('kills', `${path}.max_distance`, distance) + transaction = transaction.json.numIncrBy('kills', `${path}.kills`, 1) + return transaction.exec() +} \ No newline at end of file diff --git a/src/process/process.ts b/src/process/process.ts new file mode 100644 index 0000000..316fac4 --- /dev/null +++ b/src/process/process.ts @@ -0,0 +1,184 @@ +import db from '../db/db' +const { count, max, sum } = db.fn +import client from '../cache/redis' + +const genPrefix = { + global: ({ server }: { server?: number }) => { + return (server ? `servers.${server}.` : '') + 'data' + }, + weapon: ({ cause_of_death, server }: { cause_of_death: string, server?: number }) => { + return (server ? `servers.${server}.` : '') + `weapons.${cause_of_death}` + }, + player: ({ attacker_id, server }: { attacker_id: string, server?: number }) => { + return (server ? `servers.${server}.` : '') + `players.${attacker_id}` + }, + playerWeapons: ({ cause_of_death, attacker_id, server }: { attacker_id: string, cause_of_death: string, server?: number }) => { + return (server ? `servers.${server}.` : '') + `players.${attacker_id}.weapons.${cause_of_death}` + }, + weaponPlayers: ({ cause_of_death, attacker_id, server }: { attacker_id: string, cause_of_death: string, server?: number }) => { + return (server ? `servers.${server}.` : '') + `weapons.${cause_of_death}.players.${attacker_id}` + }, +} + +/** + * Starts the process of populating the REDIS database globally + * @returns + */ +async function processAll() { + console.log('Starting data calculation...') + const timeStart = new Date() + + if (!await client.json.type('kills')) await client.json.set('kills', '$', { data: {}, weapons: {}, players: {}, servers: {} }) + await Promise.all([processGlobalStats(), processServerStats()]) + console.log( + 'Data calculation finished. Took + ' + + Math.abs(new Date().getTime() - timeStart.getTime()) / 1000 + + ' seconds' + ) + if (process.env.ENVIRONMENT == 'production') { + return + } + setTimeout(processAll, 3600000) +} + +async function processGlobalStats() { + let promises: Promise[] = [] + //Global kills + await db.selectFrom('kill').select([count('id').as('kills'), max('distance').as('max_distance'), sum('distance').as('total_distance')]).execute() + .then(async (data) => { + let transaction = client.multi() + await Promise.all(data.map(async ({ kills, max_distance, total_distance }) => { + await processData(genPrefix.global({}), { total_distance, max_distance, kills }, transaction) + })) + return transaction.exec() + }) + + //Global weapon kill + await db.selectFrom('kill').select([count('id').as('kills'), max('distance').as('max_distance'), sum('distance').as('total_distance'), 'cause_of_death']).groupBy('cause_of_death').execute() + .then(async (data) => { + let transaction = client.multi() + await Promise.all(data.map(async ({ kills, max_distance, total_distance, cause_of_death }) => { + const prefix = genPrefix.weapon({ cause_of_death }) + await processData(prefix, { kills, max_distance, total_distance }, transaction) + })) + return transaction.exec() + }) + + //Global player kill + await db.selectFrom('kill').select([count('id').as('kills'), max('distance').as('max_distance'), sum('distance').as('total_distance'), 'attacker_id']).groupBy('attacker_id').whereRef("attacker_id", '!=', 'victim_id').execute() + .then(async (data) => { + let transaction = client.multi() + await Promise.all(data.map(async ({ kills, max_distance, total_distance, attacker_id }) => { + const prefix = genPrefix.player({ attacker_id }) + await processData(prefix, { kills, max_distance, total_distance }, transaction) + })) + return transaction.exec() + }) + //Global player deaths + await db.selectFrom('kill').select([count('id').as('kills'), 'victim_id']).groupBy('victim_id').execute() + .then(async (data) => { + let transaction = client.multi() + await Promise.all(data.map(async ({ kills, victim_id }) => { + const prefix = genPrefix.player({ attacker_id: victim_id }) + transaction.json.set('kills', prefix + ".deaths", Number(kills)) + })) + return transaction.exec() + }) + + //Player weapon kills + await db.selectFrom('kill').select([count('id').as('kills'), max('distance').as('max_distance'), sum('distance').as('total_distance'), 'attacker_id', 'cause_of_death']).groupBy(['attacker_id', 'cause_of_death']).whereRef("attacker_id", '!=', 'victim_id').execute() + .then(async (data) => { + let transaction = client.multi() + await Promise.all(data.map(async ({ kills, max_distance, total_distance, attacker_id, cause_of_death }) => { + await processData(genPrefix.playerWeapons({ attacker_id, cause_of_death }), { kills, max_distance, total_distance }, transaction) + await processData(genPrefix.weaponPlayers({ attacker_id, cause_of_death }), { kills, max_distance, total_distance }, transaction) + })) + return transaction.exec() + }) + //player weapon deaths + await db.selectFrom('kill').select([count('id').as('kills'), 'victim_id', 'cause_of_death']).groupBy(['victim_id', 'cause_of_death']).execute() + .then(async (data) => { + let transaction = client.multi() + await Promise.all(data.map(async ({ kills, victim_id, cause_of_death }) => { + transaction.json.set('kills', genPrefix.weaponPlayers({ attacker_id: victim_id, cause_of_death }) + ".deaths", Number(kills)) + })) + return transaction.exec() + }) +} + +async function processServerStats() { + let promises: Promise[] = [] + //Server global kills + await db.selectFrom('kill') + .select([count('id').as('kills'), max('distance').as('max_distance'), sum('distance').as('total_distance'), 'server']) + .groupBy(['server']).execute().then(async (data) => { + let transaction = client.multi() + const p = data.map(async ({ kills, max_distance, total_distance, server }) => { + if (!await client.json.type('kills', `servers.${server}`)) await client.json.set('kills', `servers.${server}`, { data: {}, weapons: {}, players: {} }) + await processData(genPrefix.global({ server }), { total_distance, max_distance, kills }, transaction) + }) + await Promise.all(p) + return transaction.exec() + }) + + //Server weapon kills + await db.selectFrom('kill') + .select([count('id').as('kills'), max('distance').as('max_distance'), sum('distance').as('total_distance'), 'cause_of_death', 'server']).groupBy(['cause_of_death', 'server']).execute().then(async (data) => { + let transaction = client.multi() + await Promise.all(data.map(async ({ kills, max_distance, total_distance, cause_of_death, server }) => { + const prefix = genPrefix.weapon({ cause_of_death, server }) + await processData(prefix, { kills, max_distance, total_distance }, transaction) + })) + return transaction.exec() + }) + //server player kill + await db.selectFrom('kill').select([count('id').as('kills'), max('distance').as('max_distance'), sum('distance').as('total_distance'), 'attacker_id', 'server']).groupBy(['attacker_id', 'server']).whereRef("attacker_id", '!=', 'victim_id').execute() + .then(async (data) => { + let transaction = client.multi() + await Promise.all(data.map(async ({ kills, max_distance, total_distance, attacker_id, server }) => { + const prefix = genPrefix.player({ attacker_id, server }) + await processData(prefix, { kills, max_distance, total_distance }, transaction) + })) + return transaction.exec() + }) + //server player deaths + await db.selectFrom('kill').select([count('id').as('kills'), 'victim_id', 'server']).groupBy(['victim_id', 'server']).execute() + .then(async (data) => { + let transaction = client.multi() + await Promise.all(data.map(async ({ kills, victim_id, server }) => { + const prefix = genPrefix.player({ attacker_id: victim_id, server }) + transaction.json.set('kills', prefix + ".deaths", Number(kills)) + })) + return transaction.exec() + }) + + //player weapon kills + await db.selectFrom('kill').select([count('id').as('kills'), max('distance').as('max_distance'), sum('distance').as('total_distance'), 'attacker_id', 'cause_of_death', 'server']).groupBy(['attacker_id', 'cause_of_death', 'server']).whereRef("attacker_id", '!=', 'victim_id').execute() + .then(async (data) => { + let transaction = client.multi() + await Promise.all(data.map(async ({ kills, max_distance, total_distance, attacker_id, cause_of_death, server }) => { + await processData(genPrefix.playerWeapons({ attacker_id, cause_of_death, server }), { kills, max_distance, total_distance }, transaction) + await processData(genPrefix.weaponPlayers({ attacker_id, cause_of_death, server }), { kills, max_distance, total_distance }, transaction) + })) + return transaction.exec() + }) + //player weapon deaths + await db.selectFrom('kill').select([count('id').as('kills'), 'victim_id', 'cause_of_death', 'server']).groupBy(['victim_id', 'cause_of_death', 'server']).execute() + .then(async (data) => { + let transaction = client.multi() + await Promise.all(data.map(async ({ kills, victim_id, cause_of_death, server }) => { + transaction.json.set('kills', genPrefix.weaponPlayers({ attacker_id: victim_id, cause_of_death, server }) + ".deaths", Number(kills)) + })) + return transaction.exec() + }) +} + +async function processData(prefix: string, { total_distance, max_distance, kills }: { total_distance: string | number | bigint, max_distance: number, kills: string | number | bigint }, transaction: ReturnType) { + if (!await client.json.type('kills', prefix)) await client.json.set('kills', prefix, {}) + transaction = transaction.json.set('kills', prefix + ".total_distance", Number(total_distance)) + transaction = transaction.json.set('kills', prefix + ".max_distance", Number(max_distance)) + transaction = transaction.json.set('kills', prefix + ".kills", Number(kills)) + return +} + +export default processAll diff --git a/src/processMain.ts b/src/processMain.ts index 76ae4d7..5be088d 100644 --- a/src/processMain.ts +++ b/src/processMain.ts @@ -2,11 +2,13 @@ import * as dotenv from 'dotenv' dotenv.config() import { dbReady } from './db/db' import { cacheReady } from './cache/redis' -import processAll from './client/process' +import processAll from './process/process' +import listenKills from "./process/onKill" async function startup() { await dbReady() await cacheReady() + await listenKills() await processAll() } export default startup() \ No newline at end of file diff --git a/tests/client.test.ts b/tests/client.test.ts index c118a60..c5a0adf 100644 --- a/tests/client.test.ts +++ b/tests/client.test.ts @@ -28,6 +28,24 @@ describe('client', () => { expect(first[1]).toHaveProperty('kills') }) + test('player list with weapon filter', async () => { + const request = await fetch("http://127.0.0.1:3000/players?weapons=sniper") + const data = await request.json() + const first = Object.entries(data)[0] + expect(first[1]).toHaveProperty('max_distance') + expect(first[1]).toHaveProperty('total_distance') + expect(first[1]).toHaveProperty('kills') + }) + + test('player list with weapon and server filter', async () => { + const request = await fetch("http://127.0.0.1:3000/players?weapons=sniper&server=1") + const data = await request.json() + const first = Object.entries(data)[0] + expect(first[1]).toHaveProperty('max_distance') + expect(first[1]).toHaveProperty('total_distance') + expect(first[1]).toHaveProperty('kills') + }) + test('weapon list', async () => { const request = await fetch("http://127.0.0.1:3000/weapons") const data = await request.json() @@ -36,6 +54,24 @@ describe('client', () => { expect(first[1]).toHaveProperty('total_distance') expect(first[1]).toHaveProperty('kills') }) + + test('weapon list with player filter', async () => { + const request = await fetch("http://127.0.0.1:3000/weapons?players=1005930844007") + const data = await request.json() + const first = Object.entries(data)[0] + expect(first[1]).toHaveProperty('max_distance') + expect(first[1]).toHaveProperty('total_distance') + expect(first[1]).toHaveProperty('kills') + }) + + test('weapon list with player and server filter', async () => { + const request = await fetch("http://127.0.0.1:3000/weapons?players=1005930844007&server=1") + const data = await request.json() + const first = Object.entries(data)[0] + expect(first[1]).toHaveProperty('max_distance') + expect(first[1]).toHaveProperty('total_distance') + expect(first[1]).toHaveProperty('kills') + }) }) afterAll((done) => { diff --git a/tests/realtime.test.ts b/tests/realtime.test.ts new file mode 100644 index 0000000..179278b --- /dev/null +++ b/tests/realtime.test.ts @@ -0,0 +1,110 @@ +import { afterAll, beforeAll, describe, expect, test } from '@jest/globals' +import clientMain from '../src/clientMain' +import serverMain from '../src/serverMain' +import listenKills from "../src/process/onKill" +import * as dotenv from 'dotenv' +dotenv.config() + +let listenServerClient + +const data = { + attacker_weapon_1_mods: 0, + victim_id: '0', + victim_name: 'TestVictim', + victim_offhand_weapon_2: 0, + attacker_offhand_weapon_3: '0', + victim_weapon_3_mods: 0, + attacker_weapon_2_mods: 0, + attacker_offhand_weapon_1: 0, + attacker_weapon_3_mods: 0, + attacker_offhand_weapon_2: 0, + victim_offhand_weapon_3: '0', + victim_offhand_weapon_1: 0, + attacker_weapon_3: 'defender', + killstat_version: 'ks_3.0.0', + attacker_weapon_1: 'smr', + match_id: '31b1f34d', + distance: 0, + victim_current_weapon: 'smr', + cause_of_death: 'smr', + victim_weapon_2_mods: 0, + victim_current_weapon_mods: 0, + attacker_current_weapon_mods: 0, + game_time: 377.799, + player_count: 1, + attacker_current_weapon: 'smr', + attacker_id: '1', + game_mode: 'tdm', + map: 'thaw', + attacker_weapon_2: 'autopistol', + victim_weapon_1: 'smr', + victim_weapon_2: 'autopistol', + victim_weapon_1_mods: 0, + victim_weapon_3: 'defender', + attacker_name: 'TestAttacker' +} + +beforeAll(async () => { + listenServerClient = await clientMain; + listenServerClient = await serverMain; + const response = await fetch(`http://127.0.0.1:3001/${process.env.SERVERAUTH_ID}/kill`, { + method: "POST", // *GET, POST, PUT, DELETE, etc. + credentials: "same-origin", // include, *same-origin, omit + headers: { + "Content-Type": "application/json", + 'Authorization': `Basic ${Buffer.from(process.env.SERVERAUTH_ID + ':' + process.env.SERVERAUTH_TOKEN).toString('base64')}` + }, + body: JSON.stringify(data), // body data type must match "Content-Type" header + }); + expect(response.status).toBe(201) +}) + +describe('realtime', () => { + let playerKills + let weaponKills + let playerDeaths + test('fetch player', async () => { + const request = await fetch("http://127.0.0.1:3000/players") + const data = await request.json() + const player = data["1"] + expect(player).toHaveProperty('max_distance') + expect(player).toHaveProperty('total_distance') + expect(player).toHaveProperty('kills') + playerKills = player.kills + playerDeaths = player.deaths + }) + + test('fetch weapon', async () => { + const request = await fetch("http://127.0.0.1:3000/weapons") + const data = await request.json() + const weapon = data.smr + expect(weapon).toHaveProperty('max_distance') + expect(weapon).toHaveProperty('total_distance') + expect(weapon).toHaveProperty('kills') + weaponKills = weapon.kills + }) + + test('update player', async () => { + const request = await fetch("http://127.0.0.1:3000/players") + const data = await request.json() + const player = data["1"] + expect(player).toHaveProperty('max_distance') + expect(player).toHaveProperty('total_distance') + expect(player).toHaveProperty('kills') + }) + + test('check player update', async () => { + const request = await fetch("http://127.0.0.1:3000/players") + const data = await request.json() + const player = data["1"] + expect(player.kills).toBe(playerKills + 1) + }) + + test('check weapon update', async () => { + const request = await fetch("http://127.0.0.1:3000/weapons") + const data = await request.json() + const weapon = data.smr + expect(weapon.kills).toBe(weaponKills + 1) + }) + +}) From 85d293884b21933b6e0e63dd50b48a6e54c70e49 Mon Sep 17 00:00:00 2001 From: legonzaur Date: Sun, 9 Apr 2023 17:25:26 +0200 Subject: [PATCH 2/9] add tests and instant update --- package.json | 2 +- src/cache/redis.ts | 3 ++ src/client/client.ts | 81 +++++++++++++++++------------------------- src/clientMain.ts | 6 ++-- src/process/onKill.ts | 59 ++++++++++++++++++++++++------ src/process/process.ts | 6 +++- src/serverMain.ts | 4 --- tests/client.test.ts | 14 +++++--- tests/realtime.test.ts | 54 ++++++++++++++++++++++------ tests/server.test.ts | 9 ++--- 10 files changed, 151 insertions(+), 87 deletions(-) diff --git a/package.json b/package.json index 317f3b6..803e0a9 100644 --- a/package.json +++ b/package.json @@ -35,6 +35,6 @@ "startClient": "node out/clientMain.js", "startProcess": "node out/processMain.js", "documentation": "redocly build-docs .\\docs\\v1.yml --output=docs\\index.html", - "test": "jest" + "test": "jest --runInBand" } } \ No newline at end of file diff --git a/src/cache/redis.ts b/src/cache/redis.ts index 63c61cd..30e91df 100644 --- a/src/cache/redis.ts +++ b/src/cache/redis.ts @@ -1,5 +1,8 @@ import { createClient } from 'redis' +import * as dotenv from 'dotenv' +dotenv.config() + const client = createClient({ url: process.env.REDIS_URL, username: process.env.REDIS_USER, diff --git a/src/client/client.ts b/src/client/client.ts index b858835..90b722f 100644 --- a/src/client/client.ts +++ b/src/client/client.ts @@ -2,12 +2,7 @@ import { Router } from 'express' import { param, query } from 'express-validator' import { validateErrors } from '../common' import db from '../db/db' -import { - getWeaponList, - getWeaponReport, - getPlayerReport, - getPlayerList -} from '../cache/cacheUtils' +import client from '../cache/redis' const router = Router() //timeout middleware ? router.get('/*', (req, res, next) => { @@ -20,12 +15,12 @@ router.get( query(['player', 'server']).optional().toInt().isInt(), validateErrors, async (req, res) => { - const data = await getWeaponReport( - req.params.weaponId, - Number(req.query.server), - req.query.player?.toString() - ) - res.status(200).send(data) + let path = '' + if (req.query.server) path = path + `servers.${req.query.server}.` + if (req.query.player) path = path + `players.${req.query.player}.` + path = path + `weapons.${req.params.weaponId}` + if (await client.json.type('kills', path)) return res.status(200).send(await client.json.get('kills', { path })) + res.status(404).send() } ) @@ -34,11 +29,15 @@ router.get( query(['player', 'server']).optional().toInt().isInt(), validateErrors, async (req, res) => { - const data = await getWeaponList( - req.query.server?.toString(), - req.query.player?.toString() - ) - res.status(200).send(data) + let path = '' + if (req.query.server) path = path + `servers.${req.query.server}.` + if (req.query.player) path = path + `players.${req.query.player}.` + path = path + `weapons` + if (await client.json.type('kills', path)) { + const data = await client.json.get('kills', { path: `weapons` }) as { [key: number]: any } + return res.status(200).send(Object.fromEntries(Object.entries(data).map(([key, value]) => { return [key, { total_distance: value.total_distance, max_distance: value.max_distance, kills: value.kills, deaths: value.deaths }] }))) + } + res.status(404).send() } ) @@ -49,26 +48,12 @@ router.get( query('weapon').optional().isString(), validateErrors, async (req, res) => { - const data = await getPlayerReport( - req.params.playerId, - Number(req.query.server), - req.query.weapon?.toString() - ) - /* - if (!req.query.weapon) { - const weapons = await getWeaponList( - req.query.server?.toString(), - req.params.playerId - ) - data.weapons = weapons - } else { - data.weapons = (await getWeaponReport( - req.query.weapon.toString(), - Number(req.query.server) || undefined, - req.params.playerId - )) as any - }*/ - res.status(200).send(data) + let path = '' + if (req.query.server) path = path + `servers.${req.query.server}.` + if (req.query.weapon) path = path + `weapons.${req.query.weapon}.` + path = path + `players.${req.params.playerId}` + if (await client.json.type('kills', path)) return res.status(200).send(await client.json.get('kills', { path })) + res.status(404).send() } ) @@ -78,23 +63,23 @@ router.get( query('weapon').optional().isString(), validateErrors, async (req, res) => { - const data = await getPlayerList( - Number(req.query.server), - req.query.weapon?.toString() - ) - res.status(200).send(data) + let path = '' + if (req.query.server) path = path + `servers.${req.query.server}.` + if (req.query.weapon) path = path + `weapons.${req.query.weapon}.` + path = path + `players` + if (await client.json.type('kills', path)) { + const data = await client.json.get('kills', { path: `players` }) as { [key: number]: any } + return res.status(200).send(Object.fromEntries(Object.entries(data).map(([key, value]) => { return [key, { total_distance: value.total_distance, max_distance: value.max_distance, kills: value.kills, deaths: value.deaths }] }))) + } + res.status(404).send() } ) //router.get('/maps/', (req, res, next) => {}) router.get('/servers/', async (req, res) => { - const data = await db.selectFrom('server').selectAll().execute() - res.status(200).send( - data.map((e) => { - return { name: e.name, id: e.id, description: e.description } - }) - ) + const data = await client.json.get('kills', { path: `servers` }) as { [key: number]: any } + return res.status(200).send(Object.fromEntries(Object.entries(data).map(([key, value]) => { return [key, value.data] }))) }) //router.get('/servers/:serverId/', (req, res, next) => {}) diff --git a/src/clientMain.ts b/src/clientMain.ts index 580d2a1..f51f21f 100644 --- a/src/clientMain.ts +++ b/src/clientMain.ts @@ -3,8 +3,8 @@ dotenv.config() import express from 'express' import cors from 'cors' import client from './client/client' -import { dbReady } from './db/db' -import { cacheReady } from './cache/redis' +import db, { dbReady } from './db/db' +import cache, { cacheReady } from './cache/redis' const app = express() const port = 3000 @@ -28,4 +28,4 @@ export default }) }) }) - }) + }) \ No newline at end of file diff --git a/src/process/onKill.ts b/src/process/onKill.ts index d847b56..b6e7dea 100644 --- a/src/process/onKill.ts +++ b/src/process/onKill.ts @@ -1,5 +1,6 @@ import { Client } from 'pg' import client from '../cache/redis' +import { genPrefix } from './process' const pgClient = new Client({ host: process.env.POSTGRES_HOST, @@ -15,45 +16,72 @@ export default async function listenKills() { pgClient.on('notification', async (data) => { if (!data.payload) return const payload = JSON.parse(data.payload) - console.log(JSON.parse(data.payload)) - await updateGlobal(payload) - }) + return pgClient } + async function updateGlobal({ cause_of_death, distance, attacker_id, victim_id, server }: { cause_of_death: string, distance: number, attacker_id: string, victim_id: string, server: number }) { const promises: Promise[] = [] + if (!await client.json.type('kills')) await client.json.set('kills', '', { data: {}, weapons: {}, servers: {}, players: {} }) + + if (!await client.json.type('kills', genPrefix.weapon({ cause_of_death }))) await client.json.set('kills', genPrefix.weapon({ cause_of_death }), { players: {}, max_distance: 0, kills: 0, total_distance: 0 }) + if (!await client.json.type('kills', genPrefix.player({ attacker_id }))) await client.json.set('kills', genPrefix.player({ attacker_id }), { weapons: {}, max_distance: 0, kills: 0, deaths: 0, total_distance: 0 }) + if (!await client.json.type('kills', `servers.${server}`)) await client.json.set('kills', `servers.${server}`, { data: {}, weapons: {}, players: {}, max_distance: 0, kills: 0, total_distance: 0 }) + if (!await client.json.type('kills', genPrefix.weapon({ cause_of_death, server }))) await client.json.set('kills', genPrefix.weapon({ cause_of_death, server }), { players: {}, max_distance: 0, kills: 0, total_distance: 0 }) + if (!await client.json.type('kills', genPrefix.player({ attacker_id, server }))) await client.json.set('kills', genPrefix.player({ attacker_id, server }), { weapons: {}, max_distance: 0, kills: 0, deaths: 0, total_distance: 0 }) + + if (!await client.json.type('kills', genPrefix.player({ attacker_id: victim_id, server }))) await client.json.set('kills', genPrefix.player({ attacker_id: victim_id, server }), { weapons: {}, max_distance: 0, kills: 0, deaths: 0, total_distance: 0 }) + if (!await client.json.type('kills', genPrefix.player({ attacker_id: victim_id }))) await client.json.set('kills', genPrefix.player({ attacker_id: victim_id }), { weapons: {}, max_distance: 0, kills: 0, deaths: 0, total_distance: 0 }) + + if (!await client.json.type('kills', genPrefix.weaponPlayers({ attacker_id: victim_id, cause_of_death }))) await client.json.set('kills', genPrefix.weaponPlayers({ attacker_id: victim_id, cause_of_death }), { max_distance: 0, kills: 0, deaths: 0, total_distance: 0 }) + if (!await client.json.type('kills', genPrefix.playerWeapons({ attacker_id: victim_id, cause_of_death }))) await client.json.set('kills', genPrefix.playerWeapons({ attacker_id: victim_id, cause_of_death }), { max_distance: 0, kills: 0, total_distance: 0 }) + + if (!await client.json.type('kills', genPrefix.weaponPlayers({ attacker_id: victim_id, cause_of_death, server }))) await client.json.set('kills', genPrefix.weaponPlayers({ attacker_id: victim_id, cause_of_death, server }), { max_distance: 0, kills: 0, deaths: 0, total_distance: 0 }) + if (!await client.json.type('kills', genPrefix.playerWeapons({ attacker_id: victim_id, cause_of_death, server }))) await client.json.set('kills', genPrefix.playerWeapons({ attacker_id: victim_id, cause_of_death, server }), { max_distance: 0, kills: 0, total_distance: 0 }) + promises.push(updatePath(`data`, { distance })) //servers.1 promises.push(updatePath(`servers.${server}`, { distance }).then(() => Promise.all([ //servers.1.players.123456789 - updatePath(`servers.${server}.players.${attacker_id}`, { distance }).then(() => + updatePath(`servers.${server}.players.${attacker_id}`, { distance }).then(async () => { + if (!await client.json.type('kills', `servers.${server}.players.${victim_id}.deaths`)) await client.json.set('kills', `servers.${server}.players.${victim_id}.deaths`, 0) + await client.json.numIncrBy('kills', `servers.${server}.players.${victim_id}.deaths`, 1) //servers.1.players.123456789.weapons.epg - updatePath(`servers.${server}.players.${attacker_id}.weapons.${cause_of_death}`, { distance }) + await updatePath(`servers.${server}.players.${attacker_id}.weapons.${cause_of_death}`, { distance }) + } + ), //servers.1.weapons.epg - updatePath(`servers.${server}.weapons.${cause_of_death}`, { distance }).then(() => + updatePath(`servers.${server}.weapons.${cause_of_death}`, { distance }).then(async () => { //servers.1.weapons.epg.players.123456789 - updatePath(`servers.${server}.weapons.${cause_of_death}.players.${attacker_id}`, { distance }) + await updatePath(`servers.${server}.weapons.${cause_of_death}.players.${attacker_id}`, { distance }) + if (!await client.json.type('kills', `servers.${server}.players.${victim_id}.deaths`)) await client.json.set('kills', `servers.${server}.weapons.${cause_of_death}.players.${victim_id}`, 0) + await client.json.numIncrBy('kills', `servers.${server}.weapons.${cause_of_death}.players.${victim_id}.deaths`, 1) + } + )]) )) //players.123456789 promises.push(updatePath(`players.${attacker_id}`, { distance }).then(async () => { + if (!await client.json.type('kills', `players.${victim_id}.deaths`)) await client.json.set('kills', `players.${victim_id}.deaths`, 0) await client.json.numIncrBy('kills', `players.${victim_id}.deaths`, 1) //players.123456789.weapons.epg await updatePath(`players.${attacker_id}.weapons.${cause_of_death}`, { distance }) - return await client.json.numIncrBy('kills', `server.${server}.players.${victim_id}.deaths`, 1) })) //weapons.epg - promises.push(updatePath(`weapons.${cause_of_death}`, { distance }).then(() => + promises.push(updatePath(`weapons.${cause_of_death}`, { distance }).then(async () => { //weapons.epg.players.123456789 - updatePath(`weapons.${cause_of_death}.players.${attacker_id}`, { distance }) + await updatePath(`weapons.${cause_of_death}.players.${attacker_id}`, { distance }) + if (!await client.json.type('kills', `weapons.${cause_of_death}.players.${victim_id}.deaths`)) await client.json.set('kills', `weapons.${cause_of_death}.players.${victim_id}.deaths`, 0) + await client.json.numIncrBy('kills', `weapons.${cause_of_death}.players.${victim_id}.deaths`, 1) + } )) @@ -61,11 +89,20 @@ async function updateGlobal({ cause_of_death, distance, attacker_id, victim_id, } async function updatePath(path: string, { distance }: { distance: number }) { + await setupPath(path) + console.log(path + '.max_distance', await client.json.type('kills', path + '.max_distance')) + console.log(path, await client.json.type('kills', path)) if (!await client.json.type('kills', path)) await client.json.set('kills', path, {}) let transaction = client.multi() transaction = transaction.json.numIncrBy('kills', `${path}.total_distance`, distance) let max_distance = Number(await client.json.get('kills', { path: `${path}.max_distance` })) if (isNaN(max_distance) || max_distance < distance) transaction = transaction.json.set('kills', `${path}.max_distance`, distance) transaction = transaction.json.numIncrBy('kills', `${path}.kills`, 1) - return transaction.exec() + await transaction.exec() +} + +async function setupPath(path: string) { + if (!await client.json.type('kills', path + '.kills')) await client.json.set('kills', path + '.kills', 0) + if (!await client.json.type('kills', path + '.total_distance')) await client.json.set('kills', path + '.total_distance', 0) + if (!await client.json.type('kills', path + '.max_distance')) await client.json.set('kills', path + '.max_distance', 0) } \ No newline at end of file diff --git a/src/process/process.ts b/src/process/process.ts index 316fac4..35c3ed3 100644 --- a/src/process/process.ts +++ b/src/process/process.ts @@ -2,7 +2,7 @@ import db from '../db/db' const { count, max, sum } = db.fn import client from '../cache/redis' -const genPrefix = { +export const genPrefix = { global: ({ server }: { server?: number }) => { return (server ? `servers.${server}.` : '') + 'data' }, @@ -59,6 +59,7 @@ async function processGlobalStats() { let transaction = client.multi() await Promise.all(data.map(async ({ kills, max_distance, total_distance, cause_of_death }) => { const prefix = genPrefix.weapon({ cause_of_death }) + if (!await client.json.type('kills', prefix)) await client.json.set('kills', prefix, { players: {} }) await processData(prefix, { kills, max_distance, total_distance }, transaction) })) return transaction.exec() @@ -70,6 +71,7 @@ async function processGlobalStats() { let transaction = client.multi() await Promise.all(data.map(async ({ kills, max_distance, total_distance, attacker_id }) => { const prefix = genPrefix.player({ attacker_id }) + if (!await client.json.type('kills', prefix)) await client.json.set('kills', prefix, { weapons: {} }) await processData(prefix, { kills, max_distance, total_distance }, transaction) })) return transaction.exec() @@ -127,6 +129,7 @@ async function processServerStats() { let transaction = client.multi() await Promise.all(data.map(async ({ kills, max_distance, total_distance, cause_of_death, server }) => { const prefix = genPrefix.weapon({ cause_of_death, server }) + if (!await client.json.type('kills', prefix)) await client.json.set('kills', prefix, { players: {} }) await processData(prefix, { kills, max_distance, total_distance }, transaction) })) return transaction.exec() @@ -137,6 +140,7 @@ async function processServerStats() { let transaction = client.multi() await Promise.all(data.map(async ({ kills, max_distance, total_distance, attacker_id, server }) => { const prefix = genPrefix.player({ attacker_id, server }) + if (!await client.json.type('kills', prefix)) await client.json.set('kills', prefix, { weapons: {} }) await processData(prefix, { kills, max_distance, total_distance }, transaction) })) return transaction.exec() diff --git a/src/serverMain.ts b/src/serverMain.ts index 9d861c2..0f26a0f 100644 --- a/src/serverMain.ts +++ b/src/serverMain.ts @@ -25,7 +25,3 @@ export default new Promise((resolve, reject) => { }) }) }) - -app.on('close', () => { - -}) diff --git a/tests/client.test.ts b/tests/client.test.ts index c5a0adf..51f6a27 100644 --- a/tests/client.test.ts +++ b/tests/client.test.ts @@ -1,6 +1,8 @@ import { afterAll, beforeAll, describe, expect, test } from '@jest/globals' import clientMain from '../src/clientMain' import * as dotenv from 'dotenv' +import db from '../src/db/db' +import cache from '../src/cache/redis' dotenv.config() let listenServer @@ -13,10 +15,10 @@ describe('client', () => { test('server list', async () => { const request = await fetch("http://127.0.0.1:3000/servers") const data = await request.json() - expect(data.length).toBeGreaterThan(0) - expect(data[0]).toHaveProperty('name') - expect(data[0]).toHaveProperty('id') - expect(data[0]).toHaveProperty('description') + const first = Object.entries(data)[0] + expect(first).toHaveProperty('name') + expect(first).toHaveProperty('id') + expect(first).toHaveProperty('description') }) test('player list', async () => { @@ -75,7 +77,9 @@ describe('client', () => { }) afterAll((done) => { - listenServer.close(() => { + listenServer.close(async () => { + await db.destroy() + await cache.quit() done() }) }) \ No newline at end of file diff --git a/tests/realtime.test.ts b/tests/realtime.test.ts index 179278b..8d71513 100644 --- a/tests/realtime.test.ts +++ b/tests/realtime.test.ts @@ -1,11 +1,15 @@ -import { afterAll, beforeAll, describe, expect, test } from '@jest/globals' +import { afterAll, beforeAll, describe, expect, jest, test } from '@jest/globals' import clientMain from '../src/clientMain' import serverMain from '../src/serverMain' import listenKills from "../src/process/onKill" import * as dotenv from 'dotenv' +import db from '../src/db/db' +import cache from '../src/cache/redis' dotenv.config() -let listenServerClient +let listenClient +let listenServer +let pgClient const data = { attacker_weapon_1_mods: 0, @@ -44,9 +48,20 @@ const data = { attacker_name: 'TestAttacker' } +function waitFor(time: number) { + return new Promise((resolve, reject) => { + setTimeout(resolve, time) + }) +} + +jest.setTimeout(15000) + beforeAll(async () => { - listenServerClient = await clientMain; - listenServerClient = await serverMain; + jest.setTimeout(15000) + listenClient = await clientMain; + listenServer = await serverMain; + pgClient = await listenKills() + const yea = waitFor(10000) const response = await fetch(`http://127.0.0.1:3001/${process.env.SERVERAUTH_ID}/kill`, { method: "POST", // *GET, POST, PUT, DELETE, etc. credentials: "same-origin", // include, *same-origin, omit @@ -57,6 +72,7 @@ beforeAll(async () => { body: JSON.stringify(data), // body data type must match "Content-Type" header }); expect(response.status).toBe(201) + await yea }) describe('realtime', () => { @@ -85,12 +101,19 @@ describe('realtime', () => { }) test('update player', async () => { - const request = await fetch("http://127.0.0.1:3000/players") - const data = await request.json() - const player = data["1"] - expect(player).toHaveProperty('max_distance') - expect(player).toHaveProperty('total_distance') - expect(player).toHaveProperty('kills') + jest.setTimeout(15000) + const yea = waitFor(10000) + const response = await fetch(`http://127.0.0.1:3001/${process.env.SERVERAUTH_ID}/kill`, { + method: "POST", // *GET, POST, PUT, DELETE, etc. + credentials: "same-origin", // include, *same-origin, omit + headers: { + "Content-Type": "application/json", + 'Authorization': `Basic ${Buffer.from(process.env.SERVERAUTH_ID + ':' + process.env.SERVERAUTH_TOKEN).toString('base64')}` + }, + body: JSON.stringify(data), // body data type must match "Content-Type" header + }); + expect(response.status).toBe(201) + await yea }) test('check player update', async () => { @@ -108,3 +131,14 @@ describe('realtime', () => { }) }) + +afterAll((done) => { + listenClient.close(() => { + listenServer.close(async () => { + await pgClient.end() + await db.destroy() + await cache.quit() + done() + }) + }) +}) \ No newline at end of file diff --git a/tests/server.test.ts b/tests/server.test.ts index b5cbba3..e71e44e 100644 --- a/tests/server.test.ts +++ b/tests/server.test.ts @@ -124,9 +124,10 @@ describe('server', () => { }) afterAll((done) => { - listenServer.close(() => { - db.deleteFrom('kill').where('attacker_id', '=', '0').orWhere('attacker_id', '=', '1').execute().then(() => { - db.destroy().then(() => { done() }) + db.deleteFrom('kill').where('attacker_id', '=', '0').orWhere('attacker_id', '=', '1').execute().then(() => { + listenServer.close(async () => { + await db.destroy() + done() }) }) -}) \ No newline at end of file +}) From 1fb4ff26c4e87f278adfbd7583672053288c6495 Mon Sep 17 00:00:00 2001 From: legonzaur Date: Wed, 19 Apr 2023 10:22:33 +0200 Subject: [PATCH 3/9] non-working WIP changes --- .../2023-04-09-02 server auth patch.ts | 31 +++++++++++++++++++ 1 file changed, 31 insertions(+) create mode 100644 src/db/migrations/2023-04-09-02 server auth patch.ts diff --git a/src/db/migrations/2023-04-09-02 server auth patch.ts b/src/db/migrations/2023-04-09-02 server auth patch.ts new file mode 100644 index 0000000..c62ddf1 --- /dev/null +++ b/src/db/migrations/2023-04-09-02 server auth patch.ts @@ -0,0 +1,31 @@ +import { Kysely, sql } from 'kysely' +import { Client } from 'pg' + +const pgClient = new Client({ + host: process.env.POSTGRES_HOST, + database: process.env.POSTGRES_DATABASE, + user: process.env.POSTGRES_USER, + password: process.env.POSTGRES_PASSWORD +}) + +export async function up(db: Kysely): Promise { + await pgClient.connect() + await pgClient.query('ALTER TABLE kill ADD servername character varying NULL;') + await pgClient.query('UPDATE kill SET servername = server.name FROM server WHERE server.id = kill.server;') + await pgClient.query('ALTER TABLE kill ALTER COLUMN servername SET NOT NULL;') + await pgClient.query('ALTER TABLE kill DROP COLUMN server;') + + await pgClient.query('ALTER TABLE server RENAME TO hoster') + await pgClient.query('ALTER TABLE hoster DROP COLUMN description') +} + +export async function down(db: Kysely): Promise { + await pgClient.query('ALTER TABLE hoster add description character varying NULL') + await pgClient.query('ALTER TABLE hoster RENAME TO server') + + await pgClient.query(`ALTER TABLE kill ADD server integer NULL;`) + await pgClient.query('UPDATE kill SET server = server.id FROM server where server.name = kill.servername;') + await pgClient.query('ALTER TABLE kill ALTER COLUMN server SET NOT NULL;') + await pgClient.query('ALTER TABLE kill DROP COLUMN servername;') + await pgClient.end() +} From 9284cce0ed90586150503ebb7df645cf20125afd Mon Sep 17 00:00:00 2001 From: legonzaur Date: Wed, 19 Apr 2023 10:23:03 +0200 Subject: [PATCH 4/9] Non-working breaking changes --- src/db/db.ts | 55 +++++------- .../2023-04-09-02 server auth patch.ts | 3 + src/db/model.ts | 8 +- src/process/onKill.ts | 34 +++---- src/process/process.ts | 59 ++++++------ src/server/register.ts | 90 ------------------- src/server/server.ts | 57 ++++++------ tests/client.test.ts | 6 +- tests/realtime.test.ts | 5 +- tests/server.test.ts | 15 ++-- 10 files changed, 124 insertions(+), 208 deletions(-) delete mode 100644 src/server/register.ts diff --git a/src/db/db.ts b/src/db/db.ts index c669b8f..5bee2f5 100644 --- a/src/db/db.ts +++ b/src/db/db.ts @@ -41,43 +41,36 @@ export async function CreateKillRecord(data: KillRecord) { .execute() } -export async function FindServer({ name }: { name: string }) { - return await db - .selectFrom('server') - .select(['server.name', 'server.description']) - .where('server.name', '=', name) - .executeTakeFirst() -} +// export async function FindServer({ name }: { name: string }) { +// return await db +// .selectFrom('server') +// .select(['server.name', 'server.description']) +// .where('server.name', '=', name) +// .executeTakeFirst() +// } -export async function CreateServer({ - name, - description -}: { - name: string - description: string -}) { - return await db - .insertInto('server') - .values({ name, description }) - .returning(['id', 'token']) - .executeTakeFirstOrThrow() -} +// export async function CreateServer({ +// name, +// description +// }: { +// name: string +// description: string +// }) { +// return await db +// .insertInto('server') +// .values({ name, description }) +// .returning(['id', 'token']) +// .executeTakeFirstOrThrow() +// } //tokens are stored in raw... maybe we should use something better in the future //Using callback for express-basic-auth -export function CheckServerToken( - this: { params: any, body: any }, - name: string, - password: string, - cb: (error: Error | null, success: boolean) => void -) { - db.selectFrom('server') - .where('id', '=', Number(name)) - .where('token', '=', password) +export function CheckServerToken(token: string) { + return db.selectFrom('hoster').select('id') + .where('token', '=', Buffer.from(token, 'base64').toString()) .executeTakeFirst() .then((result) => { - if (name != this.params.serverId) return cb(null, false) - return cb(null, !!result) + return result }) } /* diff --git a/src/db/migrations/2023-04-09-02 server auth patch.ts b/src/db/migrations/2023-04-09-02 server auth patch.ts index c62ddf1..41038cd 100644 --- a/src/db/migrations/2023-04-09-02 server auth patch.ts +++ b/src/db/migrations/2023-04-09-02 server auth patch.ts @@ -17,9 +17,12 @@ export async function up(db: Kysely): Promise { await pgClient.query('ALTER TABLE server RENAME TO hoster') await pgClient.query('ALTER TABLE hoster DROP COLUMN description') + await pgClient.query('ALTER TABLE kill ADD COLUMN host integer NULL') + //Update the hosts column once the hoster table is manually updated } export async function down(db: Kysely): Promise { + await pgClient.query('ALTER TABLE kill DROP COLUMN host') await pgClient.query('ALTER TABLE hoster add description character varying NULL') await pgClient.query('ALTER TABLE hoster RENAME TO server') diff --git a/src/db/model.ts b/src/db/model.ts index d9d1fba..47a74c0 100644 --- a/src/db/model.ts +++ b/src/db/model.ts @@ -2,7 +2,8 @@ import { ColumnType, Generated } from 'kysely' export interface KillTable { id: Generated - server: number + servername: string + host: number killstat_version: string match_id: string game_mode: string @@ -56,10 +57,9 @@ interface MapTable { description: string image: string } -interface ServerTable { +interface HosterTable { id: Generated name: string - description: string token: Generated } interface Database { @@ -67,7 +67,7 @@ interface Database { player: PlayerTable weapon: WeaponTable maps: MapTable - server: ServerTable + hoster: HosterTable } export default Database diff --git a/src/process/onKill.ts b/src/process/onKill.ts index b6e7dea..8d51722 100644 --- a/src/process/onKill.ts +++ b/src/process/onKill.ts @@ -22,44 +22,46 @@ export default async function listenKills() { } -async function updateGlobal({ cause_of_death, distance, attacker_id, victim_id, server }: { cause_of_death: string, distance: number, attacker_id: string, victim_id: string, server: number }) { +async function updateGlobal({ cause_of_death, distance, attacker_id, victim_id, servername, host }: { cause_of_death: string, distance: number, attacker_id: string, victim_id: string, servername: string, host: number }) { + servername = servername.replace(/[^a-z0-9]/gi, '') const promises: Promise[] = [] if (!await client.json.type('kills')) await client.json.set('kills', '', { data: {}, weapons: {}, servers: {}, players: {} }) if (!await client.json.type('kills', genPrefix.weapon({ cause_of_death }))) await client.json.set('kills', genPrefix.weapon({ cause_of_death }), { players: {}, max_distance: 0, kills: 0, total_distance: 0 }) if (!await client.json.type('kills', genPrefix.player({ attacker_id }))) await client.json.set('kills', genPrefix.player({ attacker_id }), { weapons: {}, max_distance: 0, kills: 0, deaths: 0, total_distance: 0 }) - if (!await client.json.type('kills', `servers.${server}`)) await client.json.set('kills', `servers.${server}`, { data: {}, weapons: {}, players: {}, max_distance: 0, kills: 0, total_distance: 0 }) - if (!await client.json.type('kills', genPrefix.weapon({ cause_of_death, server }))) await client.json.set('kills', genPrefix.weapon({ cause_of_death, server }), { players: {}, max_distance: 0, kills: 0, total_distance: 0 }) - if (!await client.json.type('kills', genPrefix.player({ attacker_id, server }))) await client.json.set('kills', genPrefix.player({ attacker_id, server }), { weapons: {}, max_distance: 0, kills: 0, deaths: 0, total_distance: 0 }) + if (!await client.json.type('kills', `servers.${host}`)) await client.json.set('kills', `servers.${host}`, {}) + if (!await client.json.type('kills', `servers.${host}.${servername}`)) await client.json.set('kills', `servers.${host}.${servername}`, { data: {}, weapons: {}, players: {}, max_distance: 0, kills: 0, total_distance: 0 }) + if (!await client.json.type('kills', genPrefix.weapon({ cause_of_death, server: `${host}.${servername}` }))) await client.json.set('kills', genPrefix.weapon({ cause_of_death, server: `${host}.${servername}` }), { players: {}, max_distance: 0, kills: 0, total_distance: 0 }) + if (!await client.json.type('kills', genPrefix.player({ attacker_id, server: `${host}.${servername}` }))) await client.json.set('kills', genPrefix.player({ attacker_id, server: `${host}.${servername}` }), { weapons: {}, max_distance: 0, kills: 0, deaths: 0, total_distance: 0 }) - if (!await client.json.type('kills', genPrefix.player({ attacker_id: victim_id, server }))) await client.json.set('kills', genPrefix.player({ attacker_id: victim_id, server }), { weapons: {}, max_distance: 0, kills: 0, deaths: 0, total_distance: 0 }) + if (!await client.json.type('kills', genPrefix.player({ attacker_id: victim_id, server: `${host}.${servername}` }))) await client.json.set('kills', genPrefix.player({ attacker_id: victim_id, server: `${host}.${servername}` }), { weapons: {}, max_distance: 0, kills: 0, deaths: 0, total_distance: 0 }) if (!await client.json.type('kills', genPrefix.player({ attacker_id: victim_id }))) await client.json.set('kills', genPrefix.player({ attacker_id: victim_id }), { weapons: {}, max_distance: 0, kills: 0, deaths: 0, total_distance: 0 }) if (!await client.json.type('kills', genPrefix.weaponPlayers({ attacker_id: victim_id, cause_of_death }))) await client.json.set('kills', genPrefix.weaponPlayers({ attacker_id: victim_id, cause_of_death }), { max_distance: 0, kills: 0, deaths: 0, total_distance: 0 }) if (!await client.json.type('kills', genPrefix.playerWeapons({ attacker_id: victim_id, cause_of_death }))) await client.json.set('kills', genPrefix.playerWeapons({ attacker_id: victim_id, cause_of_death }), { max_distance: 0, kills: 0, total_distance: 0 }) - if (!await client.json.type('kills', genPrefix.weaponPlayers({ attacker_id: victim_id, cause_of_death, server }))) await client.json.set('kills', genPrefix.weaponPlayers({ attacker_id: victim_id, cause_of_death, server }), { max_distance: 0, kills: 0, deaths: 0, total_distance: 0 }) - if (!await client.json.type('kills', genPrefix.playerWeapons({ attacker_id: victim_id, cause_of_death, server }))) await client.json.set('kills', genPrefix.playerWeapons({ attacker_id: victim_id, cause_of_death, server }), { max_distance: 0, kills: 0, total_distance: 0 }) + if (!await client.json.type('kills', genPrefix.weaponPlayers({ attacker_id: victim_id, cause_of_death, server: `${host}.${servername}` }))) await client.json.set('kills', genPrefix.weaponPlayers({ attacker_id: victim_id, cause_of_death, server: `${host}.${servername}` }), { max_distance: 0, kills: 0, deaths: 0, total_distance: 0 }) + if (!await client.json.type('kills', genPrefix.playerWeapons({ attacker_id: victim_id, cause_of_death, server: `${host}.${servername}` }))) await client.json.set('kills', genPrefix.playerWeapons({ attacker_id: victim_id, cause_of_death, server: `${host}.${servername}` }), { max_distance: 0, kills: 0, total_distance: 0 }) promises.push(updatePath(`data`, { distance })) //servers.1 - promises.push(updatePath(`servers.${server}`, { distance }).then(() => Promise.all([ + promises.push(updatePath(`servers.${host}.${servername}`, { distance }).then(() => Promise.all([ //servers.1.players.123456789 - updatePath(`servers.${server}.players.${attacker_id}`, { distance }).then(async () => { - if (!await client.json.type('kills', `servers.${server}.players.${victim_id}.deaths`)) await client.json.set('kills', `servers.${server}.players.${victim_id}.deaths`, 0) - await client.json.numIncrBy('kills', `servers.${server}.players.${victim_id}.deaths`, 1) + updatePath(`servers.${host}.${servername}.players.${attacker_id}`, { distance }).then(async () => { + if (!await client.json.type('kills', `servers.${host}.${servername}.players.${victim_id}.deaths`)) await client.json.set('kills', `servers.${host}.${servername}.players.${victim_id}.deaths`, 0) + await client.json.numIncrBy('kills', `servers.${host}.${servername}.players.${victim_id}.deaths`, 1) //servers.1.players.123456789.weapons.epg - await updatePath(`servers.${server}.players.${attacker_id}.weapons.${cause_of_death}`, { distance }) + await updatePath(`servers.${host}.${servername}.players.${attacker_id}.weapons.${cause_of_death}`, { distance }) } ), //servers.1.weapons.epg - updatePath(`servers.${server}.weapons.${cause_of_death}`, { distance }).then(async () => { + updatePath(`servers.${host}.${servername}.weapons.${cause_of_death}`, { distance }).then(async () => { //servers.1.weapons.epg.players.123456789 - await updatePath(`servers.${server}.weapons.${cause_of_death}.players.${attacker_id}`, { distance }) - if (!await client.json.type('kills', `servers.${server}.players.${victim_id}.deaths`)) await client.json.set('kills', `servers.${server}.weapons.${cause_of_death}.players.${victim_id}`, 0) - await client.json.numIncrBy('kills', `servers.${server}.weapons.${cause_of_death}.players.${victim_id}.deaths`, 1) + await updatePath(`servers.${host}.${servername}.weapons.${cause_of_death}.players.${attacker_id}`, { distance }) + if (!await client.json.type('kills', `servers.${host}.${servername}.players.${victim_id}.deaths`)) await client.json.set('kills', `servers.${host}.${servername}.weapons.${cause_of_death}.players.${victim_id}`, 0) + await client.json.numIncrBy('kills', `servers.${host}.${servername}.weapons.${cause_of_death}.players.${victim_id}.deaths`, 1) } )]) diff --git a/src/process/process.ts b/src/process/process.ts index 35c3ed3..4b4440c 100644 --- a/src/process/process.ts +++ b/src/process/process.ts @@ -3,19 +3,19 @@ const { count, max, sum } = db.fn import client from '../cache/redis' export const genPrefix = { - global: ({ server }: { server?: number }) => { + global: ({ server }: { server?: string }) => { return (server ? `servers.${server}.` : '') + 'data' }, - weapon: ({ cause_of_death, server }: { cause_of_death: string, server?: number }) => { + weapon: ({ cause_of_death, server }: { cause_of_death: string, server?: string }) => { return (server ? `servers.${server}.` : '') + `weapons.${cause_of_death}` }, - player: ({ attacker_id, server }: { attacker_id: string, server?: number }) => { + player: ({ attacker_id, server }: { attacker_id: string, server?: string }) => { return (server ? `servers.${server}.` : '') + `players.${attacker_id}` }, - playerWeapons: ({ cause_of_death, attacker_id, server }: { attacker_id: string, cause_of_death: string, server?: number }) => { + playerWeapons: ({ cause_of_death, attacker_id, server }: { attacker_id: string, cause_of_death: string, server?: string }) => { return (server ? `servers.${server}.` : '') + `players.${attacker_id}.weapons.${cause_of_death}` }, - weaponPlayers: ({ cause_of_death, attacker_id, server }: { attacker_id: string, cause_of_death: string, server?: number }) => { + weaponPlayers: ({ cause_of_death, attacker_id, server }: { attacker_id: string, cause_of_death: string, server?: string }) => { return (server ? `servers.${server}.` : '') + `weapons.${cause_of_death}.players.${attacker_id}` }, } @@ -112,12 +112,14 @@ async function processServerStats() { let promises: Promise[] = [] //Server global kills await db.selectFrom('kill') - .select([count('id').as('kills'), max('distance').as('max_distance'), sum('distance').as('total_distance'), 'server']) - .groupBy(['server']).execute().then(async (data) => { + .select([count('id').as('kills'), max('distance').as('max_distance'), sum('distance').as('total_distance'), 'servername', 'host']) + .groupBy(['servername', 'host']).execute().then(async (data) => { let transaction = client.multi() - const p = data.map(async ({ kills, max_distance, total_distance, server }) => { - if (!await client.json.type('kills', `servers.${server}`)) await client.json.set('kills', `servers.${server}`, { data: {}, weapons: {}, players: {} }) - await processData(genPrefix.global({ server }), { total_distance, max_distance, kills }, transaction) + const p = data.map(async ({ kills, max_distance, total_distance, servername, host }) => { + servername = servername.replace(/[^a-z0-9]/gi, '') + if (!await client.json.type('kills', `servers.${host}`)) await client.json.set('kills', `servers.${host}`, {}) + if (!await client.json.type('kills', `servers.${host}.${servername}`)) await client.json.set('kills', `servers.${host}.${servername}`, { data: {}, weapons: {}, players: {} }) + await processData(genPrefix.global({ server: `${host}.${servername}` }), { total_distance, max_distance, kills }, transaction) }) await Promise.all(p) return transaction.exec() @@ -125,53 +127,58 @@ async function processServerStats() { //Server weapon kills await db.selectFrom('kill') - .select([count('id').as('kills'), max('distance').as('max_distance'), sum('distance').as('total_distance'), 'cause_of_death', 'server']).groupBy(['cause_of_death', 'server']).execute().then(async (data) => { + .select([count('id').as('kills'), max('distance').as('max_distance'), sum('distance').as('total_distance'), 'cause_of_death', 'servername', 'host']).groupBy(['cause_of_death', 'servername', 'host']).execute().then(async (data) => { let transaction = client.multi() - await Promise.all(data.map(async ({ kills, max_distance, total_distance, cause_of_death, server }) => { - const prefix = genPrefix.weapon({ cause_of_death, server }) + await Promise.all(data.map(async ({ kills, max_distance, total_distance, cause_of_death, servername, host }) => { + servername = servername.replace(/[^a-z0-9]/gi, '') + const prefix = genPrefix.weapon({ cause_of_death, server: `${host}.${servername}` }) if (!await client.json.type('kills', prefix)) await client.json.set('kills', prefix, { players: {} }) await processData(prefix, { kills, max_distance, total_distance }, transaction) })) return transaction.exec() }) //server player kill - await db.selectFrom('kill').select([count('id').as('kills'), max('distance').as('max_distance'), sum('distance').as('total_distance'), 'attacker_id', 'server']).groupBy(['attacker_id', 'server']).whereRef("attacker_id", '!=', 'victim_id').execute() + await db.selectFrom('kill').select([count('id').as('kills'), max('distance').as('max_distance'), sum('distance').as('total_distance'), 'attacker_id', 'servername', 'host']).groupBy(['attacker_id', 'servername', 'host']).whereRef("attacker_id", '!=', 'victim_id').execute() .then(async (data) => { let transaction = client.multi() - await Promise.all(data.map(async ({ kills, max_distance, total_distance, attacker_id, server }) => { - const prefix = genPrefix.player({ attacker_id, server }) + await Promise.all(data.map(async ({ kills, max_distance, total_distance, attacker_id, servername, host }) => { + servername = servername.replace(/[^a-z0-9]/gi, '') + const prefix = genPrefix.player({ attacker_id, server: `${host}.${servername}` }) if (!await client.json.type('kills', prefix)) await client.json.set('kills', prefix, { weapons: {} }) await processData(prefix, { kills, max_distance, total_distance }, transaction) })) return transaction.exec() }) //server player deaths - await db.selectFrom('kill').select([count('id').as('kills'), 'victim_id', 'server']).groupBy(['victim_id', 'server']).execute() + await db.selectFrom('kill').select([count('id').as('kills'), 'victim_id', 'servername', 'host']).groupBy(['victim_id', 'servername', 'host']).execute() .then(async (data) => { let transaction = client.multi() - await Promise.all(data.map(async ({ kills, victim_id, server }) => { - const prefix = genPrefix.player({ attacker_id: victim_id, server }) + await Promise.all(data.map(async ({ kills, victim_id, servername, host }) => { + servername = servername.replace(/[^a-z0-9]/gi, '') + const prefix = genPrefix.player({ attacker_id: victim_id, server: `${host}.${servername}` }) transaction.json.set('kills', prefix + ".deaths", Number(kills)) })) return transaction.exec() }) //player weapon kills - await db.selectFrom('kill').select([count('id').as('kills'), max('distance').as('max_distance'), sum('distance').as('total_distance'), 'attacker_id', 'cause_of_death', 'server']).groupBy(['attacker_id', 'cause_of_death', 'server']).whereRef("attacker_id", '!=', 'victim_id').execute() + await db.selectFrom('kill').select([count('id').as('kills'), max('distance').as('max_distance'), sum('distance').as('total_distance'), 'attacker_id', 'cause_of_death', 'servername', 'host']).groupBy(['attacker_id', 'cause_of_death', 'servername', 'host']).whereRef("attacker_id", '!=', 'victim_id').execute() .then(async (data) => { let transaction = client.multi() - await Promise.all(data.map(async ({ kills, max_distance, total_distance, attacker_id, cause_of_death, server }) => { - await processData(genPrefix.playerWeapons({ attacker_id, cause_of_death, server }), { kills, max_distance, total_distance }, transaction) - await processData(genPrefix.weaponPlayers({ attacker_id, cause_of_death, server }), { kills, max_distance, total_distance }, transaction) + await Promise.all(data.map(async ({ kills, max_distance, total_distance, attacker_id, cause_of_death, servername, host }) => { + servername = servername.replace(/[^a-z0-9]/gi, '') + await processData(genPrefix.playerWeapons({ attacker_id, cause_of_death, server: `${host}.${servername}` }), { kills, max_distance, total_distance }, transaction) + await processData(genPrefix.weaponPlayers({ attacker_id, cause_of_death, server: `${host}.${servername}` }), { kills, max_distance, total_distance }, transaction) })) return transaction.exec() }) //player weapon deaths - await db.selectFrom('kill').select([count('id').as('kills'), 'victim_id', 'cause_of_death', 'server']).groupBy(['victim_id', 'cause_of_death', 'server']).execute() + await db.selectFrom('kill').select([count('id').as('kills'), 'victim_id', 'cause_of_death', 'servername', 'host']).groupBy(['victim_id', 'cause_of_death', 'servername', 'host']).execute() .then(async (data) => { let transaction = client.multi() - await Promise.all(data.map(async ({ kills, victim_id, cause_of_death, server }) => { - transaction.json.set('kills', genPrefix.weaponPlayers({ attacker_id: victim_id, cause_of_death, server }) + ".deaths", Number(kills)) + await Promise.all(data.map(async ({ kills, victim_id, cause_of_death, servername, host }) => { + servername = servername.replace(/[^a-z0-9]/gi, '') + transaction.json.set('kills', genPrefix.weaponPlayers({ attacker_id: victim_id, cause_of_death, server: `${host}.${servername}` }) + ".deaths", Number(kills)) })) return transaction.exec() }) diff --git a/src/server/register.ts b/src/server/register.ts deleted file mode 100644 index d57f994..0000000 --- a/src/server/register.ts +++ /dev/null @@ -1,90 +0,0 @@ -import { Router } from 'express' -import { GetRequest, validateErrors } from '../common' -import { FindServer, CreateServer } from '../db/db' -import { body, validationResult } from 'express-validator' - -const verificationString = 'I am a northstar server!' -const masterServerURL = 'https://northstar.tf' - -const router = Router() - -const hostsCount: { [id: string]: number } = {} -const hostsTimeout: { [id: string]: NodeJS.Timeout } = {} - -//Very simple rate limiting. max 2 registers per IP every 5 mins. Maybe 2 is a bit few ? -router.post('/register', (req, res, next) => { - let ip = - req.header('x-forwarded-for') || req.socket.remoteAddress || 'undefined' - if (hostsCount[ip] > 2) { - return res.status(429).json({ - error: - 'too many requests. Please wait 5 minutes before requesting a register again.' - }) - } - clearTimeout(hostsTimeout[ip]) - hostsCount[ip] = hostsCount[ip] ?? (hostsCount[ip] + 1) | 1 - hostsTimeout[ip] = setTimeout(() => { - hostsCount[ip] = 0 - }, 300000) - - next() -}) - -router.post( - '/register', - body(['name', 'description']).isString().withMessage('must be strings'), - body('auth_port') - .toInt() - .isInt({ min: 1, max: 65535 }) - .withMessage('must be between 1 and 65535'), - validateErrors, - async (req, res) => { - try { - //Check if server name isn't already in database - if (!!(await FindServer({ name: req.body.name }))) { - return res - .status(403) - .json({ error: 'Server already exists in the database' }) - } - - //Check if server is in masterserver's list - const masterServerList = JSON.parse( - await GetRequest(masterServerURL + '/client/servers') - ) as Array - if (!masterServerList.find((e) => e.name == req.body.name)) { - return res - .status(403) - .json({ error: 'Server not listed in masterserver' }) - } - - //Send request to verify server. Not very useful for now, but maybe a future method for auth ? - //Maybe should set a blacklist here for local domain ? - const endpoint = - 'http://' + - (req.header('x-forwarded-for') || req.socket.remoteAddress) + - ':' + - req.body.auth_port + - '/verify' - if ((await GetRequest(endpoint)) != verificationString) { - return res.status(400).json({ - error: "Couldn't reach gameserver at " + endpoint - }) - } - - //send token here - res.status(201).json( - await CreateServer({ - name: req.body.name, - description: req.body.description - }) - ) - } catch (e) { - console.log(e) - return res.status(400).json({ - error: "Server encountered an error, Couldn't register gameserver." - }) - } - } -) - -export default router diff --git a/src/server/server.ts b/src/server/server.ts index 1e5787e..afe4886 100644 --- a/src/server/server.ts +++ b/src/server/server.ts @@ -1,37 +1,32 @@ import { NextFunction, Router } from 'express' import expressBasicAuth from 'express-basic-auth' import { body, header, param } from 'express-validator' -import register from './register' import { CreateKillRecord, CheckServerToken } from '../db/db' import { validateErrors } from '../common' const router = Router() -router.use('/', register) - //auth middleware router.post( - '/:serverId*', + '/*', header('authorization') .exists({ checkFalsy: true }) .withMessage('Missing Authorization Header') .bail() - .contains('Basic') - .withMessage('Authorization Token is not Basic'), + .contains('Bearer') + .withMessage('Authorization Token is not Bearer'), validateErrors, - //Huge mess to retrieve server id from expressBasicAuth. We probably should fix it. - (req, res, next) => { - if (!req) res.sendStatus(500) - return expressBasicAuth({ - authorizeAsync: true, - authorizer: CheckServerToken.bind(req), - unauthorizedResponse: { error: 'invalid credentials' } - })(req as any, res, next) + async (req, res, next) => { + if (!req) return res.sendStatus(500) + if (!req.headers.authorization) return res.sendStatus(403) + const query = await CheckServerToken(req.headers.authorization.split(' ')[1]) + if (!query || !query.id) return res.sendStatus(403) + next() } ) //Route to check auth -router.post('/:serverId', (req, res) => { +router.post('/', (req, res) => { res.sendStatus(200) }) @@ -39,8 +34,8 @@ const serversCount: { [id: string]: number } = {} const serversTimeout: { [id: string]: NodeJS.Timeout } = {} //same rate limiting code as register. max 10 kills per server every 1 sec. should be enough. -router.post('/:serverId/kill', (req, res, next) => { - let serverId = Number(req.query.serverId) +/*router.post('/kill', (req, res, next) => { + let host = Number(req.query.serverId) if (serversCount[serverId] > 2) { return res.status(429).json({ error: 'too many requests. Are players really making that much kills ?' @@ -53,11 +48,10 @@ router.post('/:serverId/kill', (req, res, next) => { serversCount[serverId] = 0 }, 1000) next() -}) +})*/ router.post( - '/:serverId/kill', - param('serverId').exists().toInt().isInt(), + '/kill', body([ 'attacker_current_weapon_mods', 'attacker_weapon_1_mods', @@ -104,6 +98,7 @@ router.post( .withMessage('must be a valid float'), body( [ + 'servername', 'attacker_id', 'victim_id', 'killstat_version', @@ -131,9 +126,15 @@ router.post( min: 0 }), body(['cause_of_death', 'victim_id'], 'mandatory').exists().notEmpty(), + body('servername').customSanitizer(e => e.replace(/[^a-z0-9]/gi, '')), validateErrors, - (req, res) => { + async (req, res) => { + if (!req.headers.authorization) return res.sendStatus(403) + const query = (await CheckServerToken(req.headers.authorization.split(' ')[1])) + if (!query) return + const host = query.id const { + servername, killstat_version, match_id, game_mode, @@ -167,13 +168,10 @@ router.post( cause_of_death, distance } = req.body - if (!req.params.serverId) { - res.status(500).send('serverId cannot be undefined') - return - } CreateKillRecord({ killstat_version, - server: Number(req.params.serverId), + servername, + host, match_id, game_mode, map, @@ -209,16 +207,15 @@ router.post( .then((e) => { res.sendStatus(201) console.log( - `[${Date.now().toLocaleString()}] Kill submitted for server ${ - req.params.serverId - }, ${attacker_name} killed ${victim_name}` + `[${Date.now().toLocaleString()}] Kill submitted for server ${servername}, ${attacker_name} killed ${victim_name}` ) }) .catch((e) => { res.sendStatus(500) console.log({ killstat_version, - server: Number(req.params.serverId), + servername, + host, match_id, game_mode, map, diff --git a/tests/client.test.ts b/tests/client.test.ts index 51f6a27..6f04e57 100644 --- a/tests/client.test.ts +++ b/tests/client.test.ts @@ -16,9 +16,9 @@ describe('client', () => { const request = await fetch("http://127.0.0.1:3000/servers") const data = await request.json() const first = Object.entries(data)[0] - expect(first).toHaveProperty('name') - expect(first).toHaveProperty('id') - expect(first).toHaveProperty('description') + expect(first[1]).toHaveProperty('name') + expect(first[1]).toHaveProperty('id') + expect(first[1]).toHaveProperty('description') }) test('player list', async () => { diff --git a/tests/realtime.test.ts b/tests/realtime.test.ts index 8d71513..cc8e98b 100644 --- a/tests/realtime.test.ts +++ b/tests/realtime.test.ts @@ -14,6 +14,7 @@ let pgClient const data = { attacker_weapon_1_mods: 0, victim_id: '0', + servername: 'testServer', victim_name: 'TestVictim', victim_offhand_weapon_2: 0, attacker_offhand_weapon_3: '0', @@ -67,7 +68,7 @@ beforeAll(async () => { credentials: "same-origin", // include, *same-origin, omit headers: { "Content-Type": "application/json", - 'Authorization': `Basic ${Buffer.from(process.env.SERVERAUTH_ID + ':' + process.env.SERVERAUTH_TOKEN).toString('base64')}` + 'Authorization': `Bearer ${Buffer.from('' + process.env.SERVERAUTH_TOKEN).toString('base64')}` }, body: JSON.stringify(data), // body data type must match "Content-Type" header }); @@ -108,7 +109,7 @@ describe('realtime', () => { credentials: "same-origin", // include, *same-origin, omit headers: { "Content-Type": "application/json", - 'Authorization': `Basic ${Buffer.from(process.env.SERVERAUTH_ID + ':' + process.env.SERVERAUTH_TOKEN).toString('base64')}` + 'Authorization': `Bearer ${Buffer.from('' + process.env.SERVERAUTH_TOKEN).toString('base64')}` }, body: JSON.stringify(data), // body data type must match "Content-Type" header }); diff --git a/tests/server.test.ts b/tests/server.test.ts index e71e44e..7c534f6 100644 --- a/tests/server.test.ts +++ b/tests/server.test.ts @@ -12,12 +12,12 @@ beforeAll(async () => { describe('server', () => { test('server auth prefetch', async () => { - const response = await fetch(`http://127.0.0.1:3001/${process.env.SERVERAUTH_ID}`, { + const response = await fetch(`http://127.0.0.1:3001/`, { method: "POST", // *GET, POST, PUT, DELETE, etc. credentials: "same-origin", // include, *same-origin, omit headers: { "Content-Type": "application/json", - 'Authorization': `Basic ${Buffer.from(process.env.SERVERAUTH_ID + ':' + process.env.SERVERAUTH_TOKEN).toString('base64')}` + 'Authorization': `Bearer ${Buffer.from('' + process.env.SERVERAUTH_TOKEN).toString('base64')}` } }); expect(response.status).toBe(200) @@ -25,6 +25,7 @@ describe('server', () => { test('register a kill', async () => { const data = { + servername: 'testserver', attacker_weapon_1_mods: 0, victim_id: '0', victim_name: 'TestVictim', @@ -60,21 +61,23 @@ describe('server', () => { victim_weapon_3: 'defender', attacker_name: 'TestAttacker' } - const response = await fetch(`http://127.0.0.1:3001/${process.env.SERVERAUTH_ID}/kill`, { + const response = await fetch(`http://127.0.0.1:3001/kill`, { method: "POST", // *GET, POST, PUT, DELETE, etc. credentials: "same-origin", // include, *same-origin, omit headers: { "Content-Type": "application/json", - 'Authorization': `Basic ${Buffer.from(process.env.SERVERAUTH_ID + ':' + process.env.SERVERAUTH_TOKEN).toString('base64')}` + 'Authorization': `Bearer ${Buffer.from(process.env.SERVERAUTH_TOKEN + '').toString('base64')}` }, body: JSON.stringify(data), // body data type must match "Content-Type" header }); + console.log(await response.text()) expect(response.status).toBe(201) }) test('register a kill with missing data', async () => { const data = { + servername: 'testserver', attacker_weapon_1_mods: NaN, victim_id: '0', victim_name: 'TestVictim', @@ -110,12 +113,12 @@ describe('server', () => { victim_weapon_3: 'arc_launcher', attacker_name: 'TestAttacker' } - const response = await fetch(`http://127.0.0.1:3001/${process.env.SERVERAUTH_ID}/kill`, { + const response = await fetch(`http://127.0.0.1:3001/kill`, { method: "POST", // *GET, POST, PUT, DELETE, etc. credentials: "same-origin", // include, *same-origin, omit headers: { "Content-Type": "application/json", - 'Authorization': `Basic ${Buffer.from(process.env.SERVERAUTH_ID + ':' + process.env.SERVERAUTH_TOKEN).toString('base64')}` + 'Authorization': `Bearer ${Buffer.from(process.env.SERVERAUTH_TOKEN + '').toString('base64')}` }, body: JSON.stringify(data), // body data type must match "Content-Type" header }); From b609d7646e2964f7e3faf789228426226dfb16bc Mon Sep 17 00:00:00 2001 From: legonzaur Date: Thu, 20 Apr 2023 10:05:29 +0200 Subject: [PATCH 5/9] alternate data processing --- package.json | 1 - src/cache/cacheUtils.ts | 97 ------- src/cache/redis.ts | 21 -- src/client/client.ts | 133 +++++----- src/clientMain.ts | 18 +- .../2023-04-09-02 server auth patch.ts | 18 +- .../2023-04-19-01 last aggregate.ts | 46 ++++ src/process/onKill.ts | 148 ++++------- src/process/process.ts | 241 +++++------------- src/processMain.ts | 14 - src/server/server.ts | 5 +- tests/client.test.ts | 26 +- tests/realtime.test.ts | 10 +- 13 files changed, 264 insertions(+), 514 deletions(-) delete mode 100644 src/cache/cacheUtils.ts delete mode 100644 src/cache/redis.ts create mode 100644 src/db/migrations/2023-04-19-01 last aggregate.ts delete mode 100644 src/processMain.ts diff --git a/package.json b/package.json index 803e0a9..1b1a924 100644 --- a/package.json +++ b/package.json @@ -33,7 +33,6 @@ "build": "npx tsc", "startServer": "node out/serverMain.js", "startClient": "node out/clientMain.js", - "startProcess": "node out/processMain.js", "documentation": "redocly build-docs .\\docs\\v1.yml --output=docs\\index.html", "test": "jest --runInBand" } diff --git a/src/cache/cacheUtils.ts b/src/cache/cacheUtils.ts deleted file mode 100644 index 0dd17d4..0000000 --- a/src/cache/cacheUtils.ts +++ /dev/null @@ -1,97 +0,0 @@ -import cache from './redis' -/** - * Shows the data for one weapon - * @param weapon - * @param server - * @param player - * @returns - */ -export async function getWeaponReport( - weapon: string, - server?: number, - player?: string -) { - const serverPrefix = !isNaN(Number(server?.toString())) - ? `servers:${server}:` - : '' - const playerPrefix = player ? `players:${player}:` : '' - const cacheLocation = serverPrefix + playerPrefix + `weapons:` + weapon - const data = await cache.HGETALL(cacheLocation) - delete data.last_entry - return data -} - -export function getWeaponSet(server?: number, player?: string) { - const serverPrefix = !isNaN(Number(server?.toString())) - ? `servers:${server}:` - : '' - const playerPrefix = player ? `players:${player}:` : '' - const cacheLocation = serverPrefix + playerPrefix + `weapons` - return cache.SMEMBERS(cacheLocation) -} - -/** - * Show the processed list of weapons - * @param server - * @param player - * @returns string - */ -export async function getWeaponList(server?: string, player?: string) { - const serverPrefix = !isNaN(Number(server?.toString())) - ? `servers:${server}:` - : '' - const playerPrefix = player ? `players:${player}:` : '' - return JSON.parse( - (await cache.GET(serverPrefix + playerPrefix + `weapons:processedList`)) || - '{}' - ) -} - -/** - * Show processed data for a player - * @param player - * @param server - * @param weapon - * @returns - */ -export async function getPlayerReport( - player: string, - server?: number, - weapon?: string -) { - const serverPrefix = !isNaN(Number(server?.toString())) - ? `servers:${server}:` - : '' - const weaponPrefix = weapon ? `weapons:${weapon}:` : '' - const cacheLocation = serverPrefix + weaponPrefix + `players:` + player - const data = await cache.HGETALL(cacheLocation) - delete data.last_entry - return data -} - -/** - * Shows processed list of players - * @param server - * @param weapon - * @returns string - */ -export async function getPlayerList(server?: number, weapon?: string) { - const serverPrefix = !isNaN(Number(server?.toString())) - ? `servers:${server}:` - : '' - const weaponPrefix = weapon ? `weapons:${weapon}:` : '' - - return JSON.parse( - (await cache.GET(serverPrefix + weaponPrefix + `players:processedList`)) || - '{}' - ) -} - -export function getPlayerSet(server?: number, weapon?: string) { - const serverPrefix = !isNaN(Number(server?.toString())) - ? `servers:${server}:` - : '' - const weaponPrefix = weapon ? `weapons:${weapon}:` : '' - const cacheLocation = serverPrefix + weaponPrefix + `players` - return cache.SMEMBERS(cacheLocation) -} diff --git a/src/cache/redis.ts b/src/cache/redis.ts deleted file mode 100644 index 30e91df..0000000 --- a/src/cache/redis.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { createClient } from 'redis' - -import * as dotenv from 'dotenv' -dotenv.config() - -const client = createClient({ - url: process.env.REDIS_URL, - username: process.env.REDIS_USER, - password: process.env.REDIS_PASSWORD -}) - -client.on('error', (err) => console.log('Redis Client Error', err)) - -export function cacheReady() { - return client.connect() -} -export default client - -//await client.set('key', 'value'); -//const value = await client.get('key'); -//await client.disconnect(); diff --git a/src/client/client.ts b/src/client/client.ts index 90b722f..e4819cd 100644 --- a/src/client/client.ts +++ b/src/client/client.ts @@ -1,86 +1,81 @@ import { Router } from 'express' import { param, query } from 'express-validator' import { validateErrors } from '../common' -import db from '../db/db' -import client from '../cache/redis' +import { allData } from '../process/process' + const router = Router() //timeout middleware ? router.get('/*', (req, res, next) => { next() }) -router.get( - '/weapons/:weaponId', - param(['weaponId']).exists().isString(), - query(['player', 'server']).optional().toInt().isInt(), - validateErrors, - async (req, res) => { - let path = '' - if (req.query.server) path = path + `servers.${req.query.server}.` - if (req.query.player) path = path + `players.${req.query.player}.` - path = path + `weapons.${req.params.weaponId}` - if (await client.json.type('kills', path)) return res.status(200).send(await client.json.get('kills', { path })) - res.status(404).send() - } -) +function filters(e: any, query: any) { + return ( + (query.player ? query.player == e.attacker_id : true) && + (query.server ? query.server == e.servername : true) && + (query.host ? Number(query.host) == e.host : true) && + (query.map ? query.map == e.map : true) && + (query.weapon ? query.weapon == e.cause_of_death : true) && + (query.gamemode ? query.gamemode == e.game_mode : true) + ) +} router.get( - '/weapons/', - query(['player', 'server']).optional().toInt().isInt(), + '/:dataType', + param('dataType') + .custom( + (e) => e == 'weapons' || e == 'players' || e == 'maps' || e == 'servers' + ) + .withMessage('Only weapons, players, maps or servers are valid paths'), + query(['player', 'host']).optional().toInt().isInt(), + query(['server', 'map', 'weapon', 'gamemode']).optional().isString(), validateErrors, - async (req, res) => { - let path = '' - if (req.query.server) path = path + `servers.${req.query.server}.` - if (req.query.player) path = path + `players.${req.query.player}.` - path = path + `weapons` - if (await client.json.type('kills', path)) { - const data = await client.json.get('kills', { path: `weapons` }) as { [key: number]: any } - return res.status(200).send(Object.fromEntries(Object.entries(data).map(([key, value]) => { return [key, { total_distance: value.total_distance, max_distance: value.max_distance, kills: value.kills, deaths: value.deaths }] }))) + (req, res) => { + const data: { + [key: string]: { + deaths: number + kills: number + max_distance: number + total_distance: number + } + } = {} + let index: 'cause_of_death' | 'attacker_id' | 'map' | 'servername' + switch (req.params.dataType) { + case 'weapons': + index = 'cause_of_death' + break + case 'players': + index = 'attacker_id' + break + case 'maps': + index = 'map' + break + case 'servers': + index = 'servername' + break + default: + return res.status(400).send() } - res.status(404).send() + allData + .filter((e) => filters(e, req.query)) + .forEach((e) => { + if (!data[e[index]]) + data[e[index]] = { + deaths: 0, + kills: 0, + max_distance: 0, + total_distance: 0 + } + data[e[index]].deaths += Number(e.deaths) + data[e[index]].kills += Number(e.kills) + data[e[index]].total_distance += Number(e.total_distance) + data[e[index]].max_distance = Math.max( + data[e[index]].max_distance, + Number(e.max_distance) + ) + }) + res.status(200).send(data) } ) -router.get( - '/players/:playerId', - param(['playerId']).exists().toInt().isInt(), - query(['server']).optional().toInt().isInt(), - query('weapon').optional().isString(), - validateErrors, - async (req, res) => { - let path = '' - if (req.query.server) path = path + `servers.${req.query.server}.` - if (req.query.weapon) path = path + `weapons.${req.query.weapon}.` - path = path + `players.${req.params.playerId}` - if (await client.json.type('kills', path)) return res.status(200).send(await client.json.get('kills', { path })) - res.status(404).send() - } -) - -router.get( - '/players/', - query(['server']).optional().toInt().isInt(), - query('weapon').optional().isString(), - validateErrors, - async (req, res) => { - let path = '' - if (req.query.server) path = path + `servers.${req.query.server}.` - if (req.query.weapon) path = path + `weapons.${req.query.weapon}.` - path = path + `players` - if (await client.json.type('kills', path)) { - const data = await client.json.get('kills', { path: `players` }) as { [key: number]: any } - return res.status(200).send(Object.fromEntries(Object.entries(data).map(([key, value]) => { return [key, { total_distance: value.total_distance, max_distance: value.max_distance, kills: value.kills, deaths: value.deaths }] }))) - } - res.status(404).send() - } -) - -//router.get('/maps/', (req, res, next) => {}) - -router.get('/servers/', async (req, res) => { - const data = await client.json.get('kills', { path: `servers` }) as { [key: number]: any } - return res.status(200).send(Object.fromEntries(Object.entries(data).map(([key, value]) => { return [key, value.data] }))) -}) -//router.get('/servers/:serverId/', (req, res, next) => {}) - export default router diff --git a/src/clientMain.ts b/src/clientMain.ts index f51f21f..59fb734 100644 --- a/src/clientMain.ts +++ b/src/clientMain.ts @@ -3,8 +3,8 @@ dotenv.config() import express from 'express' import cors from 'cors' import client from './client/client' -import db, { dbReady } from './db/db' -import cache, { cacheReady } from './cache/redis' +import { dbReady } from './db/db' +import processAll from './process/process' const app = express() const port = 3000 @@ -19,13 +19,11 @@ app.get('/', (req, res) => { app.use('/', client) export default - new Promise((resolve, reject) => { - dbReady().then((e) => { - cacheReady().then((e) => { - const listenServer = app.listen(port, '0.0.0.0', () => { - console.log(`Tone client api listening on port ${port}`) - resolve(listenServer) - }) - }) + new Promise(async (resolve, reject) => { + await dbReady() + await processAll() + const listenServer = app.listen(port, '0.0.0.0', () => { + console.log(`Tone client api listening on port ${port}`) + resolve(listenServer) }) }) \ No newline at end of file diff --git a/src/db/migrations/2023-04-09-02 server auth patch.ts b/src/db/migrations/2023-04-09-02 server auth patch.ts index 41038cd..734bce5 100644 --- a/src/db/migrations/2023-04-09-02 server auth patch.ts +++ b/src/db/migrations/2023-04-09-02 server auth patch.ts @@ -11,20 +11,26 @@ const pgClient = new Client({ export async function up(db: Kysely): Promise { await pgClient.connect() await pgClient.query('ALTER TABLE kill ADD servername character varying NULL;') + await pgClient.query('ALTER TABLE kill ADD host integer NULL;') await pgClient.query('UPDATE kill SET servername = server.name FROM server WHERE server.id = kill.server;') await pgClient.query('ALTER TABLE kill ALTER COLUMN servername SET NOT NULL;') await pgClient.query('ALTER TABLE kill DROP COLUMN server;') - await pgClient.query('ALTER TABLE server RENAME TO hoster') - await pgClient.query('ALTER TABLE hoster DROP COLUMN description') - await pgClient.query('ALTER TABLE kill ADD COLUMN host integer NULL') + //await pgClient.query('ALTER TABLE server RENAME TO hoster') + //await pgClient.query('ALTER TABLE hoster DROP COLUMN description') + //await pgClient.query('ALTER TABLE kill ADD COLUMN host integer NULL') + await pgClient.query('CREATE TABLE host (name character varying, token character varying)') + //Update the hosts column once the hoster table is manually updated + await pgClient.end() } export async function down(db: Kysely): Promise { - await pgClient.query('ALTER TABLE kill DROP COLUMN host') - await pgClient.query('ALTER TABLE hoster add description character varying NULL') - await pgClient.query('ALTER TABLE hoster RENAME TO server') + await pgClient.connect() + //await pgClient.query('ALTER TABLE kill DROP COLUMN host') + //await pgClient.query('ALTER TABLE hoster add description character varying NULL') + //await pgClient.query('ALTER TABLE hoster RENAME TO server') + await pgClient.query('DROP TABLE host') await pgClient.query(`ALTER TABLE kill ADD server integer NULL;`) await pgClient.query('UPDATE kill SET server = server.id FROM server where server.name = kill.servername;') diff --git a/src/db/migrations/2023-04-19-01 last aggregate.ts b/src/db/migrations/2023-04-19-01 last aggregate.ts new file mode 100644 index 0000000..d410198 --- /dev/null +++ b/src/db/migrations/2023-04-19-01 last aggregate.ts @@ -0,0 +1,46 @@ +import { Kysely, sql } from 'kysely' +import { Client } from 'pg' + +const pgClient = new Client({ + host: process.env.POSTGRES_HOST, + database: process.env.POSTGRES_DATABASE, + user: process.env.POSTGRES_USER, + password: process.env.POSTGRES_PASSWORD +}) + +export async function up(db: Kysely): Promise { + await pgClient.connect() + await pgClient.query(`CREATE OR REPLACE FUNCTION public.first_agg (anyelement, anyelement) + RETURNS anyelement + LANGUAGE sql IMMUTABLE STRICT PARALLEL SAFE AS +'SELECT $1';`) + + await pgClient.query(`CREATE AGGREGATE public.first (anyelement) ( + SFUNC = public.first_agg + , STYPE = anyelement + , PARALLEL = safe + );`) + + await pgClient.query(`CREATE OR REPLACE FUNCTION public.last_agg (anyelement, anyelement) + RETURNS anyelement + LANGUAGE sql IMMUTABLE STRICT PARALLEL SAFE AS +'SELECT $2';`) + + + await pgClient.query(`CREATE AGGREGATE public.last (anyelement) ( + SFUNC = public.last_agg + , STYPE = anyelement + , PARALLEL = safe + );`) + //Update the hosts column once the hoster table is manually updated + await pgClient.end() +} + +export async function down(db: Kysely): Promise { + await pgClient.connect() + pgClient.query('DROP AGGREGATE public.last') + pgClient.query('DROP FUNCTION public.last_agg') + pgClient.query('DROP AGGREGATE public.first') + pgClient.query('DROP AGGREGATE public.first_agg') + await pgClient.end() +} diff --git a/src/process/onKill.ts b/src/process/onKill.ts index 8d51722..5ef0387 100644 --- a/src/process/onKill.ts +++ b/src/process/onKill.ts @@ -1,6 +1,5 @@ import { Client } from 'pg' -import client from '../cache/redis' -import { genPrefix } from './process' +import { allData } from './process' const pgClient = new Client({ host: process.env.POSTGRES_HOST, @@ -13,98 +12,63 @@ export default async function listenKills() { await pgClient.connect() await pgClient.query('LISTEN new_kill') - pgClient.on('notification', async (data) => { + pgClient.on('notification', (data) => { if (!data.payload) return const payload = JSON.parse(data.payload) - await updateGlobal(payload) + let killEntry = allData.find( + (e) => + e.attacker_id == payload.attacker_id && + e.cause_of_death == payload.cause_of_death && + e.game_mode == payload.game_mode && + e.host == payload.host && + e.map == payload.map && + e.servername == payload.servername + ) + let deathEntry = allData.find( + (e) => + e.attacker_id == payload.victim_id && + e.cause_of_death == payload.cause_of_death && + e.game_mode == payload.game_mode && + e.host == payload.host && + e.map == payload.map && + e.servername == payload.servername + ) + if (!killEntry) { + killEntry = { + kills: 0, + deaths: 0, + max_distance: 0, + total_distance: 0, + attacker_id: payload.attacker_id, + cause_of_death: payload.cause_of_death, + game_mode: payload.game_mode, + host: payload.host, + map: payload.map, + servername: payload.servername + } + allData.push(killEntry) + } + + if (!deathEntry) { + deathEntry = { + kills: 0, + deaths: 0, + max_distance: 0, + total_distance: 0, + attacker_id: payload.victim_id, + cause_of_death: payload.victim_current_weapon, + game_mode: payload.game_mode, + host: payload.host, + map: payload.map, + servername: payload.servername + } + allData.push(deathEntry) + } + + killEntry.kills++ + killEntry.total_distance += payload.distance + killEntry.max_distance = Math.max(killEntry.max_distance, payload.distance) + deathEntry.deaths++ }) return pgClient } - - -async function updateGlobal({ cause_of_death, distance, attacker_id, victim_id, servername, host }: { cause_of_death: string, distance: number, attacker_id: string, victim_id: string, servername: string, host: number }) { - servername = servername.replace(/[^a-z0-9]/gi, '') - const promises: Promise[] = [] - - if (!await client.json.type('kills')) await client.json.set('kills', '', { data: {}, weapons: {}, servers: {}, players: {} }) - - if (!await client.json.type('kills', genPrefix.weapon({ cause_of_death }))) await client.json.set('kills', genPrefix.weapon({ cause_of_death }), { players: {}, max_distance: 0, kills: 0, total_distance: 0 }) - if (!await client.json.type('kills', genPrefix.player({ attacker_id }))) await client.json.set('kills', genPrefix.player({ attacker_id }), { weapons: {}, max_distance: 0, kills: 0, deaths: 0, total_distance: 0 }) - if (!await client.json.type('kills', `servers.${host}`)) await client.json.set('kills', `servers.${host}`, {}) - if (!await client.json.type('kills', `servers.${host}.${servername}`)) await client.json.set('kills', `servers.${host}.${servername}`, { data: {}, weapons: {}, players: {}, max_distance: 0, kills: 0, total_distance: 0 }) - if (!await client.json.type('kills', genPrefix.weapon({ cause_of_death, server: `${host}.${servername}` }))) await client.json.set('kills', genPrefix.weapon({ cause_of_death, server: `${host}.${servername}` }), { players: {}, max_distance: 0, kills: 0, total_distance: 0 }) - if (!await client.json.type('kills', genPrefix.player({ attacker_id, server: `${host}.${servername}` }))) await client.json.set('kills', genPrefix.player({ attacker_id, server: `${host}.${servername}` }), { weapons: {}, max_distance: 0, kills: 0, deaths: 0, total_distance: 0 }) - - if (!await client.json.type('kills', genPrefix.player({ attacker_id: victim_id, server: `${host}.${servername}` }))) await client.json.set('kills', genPrefix.player({ attacker_id: victim_id, server: `${host}.${servername}` }), { weapons: {}, max_distance: 0, kills: 0, deaths: 0, total_distance: 0 }) - if (!await client.json.type('kills', genPrefix.player({ attacker_id: victim_id }))) await client.json.set('kills', genPrefix.player({ attacker_id: victim_id }), { weapons: {}, max_distance: 0, kills: 0, deaths: 0, total_distance: 0 }) - - if (!await client.json.type('kills', genPrefix.weaponPlayers({ attacker_id: victim_id, cause_of_death }))) await client.json.set('kills', genPrefix.weaponPlayers({ attacker_id: victim_id, cause_of_death }), { max_distance: 0, kills: 0, deaths: 0, total_distance: 0 }) - if (!await client.json.type('kills', genPrefix.playerWeapons({ attacker_id: victim_id, cause_of_death }))) await client.json.set('kills', genPrefix.playerWeapons({ attacker_id: victim_id, cause_of_death }), { max_distance: 0, kills: 0, total_distance: 0 }) - - if (!await client.json.type('kills', genPrefix.weaponPlayers({ attacker_id: victim_id, cause_of_death, server: `${host}.${servername}` }))) await client.json.set('kills', genPrefix.weaponPlayers({ attacker_id: victim_id, cause_of_death, server: `${host}.${servername}` }), { max_distance: 0, kills: 0, deaths: 0, total_distance: 0 }) - if (!await client.json.type('kills', genPrefix.playerWeapons({ attacker_id: victim_id, cause_of_death, server: `${host}.${servername}` }))) await client.json.set('kills', genPrefix.playerWeapons({ attacker_id: victim_id, cause_of_death, server: `${host}.${servername}` }), { max_distance: 0, kills: 0, total_distance: 0 }) - - promises.push(updatePath(`data`, { distance })) - //servers.1 - promises.push(updatePath(`servers.${host}.${servername}`, { distance }).then(() => Promise.all([ - //servers.1.players.123456789 - updatePath(`servers.${host}.${servername}.players.${attacker_id}`, { distance }).then(async () => { - if (!await client.json.type('kills', `servers.${host}.${servername}.players.${victim_id}.deaths`)) await client.json.set('kills', `servers.${host}.${servername}.players.${victim_id}.deaths`, 0) - await client.json.numIncrBy('kills', `servers.${host}.${servername}.players.${victim_id}.deaths`, 1) - //servers.1.players.123456789.weapons.epg - await updatePath(`servers.${host}.${servername}.players.${attacker_id}.weapons.${cause_of_death}`, { distance }) - } - - ), - //servers.1.weapons.epg - updatePath(`servers.${host}.${servername}.weapons.${cause_of_death}`, { distance }).then(async () => { - //servers.1.weapons.epg.players.123456789 - await updatePath(`servers.${host}.${servername}.weapons.${cause_of_death}.players.${attacker_id}`, { distance }) - if (!await client.json.type('kills', `servers.${host}.${servername}.players.${victim_id}.deaths`)) await client.json.set('kills', `servers.${host}.${servername}.weapons.${cause_of_death}.players.${victim_id}`, 0) - await client.json.numIncrBy('kills', `servers.${host}.${servername}.weapons.${cause_of_death}.players.${victim_id}.deaths`, 1) - } - - )]) - )) - - - //players.123456789 - promises.push(updatePath(`players.${attacker_id}`, { distance }).then(async () => { - if (!await client.json.type('kills', `players.${victim_id}.deaths`)) await client.json.set('kills', `players.${victim_id}.deaths`, 0) - await client.json.numIncrBy('kills', `players.${victim_id}.deaths`, 1) - //players.123456789.weapons.epg - await updatePath(`players.${attacker_id}.weapons.${cause_of_death}`, { distance }) - })) - - - //weapons.epg - promises.push(updatePath(`weapons.${cause_of_death}`, { distance }).then(async () => { - //weapons.epg.players.123456789 - await updatePath(`weapons.${cause_of_death}.players.${attacker_id}`, { distance }) - if (!await client.json.type('kills', `weapons.${cause_of_death}.players.${victim_id}.deaths`)) await client.json.set('kills', `weapons.${cause_of_death}.players.${victim_id}.deaths`, 0) - await client.json.numIncrBy('kills', `weapons.${cause_of_death}.players.${victim_id}.deaths`, 1) - } - )) - - - await Promise.all(promises) -} - -async function updatePath(path: string, { distance }: { distance: number }) { - await setupPath(path) - console.log(path + '.max_distance', await client.json.type('kills', path + '.max_distance')) - console.log(path, await client.json.type('kills', path)) - if (!await client.json.type('kills', path)) await client.json.set('kills', path, {}) - let transaction = client.multi() - transaction = transaction.json.numIncrBy('kills', `${path}.total_distance`, distance) - let max_distance = Number(await client.json.get('kills', { path: `${path}.max_distance` })) - if (isNaN(max_distance) || max_distance < distance) transaction = transaction.json.set('kills', `${path}.max_distance`, distance) - transaction = transaction.json.numIncrBy('kills', `${path}.kills`, 1) - await transaction.exec() -} - -async function setupPath(path: string) { - if (!await client.json.type('kills', path + '.kills')) await client.json.set('kills', path + '.kills', 0) - if (!await client.json.type('kills', path + '.total_distance')) await client.json.set('kills', path + '.total_distance', 0) - if (!await client.json.type('kills', path + '.max_distance')) await client.json.set('kills', path + '.max_distance', 0) -} \ No newline at end of file diff --git a/src/process/process.ts b/src/process/process.ts index 4b4440c..ea5bbd8 100644 --- a/src/process/process.ts +++ b/src/process/process.ts @@ -1,35 +1,19 @@ import db from '../db/db' -const { count, max, sum } = db.fn -import client from '../cache/redis' +const { count, max, sum, coalesce } = db.fn +import { sql } from 'kysely' -export const genPrefix = { - global: ({ server }: { server?: string }) => { - return (server ? `servers.${server}.` : '') + 'data' - }, - weapon: ({ cause_of_death, server }: { cause_of_death: string, server?: string }) => { - return (server ? `servers.${server}.` : '') + `weapons.${cause_of_death}` - }, - player: ({ attacker_id, server }: { attacker_id: string, server?: string }) => { - return (server ? `servers.${server}.` : '') + `players.${attacker_id}` - }, - playerWeapons: ({ cause_of_death, attacker_id, server }: { attacker_id: string, cause_of_death: string, server?: string }) => { - return (server ? `servers.${server}.` : '') + `players.${attacker_id}.weapons.${cause_of_death}` - }, - weaponPlayers: ({ cause_of_death, attacker_id, server }: { attacker_id: string, cause_of_death: string, server?: string }) => { - return (server ? `servers.${server}.` : '') + `weapons.${cause_of_death}.players.${attacker_id}` - }, -} +export let allData: Awaited> /** - * Starts the process of populating the REDIS database globally + * populates allData * @returns */ async function processAll() { console.log('Starting data calculation...') const timeStart = new Date() - if (!await client.json.type('kills')) await client.json.set('kills', '$', { data: {}, weapons: {}, players: {}, servers: {} }) - await Promise.all([processGlobalStats(), processServerStats()]) + allData = await processGlobalStats() + console.log( 'Data calculation finished. Took + ' + Math.abs(new Date().getTime() - timeStart.getTime()) / 1000 + @@ -38,158 +22,71 @@ async function processAll() { if (process.env.ENVIRONMENT == 'production') { return } - setTimeout(processAll, 3600000) } async function processGlobalStats() { - let promises: Promise[] = [] - //Global kills - await db.selectFrom('kill').select([count('id').as('kills'), max('distance').as('max_distance'), sum('distance').as('total_distance')]).execute() - .then(async (data) => { - let transaction = client.multi() - await Promise.all(data.map(async ({ kills, max_distance, total_distance }) => { - await processData(genPrefix.global({}), { total_distance, max_distance, kills }, transaction) - })) - return transaction.exec() - }) - - //Global weapon kill - await db.selectFrom('kill').select([count('id').as('kills'), max('distance').as('max_distance'), sum('distance').as('total_distance'), 'cause_of_death']).groupBy('cause_of_death').execute() - .then(async (data) => { - let transaction = client.multi() - await Promise.all(data.map(async ({ kills, max_distance, total_distance, cause_of_death }) => { - const prefix = genPrefix.weapon({ cause_of_death }) - if (!await client.json.type('kills', prefix)) await client.json.set('kills', prefix, { players: {} }) - await processData(prefix, { kills, max_distance, total_distance }, transaction) - })) - return transaction.exec() - }) - - //Global player kill - await db.selectFrom('kill').select([count('id').as('kills'), max('distance').as('max_distance'), sum('distance').as('total_distance'), 'attacker_id']).groupBy('attacker_id').whereRef("attacker_id", '!=', 'victim_id').execute() - .then(async (data) => { - let transaction = client.multi() - await Promise.all(data.map(async ({ kills, max_distance, total_distance, attacker_id }) => { - const prefix = genPrefix.player({ attacker_id }) - if (!await client.json.type('kills', prefix)) await client.json.set('kills', prefix, { weapons: {} }) - await processData(prefix, { kills, max_distance, total_distance }, transaction) - })) - return transaction.exec() - }) - //Global player deaths - await db.selectFrom('kill').select([count('id').as('kills'), 'victim_id']).groupBy('victim_id').execute() - .then(async (data) => { - let transaction = client.multi() - await Promise.all(data.map(async ({ kills, victim_id }) => { - const prefix = genPrefix.player({ attacker_id: victim_id }) - transaction.json.set('kills', prefix + ".deaths", Number(kills)) - })) - return transaction.exec() - }) - - //Player weapon kills - await db.selectFrom('kill').select([count('id').as('kills'), max('distance').as('max_distance'), sum('distance').as('total_distance'), 'attacker_id', 'cause_of_death']).groupBy(['attacker_id', 'cause_of_death']).whereRef("attacker_id", '!=', 'victim_id').execute() - .then(async (data) => { - let transaction = client.multi() - await Promise.all(data.map(async ({ kills, max_distance, total_distance, attacker_id, cause_of_death }) => { - await processData(genPrefix.playerWeapons({ attacker_id, cause_of_death }), { kills, max_distance, total_distance }, transaction) - await processData(genPrefix.weaponPlayers({ attacker_id, cause_of_death }), { kills, max_distance, total_distance }, transaction) - })) - return transaction.exec() - }) - //player weapon deaths - await db.selectFrom('kill').select([count('id').as('kills'), 'victim_id', 'cause_of_death']).groupBy(['victim_id', 'cause_of_death']).execute() - .then(async (data) => { - let transaction = client.multi() - await Promise.all(data.map(async ({ kills, victim_id, cause_of_death }) => { - transaction.json.set('kills', genPrefix.weaponPlayers({ attacker_id: victim_id, cause_of_death }) + ".deaths", Number(kills)) - })) - return transaction.exec() - }) + return await db + .with('kills', (db) => + db + .selectFrom('kill') + .select([ + count('id').as('kills'), + coalesce( + max('distance'), + sql`0` + ).as('max_distance'), + coalesce(sum('distance'), sql`0`).as( + 'total_distance' + ), + 'attacker_id', + 'cause_of_death', + 'map', + 'game_mode', + 'servername', + 'host' + ]) + .whereRef('attacker_id', '!=', 'victim_id') + .groupBy([ + 'attacker_id', + 'cause_of_death', + 'map', + 'servername', + 'host', + 'game_mode' + ]) + ) + .with('deaths', (db) => + db + .selectFrom('kill') + .select([ + count('id').as('deaths'), + 'victim_id', + 'victim_current_weapon', + 'map', + 'game_mode', + 'servername', + 'host' + ]) + .groupBy([ + 'victim_id', + 'victim_current_weapon', + 'map', + 'servername', + 'host', + 'game_mode' + ]) + ) + .selectFrom('kills') + .leftJoin('deaths', (join) => + join + .onRef('deaths.victim_id', '=', 'kills.attacker_id') + .onRef('deaths.victim_current_weapon', '=', 'kills.cause_of_death') + .onRef('deaths.servername', '=', 'kills.servername') + .onRef('deaths.host', '=', 'kills.host') + .onRef('deaths.game_mode', '=', 'kills.game_mode') + ) + .selectAll('kills') + .select(sql`COALESCE(deaths.deaths, 0)`.as('deaths')) + .execute() } - -async function processServerStats() { - let promises: Promise[] = [] - //Server global kills - await db.selectFrom('kill') - .select([count('id').as('kills'), max('distance').as('max_distance'), sum('distance').as('total_distance'), 'servername', 'host']) - .groupBy(['servername', 'host']).execute().then(async (data) => { - let transaction = client.multi() - const p = data.map(async ({ kills, max_distance, total_distance, servername, host }) => { - servername = servername.replace(/[^a-z0-9]/gi, '') - if (!await client.json.type('kills', `servers.${host}`)) await client.json.set('kills', `servers.${host}`, {}) - if (!await client.json.type('kills', `servers.${host}.${servername}`)) await client.json.set('kills', `servers.${host}.${servername}`, { data: {}, weapons: {}, players: {} }) - await processData(genPrefix.global({ server: `${host}.${servername}` }), { total_distance, max_distance, kills }, transaction) - }) - await Promise.all(p) - return transaction.exec() - }) - - //Server weapon kills - await db.selectFrom('kill') - .select([count('id').as('kills'), max('distance').as('max_distance'), sum('distance').as('total_distance'), 'cause_of_death', 'servername', 'host']).groupBy(['cause_of_death', 'servername', 'host']).execute().then(async (data) => { - let transaction = client.multi() - await Promise.all(data.map(async ({ kills, max_distance, total_distance, cause_of_death, servername, host }) => { - servername = servername.replace(/[^a-z0-9]/gi, '') - const prefix = genPrefix.weapon({ cause_of_death, server: `${host}.${servername}` }) - if (!await client.json.type('kills', prefix)) await client.json.set('kills', prefix, { players: {} }) - await processData(prefix, { kills, max_distance, total_distance }, transaction) - })) - return transaction.exec() - }) - //server player kill - await db.selectFrom('kill').select([count('id').as('kills'), max('distance').as('max_distance'), sum('distance').as('total_distance'), 'attacker_id', 'servername', 'host']).groupBy(['attacker_id', 'servername', 'host']).whereRef("attacker_id", '!=', 'victim_id').execute() - .then(async (data) => { - let transaction = client.multi() - await Promise.all(data.map(async ({ kills, max_distance, total_distance, attacker_id, servername, host }) => { - servername = servername.replace(/[^a-z0-9]/gi, '') - const prefix = genPrefix.player({ attacker_id, server: `${host}.${servername}` }) - if (!await client.json.type('kills', prefix)) await client.json.set('kills', prefix, { weapons: {} }) - await processData(prefix, { kills, max_distance, total_distance }, transaction) - })) - return transaction.exec() - }) - //server player deaths - await db.selectFrom('kill').select([count('id').as('kills'), 'victim_id', 'servername', 'host']).groupBy(['victim_id', 'servername', 'host']).execute() - .then(async (data) => { - let transaction = client.multi() - await Promise.all(data.map(async ({ kills, victim_id, servername, host }) => { - servername = servername.replace(/[^a-z0-9]/gi, '') - const prefix = genPrefix.player({ attacker_id: victim_id, server: `${host}.${servername}` }) - transaction.json.set('kills', prefix + ".deaths", Number(kills)) - })) - return transaction.exec() - }) - - //player weapon kills - await db.selectFrom('kill').select([count('id').as('kills'), max('distance').as('max_distance'), sum('distance').as('total_distance'), 'attacker_id', 'cause_of_death', 'servername', 'host']).groupBy(['attacker_id', 'cause_of_death', 'servername', 'host']).whereRef("attacker_id", '!=', 'victim_id').execute() - .then(async (data) => { - let transaction = client.multi() - await Promise.all(data.map(async ({ kills, max_distance, total_distance, attacker_id, cause_of_death, servername, host }) => { - servername = servername.replace(/[^a-z0-9]/gi, '') - await processData(genPrefix.playerWeapons({ attacker_id, cause_of_death, server: `${host}.${servername}` }), { kills, max_distance, total_distance }, transaction) - await processData(genPrefix.weaponPlayers({ attacker_id, cause_of_death, server: `${host}.${servername}` }), { kills, max_distance, total_distance }, transaction) - })) - return transaction.exec() - }) - //player weapon deaths - await db.selectFrom('kill').select([count('id').as('kills'), 'victim_id', 'cause_of_death', 'servername', 'host']).groupBy(['victim_id', 'cause_of_death', 'servername', 'host']).execute() - .then(async (data) => { - let transaction = client.multi() - await Promise.all(data.map(async ({ kills, victim_id, cause_of_death, servername, host }) => { - servername = servername.replace(/[^a-z0-9]/gi, '') - transaction.json.set('kills', genPrefix.weaponPlayers({ attacker_id: victim_id, cause_of_death, server: `${host}.${servername}` }) + ".deaths", Number(kills)) - })) - return transaction.exec() - }) -} - -async function processData(prefix: string, { total_distance, max_distance, kills }: { total_distance: string | number | bigint, max_distance: number, kills: string | number | bigint }, transaction: ReturnType) { - if (!await client.json.type('kills', prefix)) await client.json.set('kills', prefix, {}) - transaction = transaction.json.set('kills', prefix + ".total_distance", Number(total_distance)) - transaction = transaction.json.set('kills', prefix + ".max_distance", Number(max_distance)) - transaction = transaction.json.set('kills', prefix + ".kills", Number(kills)) - return -} - export default processAll diff --git a/src/processMain.ts b/src/processMain.ts deleted file mode 100644 index 5be088d..0000000 --- a/src/processMain.ts +++ /dev/null @@ -1,14 +0,0 @@ -import * as dotenv from 'dotenv' -dotenv.config() -import { dbReady } from './db/db' -import { cacheReady } from './cache/redis' -import processAll from './process/process' -import listenKills from "./process/onKill" - -async function startup() { - await dbReady() - await cacheReady() - await listenKills() - await processAll() -} -export default startup() \ No newline at end of file diff --git a/src/server/server.ts b/src/server/server.ts index afe4886..6bbaa71 100644 --- a/src/server/server.ts +++ b/src/server/server.ts @@ -1,6 +1,5 @@ -import { NextFunction, Router } from 'express' -import expressBasicAuth from 'express-basic-auth' -import { body, header, param } from 'express-validator' +import { Router } from 'express' +import { body, header } from 'express-validator' import { CreateKillRecord, CheckServerToken } from '../db/db' import { validateErrors } from '../common' diff --git a/tests/client.test.ts b/tests/client.test.ts index 6f04e57..7a057d0 100644 --- a/tests/client.test.ts +++ b/tests/client.test.ts @@ -2,7 +2,6 @@ import { afterAll, beforeAll, describe, expect, test } from '@jest/globals' import clientMain from '../src/clientMain' import * as dotenv from 'dotenv' import db from '../src/db/db' -import cache from '../src/cache/redis' dotenv.config() let listenServer @@ -16,9 +15,9 @@ describe('client', () => { const request = await fetch("http://127.0.0.1:3000/servers") const data = await request.json() const first = Object.entries(data)[0] - expect(first[1]).toHaveProperty('name') - expect(first[1]).toHaveProperty('id') - expect(first[1]).toHaveProperty('description') + expect(first[1]).toHaveProperty('max_distance') + expect(first[1]).toHaveProperty('total_distance') + expect(first[1]).toHaveProperty('kills') }) test('player list', async () => { @@ -39,15 +38,6 @@ describe('client', () => { expect(first[1]).toHaveProperty('kills') }) - test('player list with weapon and server filter', async () => { - const request = await fetch("http://127.0.0.1:3000/players?weapons=sniper&server=1") - const data = await request.json() - const first = Object.entries(data)[0] - expect(first[1]).toHaveProperty('max_distance') - expect(first[1]).toHaveProperty('total_distance') - expect(first[1]).toHaveProperty('kills') - }) - test('weapon list', async () => { const request = await fetch("http://127.0.0.1:3000/weapons") const data = await request.json() @@ -65,21 +55,11 @@ describe('client', () => { expect(first[1]).toHaveProperty('total_distance') expect(first[1]).toHaveProperty('kills') }) - - test('weapon list with player and server filter', async () => { - const request = await fetch("http://127.0.0.1:3000/weapons?players=1005930844007&server=1") - const data = await request.json() - const first = Object.entries(data)[0] - expect(first[1]).toHaveProperty('max_distance') - expect(first[1]).toHaveProperty('total_distance') - expect(first[1]).toHaveProperty('kills') - }) }) afterAll((done) => { listenServer.close(async () => { await db.destroy() - await cache.quit() done() }) }) \ No newline at end of file diff --git a/tests/realtime.test.ts b/tests/realtime.test.ts index cc8e98b..dacce00 100644 --- a/tests/realtime.test.ts +++ b/tests/realtime.test.ts @@ -4,7 +4,6 @@ import serverMain from '../src/serverMain' import listenKills from "../src/process/onKill" import * as dotenv from 'dotenv' import db from '../src/db/db' -import cache from '../src/cache/redis' dotenv.config() let listenClient @@ -62,8 +61,8 @@ beforeAll(async () => { listenClient = await clientMain; listenServer = await serverMain; pgClient = await listenKills() - const yea = waitFor(10000) - const response = await fetch(`http://127.0.0.1:3001/${process.env.SERVERAUTH_ID}/kill`, { + const yea = waitFor(1000) + const response = await fetch(`http://127.0.0.1:3001/kill`, { method: "POST", // *GET, POST, PUT, DELETE, etc. credentials: "same-origin", // include, *same-origin, omit headers: { @@ -103,8 +102,8 @@ describe('realtime', () => { test('update player', async () => { jest.setTimeout(15000) - const yea = waitFor(10000) - const response = await fetch(`http://127.0.0.1:3001/${process.env.SERVERAUTH_ID}/kill`, { + const yea = waitFor(1000) + const response = await fetch(`http://127.0.0.1:3001/kill`, { method: "POST", // *GET, POST, PUT, DELETE, etc. credentials: "same-origin", // include, *same-origin, omit headers: { @@ -138,7 +137,6 @@ afterAll((done) => { listenServer.close(async () => { await pgClient.end() await db.destroy() - await cache.quit() done() }) }) From 924cd692edad39614fcbc518badab6a045d4ba4d Mon Sep 17 00:00:00 2001 From: legonzaur Date: Thu, 20 Apr 2023 10:47:32 +0200 Subject: [PATCH 6/9] sql migration for new server auth scheme --- .../2023-04-09-02 server auth patch.ts | 26 +++++++++---------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/src/db/migrations/2023-04-09-02 server auth patch.ts b/src/db/migrations/2023-04-09-02 server auth patch.ts index 734bce5..ff856d5 100644 --- a/src/db/migrations/2023-04-09-02 server auth patch.ts +++ b/src/db/migrations/2023-04-09-02 server auth patch.ts @@ -10,31 +10,31 @@ const pgClient = new Client({ export async function up(db: Kysely): Promise { await pgClient.connect() - await pgClient.query('ALTER TABLE kill ADD servername character varying NULL;') - await pgClient.query('ALTER TABLE kill ADD host integer NULL;') + await pgClient.query('DROP TABLE IF EXISTS player') + + await pgClient.query('ALTER TABLE kill ADD COLUMN IF NOT EXISTS servername character varying NULL;') + await pgClient.query('ALTER TABLE kill ADD COLUMN IF NOT EXISTS host integer NULL;') await pgClient.query('UPDATE kill SET servername = server.name FROM server WHERE server.id = kill.server;') + + await pgClient.query('CREATE TABLE host (id SERIAL PRIMARY KEY, name character varying, token character varying)') + await pgClient.query('INSERT INTO host (token) SELECT DISTINCT token FROM server') + await pgClient.query('UPDATE kill SET host = host.id FROM host FULL JOIN server ON server.token = host.token WHERE host.token = server.token AND server.id = kill.server;') + await pgClient.query('ALTER TABLE kill ALTER COLUMN servername SET NOT NULL;') await pgClient.query('ALTER TABLE kill DROP COLUMN server;') - - //await pgClient.query('ALTER TABLE server RENAME TO hoster') - //await pgClient.query('ALTER TABLE hoster DROP COLUMN description') - //await pgClient.query('ALTER TABLE kill ADD COLUMN host integer NULL') - await pgClient.query('CREATE TABLE host (name character varying, token character varying)') - + await pgClient.query('DROP TABLE IF EXISTS server') //Update the hosts column once the hoster table is manually updated await pgClient.end() } export async function down(db: Kysely): Promise { await pgClient.connect() - //await pgClient.query('ALTER TABLE kill DROP COLUMN host') - //await pgClient.query('ALTER TABLE hoster add description character varying NULL') - //await pgClient.query('ALTER TABLE hoster RENAME TO server') - await pgClient.query('DROP TABLE host') - + await pgClient.query('ALTER TABLE kill ADD server integer NULL;') await pgClient.query(`ALTER TABLE kill ADD server integer NULL;`) await pgClient.query('UPDATE kill SET server = server.id FROM server where server.name = kill.servername;') await pgClient.query('ALTER TABLE kill ALTER COLUMN server SET NOT NULL;') await pgClient.query('ALTER TABLE kill DROP COLUMN servername;') + await pgClient.query('ALTER TABLE kill DROP COLUMN host;') + await pgClient.query('DROP TABLE IF EXISTS host') await pgClient.end() } From 01be7d8dd13a839e41d71026516d10c3dd33ffb7 Mon Sep 17 00:00:00 2001 From: legonzaur Date: Thu, 20 Apr 2023 11:35:38 +0200 Subject: [PATCH 7/9] add name of user to player route --- src/client/client.ts | 4 ++++ src/process/onKill.ts | 2 ++ src/process/process.ts | 2 ++ 3 files changed, 8 insertions(+) diff --git a/src/client/client.ts b/src/client/client.ts index e4819cd..c11b4d1 100644 --- a/src/client/client.ts +++ b/src/client/client.ts @@ -37,6 +37,8 @@ router.get( kills: number max_distance: number total_distance: number + username?: string + host?: number } } = {} let index: 'cause_of_death' | 'attacker_id' | 'map' | 'servername' @@ -66,6 +68,8 @@ router.get( max_distance: 0, total_distance: 0 } + if (index === 'attacker_id') data[e[index]].username = e.attacker_name + if (index === 'servername') data[e[index]].host = e.host data[e[index]].deaths += Number(e.deaths) data[e[index]].kills += Number(e.kills) data[e[index]].total_distance += Number(e.total_distance) diff --git a/src/process/onKill.ts b/src/process/onKill.ts index 5ef0387..0accb23 100644 --- a/src/process/onKill.ts +++ b/src/process/onKill.ts @@ -39,6 +39,7 @@ export default async function listenKills() { deaths: 0, max_distance: 0, total_distance: 0, + attacker_name: payload.attacker_name, attacker_id: payload.attacker_id, cause_of_death: payload.cause_of_death, game_mode: payload.game_mode, @@ -55,6 +56,7 @@ export default async function listenKills() { deaths: 0, max_distance: 0, total_distance: 0, + attacker_name: payload.victim_name, attacker_id: payload.victim_id, cause_of_death: payload.victim_current_weapon, game_mode: payload.game_mode, diff --git a/src/process/process.ts b/src/process/process.ts index ea5bbd8..132926e 100644 --- a/src/process/process.ts +++ b/src/process/process.ts @@ -39,6 +39,7 @@ async function processGlobalStats() { 'total_distance' ), 'attacker_id', + sql`last(attacker_name)`.as('attacker_name'), 'cause_of_death', 'map', 'game_mode', @@ -62,6 +63,7 @@ async function processGlobalStats() { count('id').as('deaths'), 'victim_id', 'victim_current_weapon', + sql`last(attacker_name)`.as('attacker_name'), 'map', 'game_mode', 'servername', From 96dcf2334b52d4709a38bef196b7c40d00befbc6 Mon Sep 17 00:00:00 2001 From: legonzaur Date: Thu, 20 Apr 2023 11:36:02 +0200 Subject: [PATCH 8/9] add host route --- src/client/client.ts | 9 ++++++++ src/db/db.ts | 53 ++++---------------------------------------- src/db/model.ts | 8 ++++--- 3 files changed, 18 insertions(+), 52 deletions(-) diff --git a/src/client/client.ts b/src/client/client.ts index c11b4d1..4e0f291 100644 --- a/src/client/client.ts +++ b/src/client/client.ts @@ -2,6 +2,7 @@ import { Router } from 'express' import { param, query } from 'express-validator' import { validateErrors } from '../common' import { allData } from '../process/process' +import { getHostList } from '../db/db' const router = Router() //timeout middleware ? @@ -20,6 +21,14 @@ function filters(e: any, query: any) { ) } +router.get('/hosts', + async (req, res) => { + const result = (await getHostList()) + const data: { [key: number]: string } = {} + result.forEach(e => data[Number(e.id)] = e.name) + res.status(200).send(data) + }) + router.get( '/:dataType', param('dataType') diff --git a/src/db/db.ts b/src/db/db.ts index 5bee2f5..00c72e7 100644 --- a/src/db/db.ts +++ b/src/db/db.ts @@ -34,70 +34,25 @@ interface RemoveFromKill { type KillRecord = Omit export async function CreateKillRecord(data: KillRecord) { - //TODO : Handle server foreign key crashes await db .insertInto('kill') .values({ ...data }) .execute() } - -// export async function FindServer({ name }: { name: string }) { -// return await db -// .selectFrom('server') -// .select(['server.name', 'server.description']) -// .where('server.name', '=', name) -// .executeTakeFirst() -// } - -// export async function CreateServer({ -// name, -// description -// }: { -// name: string -// description: string -// }) { -// return await db -// .insertInto('server') -// .values({ name, description }) -// .returning(['id', 'token']) -// .executeTakeFirstOrThrow() -// } - +export async function getHostList() { + return await db.selectFrom("host").select(['id', 'host.name']).execute() +} //tokens are stored in raw... maybe we should use something better in the future //Using callback for express-basic-auth export function CheckServerToken(token: string) { - return db.selectFrom('hoster').select('id') + return db.selectFrom('host').select('id') .where('token', '=', Buffer.from(token, 'base64').toString()) .executeTakeFirst() .then((result) => { return result }) } -/* -async function demo() { - const { id } = await db - .insertInto("person") - .values({ first_name: "Jennifer", gender: "female" }) - .returning("id") - .executeTakeFirstOrThrow(); - await db - .insertInto("pet") - .values({ name: "Catto", species: "cat", owner_id: id }) - .execute(); - - const person = await db - .selectFrom("person") - .innerJoin("pet", "pet.owner_id", "person.id") - .select(["first_name", "pet.name as pet_name"]) - .where("person.id", "=", id) - .executeTakeFirst(); - - if (person) { - person.pet_name; - } -} -*/ export async function dbReady(): Promise> { await migration return db diff --git a/src/db/model.ts b/src/db/model.ts index 47a74c0..21e992d 100644 --- a/src/db/model.ts +++ b/src/db/model.ts @@ -38,13 +38,15 @@ export interface KillTable { cause_of_death: string distance: number } - +/* interface PlayerTable { id: number name: string 'opt-out': boolean hide_TOS: boolean } +*/ + interface WeaponTable { id: string name: string @@ -64,10 +66,10 @@ interface HosterTable { } interface Database { kill: KillTable - player: PlayerTable + //player: PlayerTable weapon: WeaponTable maps: MapTable - hoster: HosterTable + host: HosterTable } export default Database From 4305eb117a65fe0a5516cdbe2ec055566f2950b4 Mon Sep 17 00:00:00 2001 From: legonzaur Date: Thu, 20 Apr 2023 11:48:29 +0200 Subject: [PATCH 9/9] v2 auths tests --- src/server/server.ts | 8 +++++--- tests/server.test.ts | 38 +++++++++++++++++++++++++++++++++++++- 2 files changed, 42 insertions(+), 4 deletions(-) diff --git a/src/server/server.ts b/src/server/server.ts index 6bbaa71..3a05183 100644 --- a/src/server/server.ts +++ b/src/server/server.ts @@ -12,7 +12,7 @@ router.post( .exists({ checkFalsy: true }) .withMessage('Missing Authorization Header') .bail() - .contains('Bearer') + .custom(e => e.split(' ')[0].toLowerCase() == 'bearer') .withMessage('Authorization Token is not Bearer'), validateErrors, async (req, res, next) => { @@ -129,8 +129,10 @@ router.post( validateErrors, async (req, res) => { if (!req.headers.authorization) return res.sendStatus(403) - const query = (await CheckServerToken(req.headers.authorization.split(' ')[1])) - if (!query) return + const headers = req.headers.authorization.split(' ') + if (headers[0].toLowerCase() != "bearer") return res.status(403).send("authorization must be token bearer") + const query = (await CheckServerToken(headers[1])) + if (!query) return res.sendStatus(403) const host = query.id const { servername, diff --git a/tests/server.test.ts b/tests/server.test.ts index 7c534f6..75f4d18 100644 --- a/tests/server.test.ts +++ b/tests/server.test.ts @@ -11,7 +11,43 @@ beforeAll(async () => { }) describe('server', () => { - test('server auth prefetch', async () => { + + test('bad auth prefetch', async () => { + const response = await fetch(`http://127.0.0.1:3001/`, { + method: "POST", // *GET, POST, PUT, DELETE, etc. + credentials: "same-origin", // include, *same-origin, omit + headers: { + "Content-Type": "application/json", + 'Authorization': `Bearere ${Buffer.from('' + process.env.SERVERAUTH_TOKEN).toString('base64')}` + } + }); + expect(response.status).toBe(400) + + const response2 = await fetch(`http://127.0.0.1:3001/`, { + method: "POST", // *GET, POST, PUT, DELETE, etc. + credentials: "same-origin", // include, *same-origin, omit + headers: { + "Content-Type": "application/json", + 'Authorization': `Bearer ${Buffer.from('badtoken').toString('base64')}` + } + }); + expect(response2.status).toBe(403) + }) + + test('bad kill token', async () => { + const response2 = await fetch(`http://127.0.0.1:3001/kill`, { + method: "POST", // *GET, POST, PUT, DELETE, etc. + credentials: "same-origin", // include, *same-origin, omit + headers: { + "Content-Type": "application/json", + 'Authorization': `Bearer ${Buffer.from('badtoken').toString('base64')}` + } + }); + expect(response2.status).toBe(403) + }) + + + test('good auth prefetch', async () => { const response = await fetch(`http://127.0.0.1:3001/`, { method: "POST", // *GET, POST, PUT, DELETE, etc. credentials: "same-origin", // include, *same-origin, omit