From 58818ad5476df6c1ecbe242284f468c6c31a6643 Mon Sep 17 00:00:00 2001 From: Guangcong Luo Date: Sun, 3 Dec 2023 22:44:20 +0000 Subject: [PATCH] Update for new Replays database Catastrophic database failure is always a great time to migrate onto a nicer schema... --- config/config-example.js | 25 ++- package-lock.json | 134 +++++++------- package.json | 4 +- src/actions.ts | 78 ++++---- src/database.ts | 59 ++++-- src/replays.ts | 388 ++++++++++++++++++++++----------------- src/schemas/replays.sql | 35 ++++ src/tables.ts | 76 ++++---- src/test/actions.test.ts | 58 +++--- src/test/replays.test.ts | 224 +++++++++++----------- 10 files changed, 610 insertions(+), 471 deletions(-) create mode 100644 src/schemas/replays.sql diff --git a/config/config-example.js b/config/config-example.js index c079435..a27cc6e 100644 --- a/config/config-example.js +++ b/config/config-example.js @@ -1,4 +1,4 @@ -// MySQL DB settings. +/** For the login and ladder databases */ exports.mysql = { charset: "utf8", database: "ps", @@ -9,6 +9,24 @@ exports.mysql = { prefix: "ntbb_", }; +/** For the replay databases */ +exports.replaysdb = { + charset: "utf8", + database: "ps", + password: "", + host: 'localhost', + user: "root", + socketPath: '', + prefix: "ntbb_", +}; + +/** + * For the friends database + * + * @type {import('pg').PoolConfig | null} + */ +exports.postgres = null; + /** For 2FA verification. */ exports.gapi_clientid = ''; @@ -155,8 +173,3 @@ exports.standings = { "30": "Permaban", "100": "Disabled", }; - -/** - * @type {import('pg').PoolConfig | null} - */ -exports.postgres = null; diff --git a/package-lock.json b/package-lock.json index d499718..1836e17 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,8 +14,8 @@ "bcrypt": "^5.0.1", "eslint-plugin-import": "^2.24.2", "google-auth-library": "^3.1.2", - "mysql2": "^2.3.3", - "pg": "^8.10.0", + "mysql2": "^3.6.5", + "pg": "^8.11.3", "pm2": "^5.1.2", "testcontainers": "^9.1.1" }, @@ -3841,9 +3841,9 @@ } }, "node_modules/long": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/long/-/long-4.0.0.tgz", - "integrity": "sha512-XsP+KhQif4bjX1kbuSiySJFNAehNxgLb6hPRGJ9QsUr8ajHkuXGdrHmFUTUUXhDwVX2R5bY4JNZEwbUiMhV+MA==" + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/long/-/long-5.2.3.tgz", + "integrity": "sha512-lcHwpNoggQTObv5apGNCTdJrO69eHOZMi4BNC+rTLER8iHAqGrUVeLh/irVIM7zTw2bOXA8T6uNPeujwOLg/2Q==" }, "node_modules/lru-cache": { "version": "5.1.1", @@ -4160,16 +4160,16 @@ "integrity": "sha512-nnbWWOkoWyUsTjKrhgD0dcz22mdkSnpYqbEjIm2nhwhuxlSkpywJmBo8h0ZqJdkp73mb90SssHkN4rsRaBAfAA==" }, "node_modules/mysql2": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/mysql2/-/mysql2-2.3.3.tgz", - "integrity": "sha512-wxJUev6LgMSgACDkb/InIFxDprRa6T95+VEoR+xPvtngtccNH2dGjEB/fVZ8yg1gWv1510c9CvXuJHi5zUm0ZA==", + "version": "3.6.5", + "resolved": "https://registry.npmjs.org/mysql2/-/mysql2-3.6.5.tgz", + "integrity": "sha512-pS/KqIb0xlXmtmqEuTvBXTmLoQ5LmAz5NW/r8UyQ1ldvnprNEj3P9GbmuQQ2J0A4LO+ynotGi6TbscPa8OUb+w==", "dependencies": { - "denque": "^2.0.1", + "denque": "^2.1.0", "generate-function": "^2.3.1", "iconv-lite": "^0.6.3", - "long": "^4.0.0", - "lru-cache": "^6.0.0", - "named-placeholders": "^1.1.2", + "long": "^5.2.1", + "lru-cache": "^8.0.0", + "named-placeholders": "^1.1.3", "seq-queue": "^0.0.5", "sqlstring": "^2.3.2" }, @@ -4178,21 +4178,13 @@ } }, "node_modules/mysql2/node_modules/lru-cache": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", - "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", - "dependencies": { - "yallist": "^4.0.0" - }, + "version": "8.0.5", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-8.0.5.tgz", + "integrity": "sha512-MhWWlVnuab1RG5/zMRRcVGXZLCXrZTgfwMikgzCegsPnG62yDQo5JnqKkrK4jO5iKqDAZGItAqN5CtKBCBWRUA==", "engines": { - "node": ">=10" + "node": ">=16.14" } }, - "node_modules/mysql2/node_modules/yallist": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==" - }, "node_modules/named-placeholders": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/named-placeholders/-/named-placeholders-1.1.3.tgz", @@ -4617,14 +4609,14 @@ } }, "node_modules/pg": { - "version": "8.10.0", - "resolved": "https://registry.npmjs.org/pg/-/pg-8.10.0.tgz", - "integrity": "sha512-ke7o7qSTMb47iwzOSaZMfeR7xToFdkE71ifIipOAAaLIM0DYzfOAXlgFFmYUIE2BcJtvnVlGCID84ZzCegE8CQ==", + "version": "8.11.3", + "resolved": "https://registry.npmjs.org/pg/-/pg-8.11.3.tgz", + "integrity": "sha512-+9iuvG8QfaaUrrph+kpF24cXkH1YOOUeArRNYIxq1viYHZagBxrTno7cecY1Fa44tJeZvaoG+Djpkc3JwehN5g==", "dependencies": { "buffer-writer": "2.0.0", "packet-reader": "1.0.0", - "pg-connection-string": "^2.5.0", - "pg-pool": "^3.6.0", + "pg-connection-string": "^2.6.2", + "pg-pool": "^3.6.1", "pg-protocol": "^1.6.0", "pg-types": "^2.1.0", "pgpass": "1.x" @@ -4632,6 +4624,9 @@ "engines": { "node": ">= 8.0.0" }, + "optionalDependencies": { + "pg-cloudflare": "^1.1.1" + }, "peerDependencies": { "pg-native": ">=3.0.1" }, @@ -4641,10 +4636,16 @@ } } }, + "node_modules/pg-cloudflare": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.1.1.tgz", + "integrity": "sha512-xWPagP/4B6BgFO+EKz3JONXv3YDgvkbVrGw2mTo3D6tVDQRh1e7cqVGvyR3BE+eQgAvx1XhW/iEASj4/jCWl3Q==", + "optional": true + }, "node_modules/pg-connection-string": { - "version": "2.5.0", - "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.5.0.tgz", - "integrity": "sha512-r5o/V/ORTA6TmUnyWZR9nCj1klXCO2CEKNRlVuJptZe85QuhFayC7WeMic7ndayT5IRIR0S0xFxFi2ousartlQ==" + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.6.2.tgz", + "integrity": "sha512-ch6OwaeaPYcova4kKZ15sbJ2hKb/VP48ZD2gE7i1J+L4MspCtBMAx8nMgz7bksc7IojCIIWuEhHibSMFH8m8oA==" }, "node_modules/pg-int8": { "version": "1.0.1", @@ -4663,9 +4664,9 @@ } }, "node_modules/pg-pool": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.6.0.tgz", - "integrity": "sha512-clFRf2ksqd+F497kWFyM21tMjeikn60oGDmqMT8UBrynEwVEX/5R5xd2sdvdo1cZCFlguORNpVuqxIj+aK4cfQ==", + "version": "3.6.1", + "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.6.1.tgz", + "integrity": "sha512-jizsIzhkIitxCGfPRzJn1ZdcosIt3pz9Sh3V01fm1vZnbnCMgmGl5wvGGdNN2EL9Rmb0EcFoCkixH4Pu+sP9Og==", "peerDependencies": { "pg": ">=8.0" } @@ -9380,9 +9381,9 @@ } }, "long": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/long/-/long-4.0.0.tgz", - "integrity": "sha512-XsP+KhQif4bjX1kbuSiySJFNAehNxgLb6hPRGJ9QsUr8ajHkuXGdrHmFUTUUXhDwVX2R5bY4JNZEwbUiMhV+MA==" + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/long/-/long-5.2.3.tgz", + "integrity": "sha512-lcHwpNoggQTObv5apGNCTdJrO69eHOZMi4BNC+rTLER8iHAqGrUVeLh/irVIM7zTw2bOXA8T6uNPeujwOLg/2Q==" }, "lru-cache": { "version": "5.1.1", @@ -9632,32 +9633,24 @@ "integrity": "sha512-nnbWWOkoWyUsTjKrhgD0dcz22mdkSnpYqbEjIm2nhwhuxlSkpywJmBo8h0ZqJdkp73mb90SssHkN4rsRaBAfAA==" }, "mysql2": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/mysql2/-/mysql2-2.3.3.tgz", - "integrity": "sha512-wxJUev6LgMSgACDkb/InIFxDprRa6T95+VEoR+xPvtngtccNH2dGjEB/fVZ8yg1gWv1510c9CvXuJHi5zUm0ZA==", + "version": "3.6.5", + "resolved": "https://registry.npmjs.org/mysql2/-/mysql2-3.6.5.tgz", + "integrity": "sha512-pS/KqIb0xlXmtmqEuTvBXTmLoQ5LmAz5NW/r8UyQ1ldvnprNEj3P9GbmuQQ2J0A4LO+ynotGi6TbscPa8OUb+w==", "requires": { - "denque": "^2.0.1", + "denque": "^2.1.0", "generate-function": "^2.3.1", "iconv-lite": "^0.6.3", - "long": "^4.0.0", - "lru-cache": "^6.0.0", - "named-placeholders": "^1.1.2", + "long": "^5.2.1", + "lru-cache": "^8.0.0", + "named-placeholders": "^1.1.3", "seq-queue": "^0.0.5", "sqlstring": "^2.3.2" }, "dependencies": { "lru-cache": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", - "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", - "requires": { - "yallist": "^4.0.0" - } - }, - "yallist": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==" + "version": "8.0.5", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-8.0.5.tgz", + "integrity": "sha512-MhWWlVnuab1RG5/zMRRcVGXZLCXrZTgfwMikgzCegsPnG62yDQo5JnqKkrK4jO5iKqDAZGItAqN5CtKBCBWRUA==" } } }, @@ -9980,23 +9973,30 @@ "dev": true }, "pg": { - "version": "8.10.0", - "resolved": "https://registry.npmjs.org/pg/-/pg-8.10.0.tgz", - "integrity": "sha512-ke7o7qSTMb47iwzOSaZMfeR7xToFdkE71ifIipOAAaLIM0DYzfOAXlgFFmYUIE2BcJtvnVlGCID84ZzCegE8CQ==", + "version": "8.11.3", + "resolved": "https://registry.npmjs.org/pg/-/pg-8.11.3.tgz", + "integrity": "sha512-+9iuvG8QfaaUrrph+kpF24cXkH1YOOUeArRNYIxq1viYHZagBxrTno7cecY1Fa44tJeZvaoG+Djpkc3JwehN5g==", "requires": { "buffer-writer": "2.0.0", "packet-reader": "1.0.0", - "pg-connection-string": "^2.5.0", - "pg-pool": "^3.6.0", + "pg-cloudflare": "^1.1.1", + "pg-connection-string": "^2.6.2", + "pg-pool": "^3.6.1", "pg-protocol": "^1.6.0", "pg-types": "^2.1.0", "pgpass": "1.x" } }, + "pg-cloudflare": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.1.1.tgz", + "integrity": "sha512-xWPagP/4B6BgFO+EKz3JONXv3YDgvkbVrGw2mTo3D6tVDQRh1e7cqVGvyR3BE+eQgAvx1XhW/iEASj4/jCWl3Q==", + "optional": true + }, "pg-connection-string": { - "version": "2.5.0", - "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.5.0.tgz", - "integrity": "sha512-r5o/V/ORTA6TmUnyWZR9nCj1klXCO2CEKNRlVuJptZe85QuhFayC7WeMic7ndayT5IRIR0S0xFxFi2ousartlQ==" + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.6.2.tgz", + "integrity": "sha512-ch6OwaeaPYcova4kKZ15sbJ2hKb/VP48ZD2gE7i1J+L4MspCtBMAx8nMgz7bksc7IojCIIWuEhHibSMFH8m8oA==" }, "pg-int8": { "version": "1.0.1", @@ -10009,9 +10009,9 @@ "integrity": "sha512-BM/Thnrw5jm2kKLE5uJkXqqExRUY/toLHda65XgFTBTFYZyopbKjBe29Ii3RbkvlsMoFwD+tHeGaCjjv0gHlyw==" }, "pg-pool": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.6.0.tgz", - "integrity": "sha512-clFRf2ksqd+F497kWFyM21tMjeikn60oGDmqMT8UBrynEwVEX/5R5xd2sdvdo1cZCFlguORNpVuqxIj+aK4cfQ==", + "version": "3.6.1", + "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.6.1.tgz", + "integrity": "sha512-jizsIzhkIitxCGfPRzJn1ZdcosIt3pz9Sh3V01fm1vZnbnCMgmGl5wvGGdNN2EL9Rmb0EcFoCkixH4Pu+sP9Og==", "requires": {} }, "pg-protocol": { diff --git a/package.json b/package.json index 81090ce..abcb8d3 100644 --- a/package.json +++ b/package.json @@ -19,8 +19,8 @@ "bcrypt": "^5.0.1", "eslint-plugin-import": "^2.24.2", "google-auth-library": "^3.1.2", - "mysql2": "^2.3.3", - "pg": "^8.10.0", + "mysql2": "^3.6.5", + "pg": "^8.11.3", "pm2": "^5.1.2", "testcontainers": "^9.1.1" }, diff --git a/src/actions.ts b/src/actions.ts index 80b8911..128a4ee 100644 --- a/src/actions.ts +++ b/src/actions.ts @@ -172,41 +172,43 @@ export const actions: {[k: string]: QueryHandler} = { }, async prepreplay(params) { - const server = await this.getServer(true); - if (!server) { - // legacy error - return {errorip: this.getIp()}; - } - - const extractedFormatId = /^([a-z0-9]+)-[0-9]+$/.exec(`${params.id}`)?.[1]; - const formatId = /^([a-z0-9]+)$/.exec(`${params.format}`)?.[1]; - if ( - // the server must send all the required values - !params.id || !params.format || !params.loghash || !params.p1 || !params.p2 || - // player usernames cannot be longer than 18 characters - params.p1.length > 18 || params.p2.length > 18 || - // the battle ID must be valid - !extractedFormatId || - // the format from the battle ID must match the format ID - formatId !== extractedFormatId - ) { - return 0; - } - - if (server.id !== Config.mainserver) { - params.id = server.id + '-' + params.id; - } - params.serverid = server.id; - - const result = await Replays.prep(params); - - this.setPrefix(''); // No need for prefix since only usable by server. - return result; + return 'currently unavailable'; + // const server = await this.getServer(true); + // if (!server) { + // // legacy error + // return {errorip: this.getIp()}; + // } + + // const extractedFormatId = /^([a-z0-9]+)-[0-9]+$/.exec(`${params.id}`)?.[1]; + // const formatId = /^([a-z0-9]+)$/.exec(`${params.format}`)?.[1]; + // if ( + // // the server must send all the required values + // !params.id || !params.format || !params.loghash || !params.p1 || !params.p2 || + // // player usernames cannot be longer than 18 characters + // params.p1.length > 18 || params.p2.length > 18 || + // // the battle ID must be valid + // !extractedFormatId || + // // the format from the battle ID must match the format ID + // formatId !== extractedFormatId + // ) { + // return 0; + // } + + // if (server.id !== Config.mainserver) { + // params.id = server.id + '-' + params.id; + // } + // params.serverid = server.id; + + // const result = await Replays.prep(params); + + // this.setPrefix(''); // No need for prefix since only usable by server. + // return result; }, uploadreplay(params) { - this.setHeader('Content-Type', 'text/plain; charset=utf-8'); - return Replays.upload(params, this); + return 'currently unavailable'; + // this.setHeader('Content-Type', 'text/plain; charset=utf-8'); + // return Replays.upload(params, this); }, async invalidatecss() { @@ -715,8 +717,12 @@ export const actions: {[k: string]: QueryHandler} = { if (params.sort && params.sort !== 'rating' && params.sort !== 'date') { throw new ActionError('Sort must be "rating" or "date"'); } + let username = (params.username ||= params.user); + if (!params.username2 && username?.includes(',')) { + [username, params.username2] = username.split(','); + } const search = { - username: toID(params.username || params.user), + username: toID(username), username2: toID(params.username2), format: toID(params.format), page: Number(params.page || '1'), @@ -735,8 +741,12 @@ export const actions: {[k: string]: QueryHandler} = { if (params.sort && params.sort !== 'rating' && params.sort !== 'date') { throw new ActionError('Sort must be "rating" or "date"'); } + let username = (params.username ||= params.user); + if (!params.username2 && username?.includes(',')) { + [username, params.username2] = username.split(','); + } const search = { - username: toID(params.username || params.user), + username: toID(username), username2: toID(params.username2), format: toID(params.format), page: Number(params.page || '1'), diff --git a/src/database.ts b/src/database.ts index 7b4fc53..b4aff50 100644 --- a/src/database.ts +++ b/src/database.ts @@ -10,6 +10,7 @@ import * as mysql from 'mysql2'; import * as pg from 'pg'; export type BasicSQLValue = string | number | null; +// eslint-disable-next-line export type SQLRow = {[k: string]: BasicSQLValue}; export type SQLValue = BasicSQLValue | SQLStatement | PartialOrSQL | BasicSQLValue[] | undefined; @@ -119,6 +120,7 @@ export const connectedDatabases: Database[] = []; export abstract class Database { connection: Pool; prefix: string; + type = ''; constructor(connection: Pool, prefix = '') { this.prefix = prefix; this.connection = connection; @@ -126,6 +128,7 @@ export abstract class Database; + abstract _queryExec(sql: string, values: BasicSQLValue[]): Promise; abstract escapeId(param: string): string; query(sql: SQLStatement): Promise; query(): (strings: TemplateStringsArray, ...rest: SQLValue[]) => Promise; @@ -146,7 +149,12 @@ export abstract class Database Promise; queryExec(sql?: SQLStatement) { if (!sql) return (strings: any, ...rest: any) => this.queryExec(new SQLStatement(strings, rest)); - return this.queryOne(sql); + + const [query, values] = this._resolveSQL(sql); + return this._queryExec(query, values); + } + getTable(name: string, primaryKeyName: keyof Row & string | null = null) { + return new DatabaseTable(this, name, primaryKeyName); } close() { void this.connection.end(); @@ -157,15 +165,17 @@ type PartialOrSQL = { [P in keyof T]?: T[P] | SQLStatement; }; +type OkPacketOf = DB extends Database ? T : never; + // Row extends SQLRow but TS doesn't support closed types so we can't express this -export class DatabaseTable { - db: Database; +export class DatabaseTable { + db: DB; name: string; - primaryKeyName: keyof Row & string; + primaryKeyName: keyof Row & string | null; constructor( - db: Database, + db: DB, name: string, - primaryKeyName: keyof Row & string + primaryKeyName: keyof Row & string | null = null ) { this.db = db; this.name = db.prefix + name; @@ -187,8 +197,8 @@ export class DatabaseTable { queryOne(sql?: SQLStatement) { return this.db.queryOne(sql as any) as any; } - queryExec(sql: SQLStatement): Promise; - queryExec(): (strings: TemplateStringsArray, ...rest: SQLValue[]) => Promise; + queryExec(sql: SQLStatement): Promise>; + queryExec(): (strings: TemplateStringsArray, ...rest: SQLValue[]) => Promise>; queryExec(sql?: SQLStatement) { return this.db.queryExec(sql as any) as any; } @@ -210,22 +220,22 @@ export class DatabaseTable { this.queryOne()`SELECT ${entries} FROM "${this.name}" ${new SQLStatement(strings, rest)} LIMIT 1`; } updateAll(partialRow: PartialOrSQL): - (strings: TemplateStringsArray, ...rest: SQLValue[]) => Promise { + (strings: TemplateStringsArray, ...rest: SQLValue[]) => Promise> { return (strings, ...rest) => this.queryExec()`UPDATE "${this.name}" SET ${partialRow as any} ${new SQLStatement(strings, rest)}`; } updateOne(partialRow: PartialOrSQL): - (strings: TemplateStringsArray, ...rest: SQLValue[]) => Promise { + (strings: TemplateStringsArray, ...rest: SQLValue[]) => Promise> { return (s, ...r) => this.queryExec()`UPDATE "${this.name}" SET ${partialRow as any} ${new SQLStatement(s, r)} LIMIT 1`; } deleteAll(): - (strings: TemplateStringsArray, ...rest: SQLValue[]) => Promise { + (strings: TemplateStringsArray, ...rest: SQLValue[]) => Promise> { return (strings, ...rest) => this.queryExec()`DELETE FROM "${this.name}" ${new SQLStatement(strings, rest)}`; } deleteOne(): - (strings: TemplateStringsArray, ...rest: SQLValue[]) => Promise { + (strings: TemplateStringsArray, ...rest: SQLValue[]) => Promise> { return (strings, ...rest) => this.queryExec()`DELETE FROM "${this.name}" ${new SQLStatement(strings, rest)} LIMIT 1`; } @@ -255,7 +265,17 @@ export class DatabaseTable { throw err; } } + upsert(partialRow: PartialOrSQL, partialUpdate = partialRow, where?: SQLStatement) { + if (this.db.type === 'pg') { + return this.queryExec( + )`INSERT INTO "${this.name}" (${partialRow as any}) ON CONFLICT (${this.primaryKeyName + }) DO UPDATE ${partialUpdate as any} ${where}`; + } + return this.queryExec( + )`INSERT INTO "${this.name}" (${partialRow as any}) ON DUPLICATE KEY UPDATE ${partialUpdate as any} ${where}`; + } set(primaryKey: BasicSQLValue, partialRow: PartialOrSQL, where?: SQLStatement) { + if (!this.primaryKeyName) throw new Error(`Cannot set() without a single-column primary key`); partialRow[this.primaryKeyName] = primaryKey as any; return this.replace(partialRow, where); } @@ -263,17 +283,21 @@ export class DatabaseTable { return this.queryExec()`REPLACE INTO "${this.name}" (${partialRow as SQLValue}) ${where}`; } get(primaryKey: BasicSQLValue, entries?: (keyof Row & string)[] | SQLStatement) { + if (!this.primaryKeyName) throw new Error(`Cannot get() without a single-column primary key`); return this.selectOne(entries)`WHERE "${this.primaryKeyName}" = ${primaryKey}`; } delete(primaryKey: BasicSQLValue) { + if (!this.primaryKeyName) throw new Error(`Cannot delete() without a single-column primary key`); return this.deleteAll()`WHERE "${this.primaryKeyName}" = ${primaryKey} LIMIT 1`; } update(primaryKey: BasicSQLValue, data: PartialOrSQL) { + if (!this.primaryKeyName) throw new Error(`Cannot update() without a single-column primary key`); return this.updateAll(data)`WHERE "${this.primaryKeyName}" = ${primaryKey} LIMIT 1`; } } export class MySQLDatabase extends Database { + override type = 'mysql' as const; constructor(config: mysql.PoolOptions & {prefix?: string}) { const prefix = config.prefix || ""; if (config.prefix) { @@ -313,12 +337,16 @@ export class MySQLDatabase extends Database { }); }); } + override _queryExec(sql: string, values: BasicSQLValue[]): Promise { + return this._query(sql, values); + } override escapeId(id: string) { - return this.connection.escapeId(id); + return mysql.escapeId(id); } } -export class PGDatabase extends Database { +export class PGDatabase extends Database { + override type = 'pg' as const; constructor(config: pg.PoolConfig) { super(new pg.Pool(config)); } @@ -341,6 +369,9 @@ export class PGDatabase extends Database { override _query(query: string, values: BasicSQLValue[]) { return this.connection.query(query, values).then(res => res.rows); } + override _queryExec(query: string, values: BasicSQLValue[]) { + return this.connection.query(query, values).then(res => ({affectedRows: res.rowCount})); + } override escapeId(id: string) { // @ts-expect-error @types/pg really needs to be updated return pg.escapeIdentifier(id); diff --git a/src/replays.ts b/src/replays.ts index f3e7e15..9ce77d6 100644 --- a/src/replays.ts +++ b/src/replays.ts @@ -6,25 +6,22 @@ import {Session} from './user'; import {ActionError, ActionContext} from './server'; import {toID, time, stripNonAscii, md5} from './utils'; -import {prepreplays, replays} from './tables'; +import {replayPrep, replayPlayers, replays} from './tables'; import {Config} from './config-loader'; import {SQL} from './database'; -export interface ReplayData { +// must be a type and not an interface to qualify as an SQLRow +export type ReplayRow = { id: string; - /** player name; starting with ! denotes that player wants the replay private */ - p1: string; - /** player name; starting with ! denotes that player wants the replay private */ - p2: string; format: string; + /** player names delimited by `,`; starting with `!` denotes that player wants the replay private */ + players: string; log: string; inputlog: string | null; uploadtime: number; views: number; - p1id: string; - p2id: string; formatid: string; - rating: number; + rating: number | null; /** * 0 = public * 1 = private (with or without password) @@ -33,134 +30,193 @@ export interface ReplayData { */ private: 0 | 1 | 2 | 3; password: string | null; -} +}; +type Replay = Omit & { + players: string[]; + views?: number; + password?: string | null; +}; export const Replays = new class { readonly passwordCharacters = '0123456789abcdefghijklmnopqrstuvwxyz'; - async prep(params: {[k: string]: unknown}) { - const id = ('' + params.id).toLowerCase().replace(/[^a-z0-9-]+/g, ''); - let isPrivate: 0 | 1 | 2 = params.hidden ? 1 : 0; - if (params.hidden === 2) isPrivate = 2; - let p1 = Session.wordfilter(`${params.p1}`); - let p2 = Session.wordfilter(`${params.p2}`); - if (isPrivate) { - p1 = `!${p1}`; - p2 = `!${p2}`; - } - const {loghash, format} = params as Record; - let rating = Number(params.rating); - if (params.serverid !== Config.mainserver) rating = 0; - const inputlog = params.inputlog || null; - const out = await prepreplays.replace({ - id, loghash, - p1, p2, - format, - uploadtime: time(), - rating, - inputlog: Array.isArray(inputlog) ? inputlog.join('\n') : inputlog as string, - private: isPrivate, - }); - return !!out.affectedRows; - } + // async prep(params: {[k: string]: unknown}) { + // const id = ('' + params.id).toLowerCase().replace(/[^a-z0-9-]+/g, ''); + // let isPrivate: 0 | 1 | 2 = params.hidden ? 1 : 0; + // if (params.hidden === 2) isPrivate = 2; + // let p1 = Session.wordfilter(`${params.p1}`); + // let p2 = Session.wordfilter(`${params.p2}`); + // if (isPrivate) { + // p1 = `!${p1}`; + // p2 = `!${p2}`; + // } + // const {loghash, format} = params as Record; + // let rating = Number(params.rating); + // if (params.serverid !== Config.mainserver) rating = 0; + // const inputlog = params.inputlog || null; + // const out = await replayPrep.replace({ + // id, loghash, + // players: `${p1},${p2}`, + // format, + // uploadtime: time(), + // rating, + // inputlog: Array.isArray(inputlog) ? inputlog.join('\n') : inputlog as string, + // private: isPrivate, + // }); + // return !!out.affectedRows; + // } - /** - * Not a direct upload; you should call prep first. - * - * The intended use is that the sim server sends `prepreplay` directly - * to here, and then the client sends `upload`. Convoluted mostly in - * case of firewalls between the sim server and the loginserver. - */ - async upload(params: {[k: string]: string | undefined}, context: ActionContext) { - let id = ('' + params.id).toLowerCase().replace(/[^a-z0-9-]+/g, ''); - if (!id) throw new ActionError('Battle ID needed.'); - const preppedReplay = await prepreplays.get(id); - const replay = await replays.get(id, ['id', 'private', 'password']); - if (!preppedReplay) { - if (replay) { - if (replay.password) { - id += '-' + replay.password + 'pw'; - } - return 'success:' + id; - } - if (!/^[a-z0-9]+-[a-z0-9]+-[0-9]+$/.test(id)) { - return 'invalid id'; - } - return 'not found'; - } - let password: string | null = null; - if (preppedReplay.private && preppedReplay.private !== 2) { - if (replay?.password) { - password = replay.password; - } else if (!replay?.private) { - password = this.generatePassword(); - } - } - if (typeof params.password === 'string') password = params.password; + // /** + // * Not a direct upload; you should call prep first. + // * + // * The intended use is that the sim server sends `prepreplay` directly + // * to here, and then the client sends `upload`. Convoluted mostly in + // * case of firewalls between the sim server and the loginserver. + // */ + // async upload(params: {[k: string]: string | undefined}, context: ActionContext) { + // let id = ('' + params.id).toLowerCase().replace(/[^a-z0-9-]+/g, ''); + // if (!id) throw new ActionError('Battle ID needed.'); + // const preppedReplay = await replayPrep.get(id); + // const replay = await replays.get(id, ['id', 'private', 'password']); + // if (!preppedReplay) { + // if (replay) { + // if (replay.password) { + // id += '-' + replay.password + 'pw'; + // } + // return 'success:' + id; + // } + // if (!/^[a-z0-9]+-[a-z0-9]+-[0-9]+$/.test(id)) { + // return 'invalid id'; + // } + // return 'not found'; + // } + // let password: string | null = null; + // if (preppedReplay.private && preppedReplay.private !== 2) { + // if (replay?.password) { + // password = replay.password; + // } else if (!replay?.private) { + // password = this.generatePassword(); + // } + // } + // if (typeof params.password === 'string') password = params.password; - let fullid = id; - if (password) fullid += '-' + password + 'pw'; + // let fullid = id; + // if (password) fullid += '-' + password + 'pw'; - let log = params.log as string; - if (md5(stripNonAscii(log)) !== preppedReplay.loghash) { - log = log.replace('\r', ''); - if (md5(stripNonAscii(log)) !== preppedReplay.loghash) { - // Hashes don't match. + // let log = params.log as string; + // if (md5(stripNonAscii(log)) !== preppedReplay.loghash) { + // log = log.replace('\r', ''); + // if (md5(stripNonAscii(log)) !== preppedReplay.loghash) { + // // Hashes don't match. - // Someone else tried to upload a replay of the same battle, - // while we were uploading this - // ...pretend it was a success - return 'success:' + fullid; - } - } + // // Someone else tried to upload a replay of the same battle, + // // while we were uploading this + // // ...pretend it was a success + // return 'success:' + fullid; + // } + // } - if (password && password.length > 31) { - context.setHeader('HTTP/1.1', '403 Forbidden'); - return 'password must be 31 or fewer chars long'; - } + // if (password && password.length > 31) { + // context.setHeader('HTTP/1.1', '403 Forbidden'); + // return 'password must be 31 or fewer chars long'; + // } + + // const formatid = toID(preppedReplay.format); - const p1id = toID(preppedReplay.p1); - const p2id = toID(preppedReplay.p2); - const formatid = toID(preppedReplay.format); + // const privacy = preppedReplay.private ? 1 : 0; + // const {players, format, uploadtime, rating, inputlog} = preppedReplay; + // await replays.insert({ + // id, players, format, + // formatid, uploadtime, + // private: privacy, rating, log, + // inputlog, password, + // }, SQL`ON DUPLICATE KEY UPDATE log = ${params.log as string}, + // inputlog = ${inputlog}, rating = ${rating}, + // private = ${privacy}, \`password\` = ${password}`); - const privacy = preppedReplay.private ? 1 : 0; - const {p1, p2, format, uploadtime, rating, inputlog} = preppedReplay; - await replays.insert({ - id, p1, p2, format, p1id, p2id, - formatid, uploadtime, - private: privacy, rating, log, - inputlog, password, - }, SQL`ON DUPLICATE KEY UPDATE log = ${params.log as string}, - inputlog = ${inputlog}, rating = ${rating}, - private = ${privacy}, \`password\` = ${password}`); + // await replayPrep.deleteOne()`WHERE id = ${id} AND loghash = ${preppedReplay.loghash}`; - await prepreplays.deleteOne()`WHERE id = ${id} AND loghash = ${preppedReplay.loghash}`; + // return 'success:' + fullid; + // } + + toReplay(this: void, row: ReplayRow) { + const replay: Replay = { + ...row, + players: row.players.split(',').map(player => player.startsWith('!') ? player.slice(1) : player), + }; + if (!replay.password && replay.private === 1) replay.private = 2; + return replay; + } + toReplays(this: void, rows: ReplayRow[]) { + return rows.map(row => Replays.toReplay(row)); + } - return 'success:' + fullid; + toReplayRow(this: void, replay: Replay) { + const formatid = toID(replay.format); + const replayData: ReplayRow = { + password: null, + views: 0, + ...replay, + players: replay.players.join(','), + formatid, + }; + if (replayData.private === 1 && !replayData.password) { + replayData.password = Replays.generatePassword(); + } else { + if (replayData.private === 2) replayData.private = 1; + replayData.password = null; + } + return replayData; } - async get(id: string): Promise { - const replay = await replays.get(id); - if (!replay) return null; + async add(replay: Replay) { + const fullid = replay.id + (replay.password ? `-${replay.password}pw` : ''); - for (const player of ['p1', 'p2'] as const) { - if (replay[player].startsWith('!')) replay[player] = replay[player].slice(1); + // obviously upsert exists but this is the easiest way when multiple things need to be changed + const replayData = this.toReplayRow(replay); + try { + await replays.insert(replayData); + for (const playerName of replay.players) { + await replayPlayers.insert({ + playerid: toID(playerName), + formatid: replayData.formatid, + id: replayData.id, + rating: replayData.rating, + uploadtime: replayData.uploadtime, + private: replayData.private, + password: replayData.password, + format: replayData.format, + players: replayData.players, + }); + } + } catch { + await replays.update(replay.id, { + log: replayData.log, + inputlog: replayData.inputlog, + rating: replayData.rating, + private: replayData.private, + password: replayData.password, + }); + await replayPlayers.updateAll({ + rating: replayData.rating, + private: replayData.private, + password: replayData.password, + })`WHERE replayid = ${replay.id}`; } - await replays.update(replay.id, {views: SQL`views + 1`}); + return fullid; + } - return replay; + async get(id: string): Promise { + const replayData = await replays.get(id); + if (!replayData) return null; + + await replays.update(replayData.id, {views: SQL`views + 1`}); + + return this.toReplay(replayData); } - async edit(replay: ReplayData) { - if (replay.private === 3) { - await replays.update(replay.id, {private: 3, password: null}); - } else if (replay.private === 2) { - await replays.update(replay.id, {private: 1, password: null}); - } else if (replay.private) { - if (!replay.password) replay.password = this.generatePassword(); - await replays.update(replay.id, {private: 1, password: replay.password}); - } else { - await replays.update(replay.id, {private: 1, password: null}); - } + async edit(replay: Replay) { + const replayData = this.toReplayRow(replay); + await replays.update(replay.id, {private: replayData.private, password: replayData.password}); } generatePassword(length = 31) { @@ -172,15 +228,16 @@ export const Replays = new class { return password; } - async search(args: { + search(args: { page?: number; isPrivate?: boolean; byRating?: boolean; format?: string; username?: string; username2?: string; - }): Promise { + }): Promise { const page = args.page || 0; - if (page > 100) return []; + if (page > 100) return Promise.resolve([]); let limit1 = 50 * (page - 1); if (limit1 < 0) limit1 = 0; + const paginate = SQL`LIMIT 51 OFFSET ${limit1}`; const isPrivate = args.isPrivate ? 1 : 0; @@ -192,84 +249,69 @@ export const Replays = new class { if (args.username2) { const userid2 = toID(args.username2); if (format) { - return replays.query()`(SELECT uploadtime, id, format, p1, p2, password FROM ps_replays - FORCE INDEX (p1) - WHERE private = ${isPrivate} AND p1id = ${userid} - AND p2id = ${userid2} AND format = ${format} - ${order}) - UNION - (SELECT uploadtime, id, format, p1, p2, password - FROM ps_replays FORCE INDEX (p1) - WHERE private = ${isPrivate} AND p1id = ${userid2} AND p2id = ${userid} - AND format = ${format} - ${order}) - ${order} LIMIT ${limit1}, 51;`; + return replays.query()`SELECT + p1.uploadtime AS uploadtime, p1.id AS id, p1.format AS format, p1.players AS players, + p1.rating AS rating, p1.password AS password, p1.private AS private + FROM replayplayers p1 INNER JOIN replayplayers p2 ON p2.id = p1.id + WHERE p1.playerid = ${userid} AND p1.formatid = ${format} AND p1.private = ${isPrivate} + AND p2.playerid = ${userid2} + ${order} ${paginate};`.then(this.toReplays); } else { - return replays.query()`(SELECT uploadtime, id, format, p1, p2, password FROM ps_replays - FORCE INDEX (p1) - WHERE private = ${isPrivate} AND p1id = ${userid} AND p2id = ${userid2} - ${order}) - UNION - (SELECT uploadtime, id, format, p1, p2, password FROM ps_replays FORCE INDEX (p1) - WHERE private = ${isPrivate} AND p1id = ${userid2} AND p2id = ${userid} - ${order}) - ${order} LIMIT ${limit1}, 51;`; + return replays.query()`SELECT + p1.uploadtime AS uploadtime, p1.id AS id, p1.format AS format, p1.players AS players, + p1.rating AS rating, p1.password AS password, p1.private AS private + FROM replayplayers p1 INNER JOIN replayplayers p2 ON p2.id = p1.id + WHERE p1.playerid = ${userid} AND p1.private = ${isPrivate} + AND p2.playerid = ${userid2} + ${order} ${paginate};`.then(this.toReplays); } } else { if (format) { - return replays.query()`(SELECT uploadtime, id, format, p1, p2, password FROM ps_replays - FORCE INDEX (p1) - WHERE private = ${isPrivate} AND p1id = ${userid} AND format = ${format} - ${order}) - UNION - (SELECT uploadtime, id, format, p1, p2, password FROM ps_replays FORCE INDEX (p2) - WHERE private = ${isPrivate} AND p2id = ${userid} AND format = ${format} - ${order}) - ${order} LIMIT ${limit1}, 51;`; + return replays.query()`SELECT uploadtime, id, format, players, rating, private, password FROM replayplayers + WHERE playerid = ${userid} AND formatid = ${format} AND "private" = ${isPrivate} + ${order} ${paginate};`.then(this.toReplays); } else { - return replays.query()`(SELECT uploadtime, id, format, p1, p2, password FROM ps_replays - FORCE INDEX (p1) - WHERE private = ${isPrivate} AND p1id = ${userid} ${order}) - UNION - (SELECT uploadtime, id, format, p1, p2, password FROM ps_replays FORCE INDEX (p2) - WHERE private = ${isPrivate} AND p2id = ${userid} ${order}) - ${order} LIMIT ${limit1}, 51;`; + return replays.query()`SELECT uploadtime, id, format, players, rating, private, password FROM replayplayers + WHERE playerid = ${userid} AND private = ${isPrivate} + ${order} ${paginate};`.then(this.toReplays); } } } if (args.byRating) { - return replays.query()`SELECT uploadtime, id, format, p1, p2, rating, password - FROM ps_replays FORCE INDEX (top) - WHERE private = ${isPrivate} AND formatid = ${format} ORDER BY rating DESC LIMIT ${limit1}, 51`; + return replays.query()`SELECT uploadtime, id, format, players, rating, private, password + FROM replays + WHERE private = ${isPrivate} AND formatid = ${format} ORDER BY rating DESC ${paginate}` + .then(this.toReplays); } else { - return replays.query()`SELECT uploadtime, id, format, p1, p2, rating, password - FROM ps_replays FORCE INDEX (format) - WHERE private = ${isPrivate} AND formatid = ${format} ORDER BY uploadtime DESC LIMIT ${limit1}, 51`; + return replays.query()`SELECT uploadtime, id, format, players, rating, private, password + FROM replays + WHERE private = ${isPrivate} AND formatid = ${format} ORDER BY uploadtime DESC ${paginate}` + .then(this.toReplays); } } - async fullSearch(term: string, page = 0): Promise { - if (page > 0) return []; + fullSearch(term: string, page = 0): Promise { + if (page > 0) return Promise.resolve([]); const patterns = term.split(',').map(subterm => { const escaped = subterm.replace(/%/g, '\\%').replace(/_/g, '\\_'); return `%${escaped}%`; }); - if (patterns.length !== 1 && patterns.length !== 2) return []; + if (patterns.length !== 1 && patterns.length !== 2) return Promise.resolve([]); const secondPattern = patterns.length >= 2 ? SQL`AND log LIKE ${patterns[1]} ` : undefined; return replays.query()`SELECT /*+ MAX_EXECUTION_TIME(10000) */ - uploadtime, id, format, p1, p2, password FROM ps_replays - FORCE INDEX (recent) WHERE private = 0 AND log LIKE ${patterns[0]} ${secondPattern} - ORDER BY uploadtime DESC LIMIT 10;`; + uploadtime, id, format, players, rating FROM ps_replays + WHERE private = 0 AND log LIKE ${patterns[0]} ${secondPattern} + ORDER BY uploadtime DESC LIMIT 10;`.then(this.toReplays); } - async recent() { + recent() { return replays.selectAll( - SQL`uploadtime, id, format, p1, p2` - )`FORCE INDEX (recent) WHERE private = 0 ORDER BY uploadtime DESC LIMIT 50`; + SQL`uploadtime, id, format, players, rating` + )`WHERE private = 0 ORDER BY uploadtime DESC LIMIT 50`.then(this.toReplays); } }; diff --git a/src/schemas/replays.sql b/src/schemas/replays.sql new file mode 100644 index 0000000..fc256f7 --- /dev/null +++ b/src/schemas/replays.sql @@ -0,0 +1,35 @@ +CREATE TABLE public.replayplayers ( + playerid STRING(45) NOT NULL, + formatid STRING(45) NOT NULL, + id STRING(255) NOT NULL, + rating INT8 NULL, + uploadtime INT8 NOT NULL, + private INT2 NOT NULL, + password STRING(31) NULL, + format STRING NOT NULL, + players STRING(255) NOT NULL, + CONSTRAINT replayplayers_pkey PRIMARY KEY (id ASC, playerid ASC), + INDEX playerid_uploadtime (playerid ASC, uploadtime ASC), + INDEX playerid_rating (playerid ASC, rating ASC), + INDEX formatid_playerid_uploadtime (formatid ASC, playerid ASC, uploadtime ASC), + INDEX formatid_playerid_rating (formatid ASC, playerid ASC, rating ASC) +); + +CREATE TABLE public.replays ( + id STRING(255) NOT NULL, + format STRING(45) NOT NULL, + players STRING(255) NOT NULL, + log STRING NOT NULL, + inputlog STRING NULL, + uploadtime INT8 NOT NULL, + views INT8 NOT NULL DEFAULT 0:::INT8, + formatid STRING(45) NOT NULL, + rating INT8 NULL, + private INT8 NOT NULL DEFAULT 0:::INT8, + password STRING(31) NULL, + CONSTRAINT replays_pkey PRIMARY KEY (id ASC), + INDEX private_uploadtime (private ASC, uploadtime ASC), + INDEX private_formatid_uploadtime (private ASC, formatid ASC, uploadtime ASC), + INDEX private_formatid_rating (private ASC, formatid ASC, rating ASC), + INVERTED INDEX log (log) +); diff --git a/src/tables.ts b/src/tables.ts index f65caa6..5a815e7 100644 --- a/src/tables.ts +++ b/src/tables.ts @@ -1,19 +1,19 @@ /** * Login server database tables */ -import {DatabaseTable, MySQLDatabase, PGDatabase} from './database'; +import {MySQLDatabase, PGDatabase} from './database'; import {Config} from './config-loader'; import type {LadderEntry} from './ladder'; -import type {ReplayData} from './replays'; +import type {ReplayRow} from './replays'; // direct access export const psdb = new MySQLDatabase(Config.mysql); export const pgdb = new PGDatabase(Config.postgres!); -export const replaysDB = Config.replaysdb ? new MySQLDatabase(Config.replaysdb!) : psdb; +export const replaysDB = Config.replaysdb ? new PGDatabase(Config.replaysdb) : pgdb; export const ladderDB = Config.ladderdb ? new MySQLDatabase(Config.ladderdb!) : psdb; -export const users = new DatabaseTable<{ +export const users = psdb.getTable<{ userid: string; usernum: number; username: string; @@ -33,17 +33,16 @@ export const users = new DatabaseTable<{ avatar: number; logintime: number; loginip: string | null; -}>(psdb, 'users', 'userid'); +}>('users', 'userid'); -export const ladder = new DatabaseTable( - ladderDB, 'ladder', 'entryid', -); +export const ladder = ladderDB.getTable< +LadderEntry +>('ladder', 'entryid'); -export const prepreplays = new DatabaseTable<{ +export const replayPrep = replaysDB.getTable<{ id: string; - p1: string; - p2: string; format: string; + players: string; /** * 0 = public * 1 = private (with password) @@ -55,74 +54,85 @@ export const prepreplays = new DatabaseTable<{ inputlog: string; rating: number; uploadtime: number; -}>( - replaysDB, 'prepreplays', 'id', -); +}>('replayprep', 'id'); -export const replays = new DatabaseTable( - replaysDB, 'replays', 'id', -); +export const replays = replaysDB.getTable< +ReplayRow +>('replays', 'id'); -export const sessions = new DatabaseTable<{ +export const replayPlayers = replaysDB.getTable<{ + playerid: string; + formatid: string; + id: string; + rating: number | null; + uploadtime: number; + private: ReplayRow['private']; + password: string | null; + format: string; + /** comma-delimited player names */ + players: string; +}>('replayplayers'); + +export const sessions = psdb.getTable<{ session: number; sid: string; userid: string; time: number; timeout: number; ip: string; -}>(psdb, 'sessions', 'session'); +}>('sessions', 'session'); -export const userstats = new DatabaseTable<{ +export const userstats = psdb.getTable<{ id: number; serverid: string; usercount: number; date: number; -}>(psdb, 'userstats', 'id'); +}>('userstats', 'id'); -export const loginthrottle = new DatabaseTable<{ +export const loginthrottle = psdb.getTable<{ ip: string; count: number; time: number; lastuserid: string; -}>(psdb, 'loginthrottle', 'ip'); +}>('loginthrottle', 'ip'); -export const usermodlog = new DatabaseTable<{ +export const usermodlog = psdb.getTable<{ entryid: number; userid: string; actorid: string; date: number; ip: string; entry: string; -}>(psdb, 'usermodlog', 'entryid'); +}>('usermodlog', 'entryid'); -export const userstatshistory = new DatabaseTable<{ +export const userstatshistory = psdb.getTable<{ id: number; date: number; usercount: number; programid: 'showdown' | 'po'; -}>(psdb, 'userstatshistory', 'id'); +}>('userstatshistory', 'id'); // oauth stuff -export const oauthClients = new DatabaseTable<{ +export const oauthClients = psdb.getTable<{ owner: string; // ps username client_title: string; id: string; // hex hash origin_url: string; -}>(psdb, 'oauth_clients', 'id'); +}>('oauth_clients', 'id'); -export const oauthTokens = new DatabaseTable<{ +export const oauthTokens = psdb.getTable<{ owner: string; client: string; // id of client id: string; time: number; -}>(psdb, 'oauth_tokens', 'id'); +}>('oauth_tokens', 'id'); -export const teams = new DatabaseTable<{ +export const teams = pgdb.getTable<{ teamid: string; ownerid: string; team: string; format: string; title: string; private: number; -}>(pgdb, 'teams', 'teamid'); +}>('teams', 'teamid'); diff --git a/src/test/actions.test.ts b/src/test/actions.test.ts index 268d384..56eafca 100644 --- a/src/test/actions.test.ts +++ b/src/test/actions.test.ts @@ -74,37 +74,37 @@ describe('Loginserver actions', () => { }); }); - it('Should prepare replays', async () => { - // clear old - await tables.prepreplays.delete('gen8randombattle-3096'); + // it('Should prepare replays', async () => { + // // clear old + // await tables.prepreplays.delete('gen8randombattle-3096'); - // as long as it doesn't throw, we're fine - await utils.testDispatcher({ - act: 'prepreplay', - id: 'gen8randombattle-3096', - loghash: 'ec4730e807719f9b94327f4b5ab28034', - p1: 'Adora', - p2: 'Catra', - format: 'gen8randombattle', - rating: '1500', - hidden: '', - private: '0', - serverid: 'showdown', - servertoken: token, - inputlog: [ - '>version 3eeccb002ecc608fb66c25b6abb3ef87f667f8b6', - '>version-origin a5d3aaee353a60c91076162238b2a6d09c284165', - '>start {"formatid":"gen8randombattle","seed":[17049,48118,24089,21353],"rated":"Rated battle"}', - '>player p1 {"name":"Adora","avatar":"169","rating":1000,"seed":[14989,14520,19847,43935]}', - '>player p2 {"name":"Catra","avatar":"miapi.png","rating":1069,"seed":[35058,54063,46942,19311]}', - ].join('\n'), - }); + // // as long as it doesn't throw, we're fine + // await utils.testDispatcher({ + // act: 'prepreplay', + // id: 'gen8randombattle-3096', + // loghash: 'ec4730e807719f9b94327f4b5ab28034', + // p1: 'Adora', + // p2: 'Catra', + // format: 'gen8randombattle', + // rating: '1500', + // hidden: '', + // private: '0', + // serverid: 'showdown', + // servertoken: token, + // inputlog: [ + // '>version 3eeccb002ecc608fb66c25b6abb3ef87f667f8b6', + // '>version-origin a5d3aaee353a60c91076162238b2a6d09c284165', + // '>start {"formatid":"gen8randombattle","seed":[17049,48118,24089,21353],"rated":"Rated battle"}', + // '>player p1 {"name":"Adora","avatar":"169","rating":1000,"seed":[14989,14520,19847,43935]}', + // '>player p2 {"name":"Catra","avatar":"miapi.png","rating":1069,"seed":[35058,54063,46942,19311]}', + // ].join('\n'), + // }); - const cached = await tables.prepreplays.get( - 'gen8randombattle-3096' - ); - assert(cached, 'Could not locate entry for prepped replay'); - }); + // const cached = await tables.prepreplays.get( + // 'gen8randombattle-3096' + // ); + // assert(cached, 'Could not locate entry for prepped replay'); + // }); describe('Ladder', () => { it('Should update the ladder', async () => { diff --git a/src/test/replays.test.ts b/src/test/replays.test.ts index 45909a4..2725eb1 100644 --- a/src/test/replays.test.ts +++ b/src/test/replays.test.ts @@ -2,45 +2,44 @@ * Tests for replays.ts. * @author Annika */ -import {Replays, ReplayData} from '../replays'; -import {prepreplays, replays} from '../tables'; +import {Replays, ReplayRow} from '../replays'; +import {replayPrep, replays} from '../tables'; import {strict as assert} from 'assert'; import * as utils from './test-utils'; import {md5, stripNonAscii} from '../utils'; (describe.skip)('Replay database manipulation', () => { - it('should properly prepare replays', async () => { - const inputlog = [ - '>version 3eeccb002ecc608fb66c25b6abb3ef87f667f8b6', - '>version-origin a5d3aaee353a60c91076162238b2a6d09c284165', - '>start {"formatid":"gen8randombattle","seed":[1,1,1,1],"rated":"Rated battle"}', - '>player p1 {"name":"Annika","avatar":"1","rating":1000,"seed":[2,3,4,5]}', - '>player p2 {"name":"Heart of Etheria","avatar":"1","rating":1069,"seed":[4,5,6,7]}', - ].join('\n'); - const loghash = md5(inputlog); - await Replays.prep({ - p1: 'annika', p2: 'heartofetheria', id: 'gen8randombattle-42', rating: '1000', - format: 'gen8randombattle', hidden: true, loghash, serverid: 'showdown', inputlog, - }); - - const currentUnixTime = Math.floor(Date.now() / 1000); - const dbResult = await prepreplays.selectOne()`WHERE p1 = ${'annika'} AND p2 = ${'heartofetheria'}`; - assert(dbResult, 'database entry should exist'); - - assert.equal(dbResult.id, 'gen8randombattle-42'); - assert.equal(dbResult.format, 'gen8randombattle'); - assert.equal(dbResult.loghash, loghash); - assert.equal(dbResult.uploadtime, currentUnixTime); - assert.equal(dbResult.rating, 1000); - assert.equal(dbResult.inputlog, inputlog); - }); + // it('should properly prepare replays', async () => { + // const inputlog = [ + // '>version 3eeccb002ecc608fb66c25b6abb3ef87f667f8b6', + // '>version-origin a5d3aaee353a60c91076162238b2a6d09c284165', + // '>start {"formatid":"gen8randombattle","seed":[1,1,1,1],"rated":"Rated battle"}', + // '>player p1 {"name":"Annika","avatar":"1","rating":1000,"seed":[2,3,4,5]}', + // '>player p2 {"name":"Heart of Etheria","avatar":"1","rating":1069,"seed":[4,5,6,7]}', + // ].join('\n'); + // const loghash = md5(inputlog); + // await Replays.prep({ + // p1: 'annika', p2: 'heartofetheria', id: 'gen8randombattle-42', rating: '1000', + // format: 'gen8randombattle', hidden: true, loghash, serverid: 'showdown', inputlog, + // }); + + // const currentUnixTime = Math.floor(Date.now() / 1000); + // const dbResult = await replayPrep.selectOne()`WHERE p1 = ${'annika'} AND p2 = ${'heartofetheria'}`; + // assert(dbResult, 'database entry should exist'); + + // assert.equal(dbResult.id, 'gen8randombattle-42'); + // assert.equal(dbResult.format, 'gen8randombattle'); + // assert.equal(dbResult.loghash, loghash); + // assert.equal(dbResult.uploadtime, currentUnixTime); + // assert.equal(dbResult.rating, 1000); + // assert.equal(dbResult.inputlog, inputlog); + // }); it('should increment views upon get()ing a replay', async () => { await replays.insert({ id: 'gettest', views: 1, - p1: 'annika', - p2: 'annikatesting', + players: 'annika,annikatesting', format: 'gen8ou', }); const replay = await Replays.get('gettest'); @@ -52,89 +51,88 @@ import {md5, stripNonAscii} from '../utils'; await replays.insert({ id: 'edittest', views: 1, - p1: 'annika', - p2: 'annikatesting', + players: 'annika,annikatesting', format: 'gen8ou', }); const original = await Replays.get('edittest'); assert(original); - assert.equal(original.p1, 'annika'); + assert.equal(original.private, 0); - original.p1 = 'somerandomreg'; + original.private = 2; await Replays.edit(original); await Replays.get('edittest'); - assert.equal(original.p1, 'somerandomreg'); + assert.equal(original.private, 2); }); - it('should properly upload replays', async () => { - const context = utils.makeDispatcher({act: ''}); - /* eslint-disable max-len */ - const inputlog = [ - '>version 3643e94ff7b9b025f98fb947cfe103546db62c03', - '>version-origin 222745920a04435f2585483b5f119227c147005a', - '>start {"formatid":"gen8randombattle","seed":[10795,22527,59340,715],"rated":"Rated battle"}", ">player p1 {"name":"mia is testing 2","avatar":"2","rating":1000,"seed":[61291,35585,26582,55949]}', - '>player p2 {"name":"Mia","avatar":"miapi.png","rating":1000,"seed":[31770,27174,44195,58706]}', - ].join('\n'); - /* eslint-enable */ - const log = `|j|☆mia is testing 2 - |j|☆Mia - |t:|1633454094 - |gametype|singles - |player|p1|mia is testing 2|2|1000 - |player|p2|Mia|miapi.png|1000 - |teamsize|p1|6 - |teamsize|p2|6 - |gen|8 - |tier|[Gen 8] Random Battle - |rated| - |rule|Species Clause: Limit one of each Pokémon - |rule|HP Percentage Mod: HP is shown in percentages - |rule|Sleep Clause Mod: Limit one foe put to sleep - | - |t:|1633454094 - |start - |switch|p1a: Tyrantrum|Tyrantrum, L82, M|269/269 - |switch|p2a: Polteageist|Polteageist, L78|222/222 - |turn|1 - `; - const loghash = md5(stripNonAscii(log)); - const toPrep = { - p1: 'Annika', p2: 'Heart of Etheria', - id: 'uploadtest', rating: '1000', - format: '[Gen 8] Random Battle', hidden: true, - loghash, serverid: 'showdown', inputlog, - }; - await Replays.prep(toPrep); - const replay: {[k: string]: string} = { - id: 'uploadtest', - password: 'hunter2', - p1: 'Annika', p2: 'Heart of Etheria', - format: toPrep.format, - log, - }; - - const result = await Replays.upload(replay, context); - assert(result.startsWith('success:')); - - const fetchedReplay = await Replays.get('uploadtest'); - assert(fetchedReplay); - for (const k in replay) { - assert.equal( - fetchedReplay[k as keyof ReplayData], - replay[k as keyof ReplayData] - ); - } - - assert.equal(fetchedReplay['p1id'], 'annika'); - assert.equal(fetchedReplay['p2id'], 'heartofetheria'); - assert.equal(fetchedReplay['formatid'], 'gen8randombattle'); - assert.equal(fetchedReplay['private'], 1); - assert.equal(fetchedReplay['rating'], 1000); - assert.equal(fetchedReplay['log'], replay.log); - assert.equal(fetchedReplay['inputlog'], inputlog); - }); + // it('should properly upload replays', async () => { + // const context = utils.makeDispatcher({act: ''}); + // /* eslint-disable max-len */ + // const inputlog = [ + // '>version 3643e94ff7b9b025f98fb947cfe103546db62c03', + // '>version-origin 222745920a04435f2585483b5f119227c147005a', + // '>start {"formatid":"gen8randombattle","seed":[10795,22527,59340,715],"rated":"Rated battle"}", ">player p1 {"name":"mia is testing 2","avatar":"2","rating":1000,"seed":[61291,35585,26582,55949]}', + // '>player p2 {"name":"Mia","avatar":"miapi.png","rating":1000,"seed":[31770,27174,44195,58706]}', + // ].join('\n'); + // /* eslint-enable */ + // const log = `|j|☆mia is testing 2 + // |j|☆Mia + // |t:|1633454094 + // |gametype|singles + // |player|p1|mia is testing 2|2|1000 + // |player|p2|Mia|miapi.png|1000 + // |teamsize|p1|6 + // |teamsize|p2|6 + // |gen|8 + // |tier|[Gen 8] Random Battle + // |rated| + // |rule|Species Clause: Limit one of each Pokémon + // |rule|HP Percentage Mod: HP is shown in percentages + // |rule|Sleep Clause Mod: Limit one foe put to sleep + // | + // |t:|1633454094 + // |start + // |switch|p1a: Tyrantrum|Tyrantrum, L82, M|269/269 + // |switch|p2a: Polteageist|Polteageist, L78|222/222 + // |turn|1 + // `; + // const loghash = md5(stripNonAscii(log)); + // const toPrep = { + // p1: 'Annika', p2: 'Heart of Etheria', + // id: 'uploadtest', rating: '1000', + // format: '[Gen 8] Random Battle', hidden: true, + // loghash, serverid: 'showdown', inputlog, + // }; + // await Replays.prep(toPrep); + // const replay: {[k: string]: string} = { + // id: 'uploadtest', + // password: 'hunter2', + // p1: 'Annika', p2: 'Heart of Etheria', + // format: toPrep.format, + // log, + // }; + + // const result = await Replays.upload(replay, context); + // assert(result.startsWith('success:')); + + // const fetchedReplay = await Replays.get('uploadtest'); + // assert(fetchedReplay); + // for (const k in replay) { + // assert.equal( + // fetchedReplay[k as keyof ReplayRow], + // replay[k as keyof ReplayRow] + // ); + // } + + // assert.equal(fetchedReplay['p1id'], 'annika'); + // assert.equal(fetchedReplay['p2id'], 'heartofetheria'); + // assert.equal(fetchedReplay['formatid'], 'gen8randombattle'); + // assert.equal(fetchedReplay['private'], 1); + // assert.equal(fetchedReplay['rating'], 1000); + // assert.equal(fetchedReplay['log'], replay.log); + // assert.equal(fetchedReplay['inputlog'], inputlog); + // }); describe('searching replays', () => { async function search(args: any) { @@ -146,29 +144,29 @@ import {md5, stripNonAscii} from '../utils'; before(async () => { await replays.insert({ - id: 'searchtest1', private: 1, views: 1, p1: 'somerandomreg', - p2: 'annikaskywalker', rating: 1000, format: 'gen8randombattle', uploadtime: 1, + id: 'searchtest1', private: 1, views: 1, players: 'somerandomreg,annikaskywalker', + rating: 1000, format: 'gen8randombattle', uploadtime: 1, }); await replays.insert({ - id: 'searchtest2', private: 1, views: 1, p1: 'annika', - p2: 'somerandomreg', rating: 1100, format: 'gen8randombattle', uploadtime: 2, + id: 'searchtest2', private: 1, views: 1, players: 'annika,somerandomreg', + rating: 1100, format: 'gen8randombattle', uploadtime: 2, }); await replays.insert({ - id: 'searchtest3', private: 1, views: 1, p1: 'annika', - p2: 'somerandomreg', rating: 1100, format: 'gen8ou', uploadtime: 3, + id: 'searchtest3', private: 1, views: 1, players: 'annika,somerandomreg', + rating: 1100, format: 'gen8ou', uploadtime: 3, }); await replays.insert({ - id: 'searchtest4', private: 0, views: 1, p1: 'heartofetheria', - p2: 'somerandomreg', rating: 1200, format: 'gen8ou', uploadtime: 4, + id: 'searchtest4', private: 0, views: 1, players: 'heartofetheria,somerandomreg', + rating: 1200, format: 'gen8ou', uploadtime: 4, }); await replays.insert({ - id: 'searchtest5', private: 0, views: 1, p1: 'heartofetheria', - p2: 'annikaskywalker', rating: 1500, format: 'gen8anythinggoes', uploadtime: 5, + id: 'searchtest5', private: 0, views: 1, players: 'heartofetheria,annikaskywalker', + rating: 1500, format: 'gen8anythinggoes', uploadtime: 5, log: 'the quick brown fox jumped over the lazy dog', }); await replays.insert({ - id: 'searchtest6', private: 0, views: 1, p1: 'heartofetheria', - p2: 'annikaskywalker', rating: 1300, format: 'gen8anythinggoes', uploadtime: 6, + id: 'searchtest6', private: 0, views: 1, players: 'heartofetheria,annikaskywalker', + rating: 1300, format: 'gen8anythinggoes', uploadtime: 6, log: 'yxmördaren Julia Blomqvist på fäktning i Schweiz', }); });