diff --git a/config/config-example.js b/config/config-example.js index 532ade8cd..f717d2706 100644 --- a/config/config-example.js +++ b/config/config-example.js @@ -1,13 +1,14 @@ /** - * @typedef {'mysql' | 'postgres' | 'mock'} DatabaseDriver - * @typedef {{driver?: DatabaseDriver, prefix?: string}} DatabaseConfig - * @typedef {import('mysql2').PoolOptions & DatabaseConfig} MySQLDatabaseConfig - * @typedef {import('pg').PoolConfig & DatabaseConfig} PGDatabaseConfig + * @typedef {'mysql' | 'postgres' | 'sqlite' | 'mock'} DatabaseDriver + * @typedef {{driver?: DatabaseDriver, prefix?: string, path?: string}} BasicDatabaseConfig + * @typedef {import('mysql2').PoolOptions & BasicDatabaseConfig} MySQLDatabaseConfig + * @typedef {import('pg').PoolConfig & BasicDatabaseConfig} PGDatabaseConfig + * @typedef {MySQLDatabaseConfig | PGDatabaseConfig} DatabaseConfig */ /** * For logins and ladders - * @type {MySQLDatabaseConfig} + * @type {DatabaseConfig} */ exports.mysql = { driver: "mysql", @@ -22,7 +23,7 @@ exports.mysql = { /** * For replays - * @type {PGDatabaseConfig | undefined} + * @type {DatabaseConfig | null | undefined} */ exports.replaysdb = { driver: "postgres", @@ -35,13 +36,13 @@ exports.replaysdb = { /** * For ladders - * @type {MySQLDatabaseConfig | undefined} + * @type {DatabaseConfig | null | undefined} */ -exports.ladderdb = undefined; +exports.ladderdb = null; /** * For friends - * @type {PGDatabaseConfig | null} + * @type {DatabaseConfig | null | undefined} */ exports.postgres = null; diff --git a/src/config-loader.ts b/src/config-loader.ts index c8036e32d..a6dc73eea 100644 --- a/src/config-loader.ts +++ b/src/config-loader.ts @@ -14,13 +14,16 @@ export type Configuration = typeof defaults; export function load(invalidate = false): Configuration { if (process.env.NODE_TEST_CONTEXT) { + const databasePath = path.resolve(__dirname, '../../src/test/fixtures/database.sql'); return { ...defaults, watchconfig: false, loadprivaterelayips: false, serverlist: path.resolve(__dirname, '../../src/test/fixtures/servers.php'), - mysql: { driver: 'mock' }, - postgres: { driver: 'mock' }, + mysql: { driver: 'mock', path: databasePath }, + postgres: { driver: 'mock', path: databasePath }, + replaysdb: { driver: 'mock', path: databasePath }, + ladderdb: { driver: 'mock', path: databasePath }, }; } diff --git a/src/database-mock.ts b/src/database-mock.ts deleted file mode 100644 index 784d80e87..000000000 --- a/src/database-mock.ts +++ /dev/null @@ -1,39 +0,0 @@ -/** - * Mock database driver. - */ -import { type DatabaseTable } from './database'; - -type MockTableBase = Pick, 'name' | 'primaryKeyName' | 'db'>; - -function createMockTable( - name: string, primaryKeyName: keyof Row & string | null = null -): DatabaseTable { - const table: MockTableBase = { - name, - primaryKeyName, - db: { type: 'mock' } as any, - }; - return new Proxy(table, { - get(target, prop, receiver) { - if (prop in target) return Reflect.get(target, prop, receiver); - if (typeof prop === 'string') { - return () => { - throw new Error(`Mock table "${name}" attempted to use "${prop}".`); - }; - } - }, - }) as DatabaseTable; -} - -export class MockDatabase { - type = 'mock'; - prefix = ''; - readonly name: string; - constructor(_config: any, name: string) { - this.name = name; - } - getTable(tableName: string, primaryKeyName: keyof Row & string | null = null) { - return createMockTable(`${this.name}.${tableName}`, primaryKeyName); - } - close() {} -} diff --git a/src/database.ts b/src/database.ts index 4a82f6d63..5348efb94 100644 --- a/src/database.ts +++ b/src/database.ts @@ -6,6 +6,8 @@ * @author Zarel */ +import * as fs from 'fs'; +import { DatabaseSync } from 'node:sqlite'; import * as mysql from 'mysql2'; import * as pg from 'pg'; @@ -114,14 +116,14 @@ export function SQL(strings: TemplateStringsArray, ...values: SQLValue[]) { export interface ResultRow { [k: string]: BasicSQLValue } -export const connectedDatabases: Database[] = []; +export const connectedDatabases: Database[] = []; export async function closeDatabases() { const databases = connectedDatabases.splice(0); await Promise.all(databases.map(database => database.close())); } -export abstract class Database { +export abstract class Database { connection: Pool; prefix: string; type = ''; @@ -170,7 +172,7 @@ type PartialOrSQL = { 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 { +export class DatabaseTable> { db: DB; name: string; primaryKeyName: keyof Row & string | null; @@ -228,6 +230,13 @@ export class DatabaseTable { } updateOne(partialRow: PartialOrSQL): (strings: TemplateStringsArray, ...rest: SQLValue[]) => Promise> { + if (this.db.type === 'sqlite') { + // sqlite usually doesn't support UPDATE ... LIMIT 1 + return (strings, ...rest) => this.queryExec()`UPDATE "${this.name}" SET ${partialRow as any} + WHERE rowid = ( + SELECT rowid FROM "${this.name}" ${new SQLStatement(strings, rest)} LIMIT 1 + )`; + } return (s, ...r) => this.queryExec()`UPDATE "${this.name}" SET ${partialRow as any} ${new SQLStatement(s, r)} LIMIT 1`; } @@ -238,6 +247,13 @@ export class DatabaseTable { } deleteOne(): (strings: TemplateStringsArray, ...rest: SQLValue[]) => Promise> { + if (this.db.type === 'sqlite') { + // sqlite usually doesn't support DELETE ... LIMIT 1 + return (strings, ...rest) => this.queryExec()`DELETE FROM "${this.name}" + WHERE rowid = ( + SELECT rowid FROM "${this.name}" ${new SQLStatement(strings, rest)} LIMIT 1 + )`; + } return (strings, ...rest) => this.queryExec()`DELETE FROM "${this.name}" ${new SQLStatement(strings, rest)} LIMIT 1`; } @@ -255,6 +271,10 @@ export class DatabaseTable { return this.queryExec()`INSERT INTO "${this.name}" (${partialRow as SQLValue}) ${where}`; } insertIgnore(partialRow: PartialOrSQL, where?: SQLStatement) { + if (this.db.type === 'sqlite') { + // sqlite usually doesn't support DELETE ... LIMIT 1 + return this.queryExec()`INSERT OR IGNORE INTO "${this.name}" (${partialRow as SQLValue}) ${where}`; + } return this.queryExec()`INSERT IGNORE INTO "${this.name}" (${partialRow as SQLValue}) ${where}`; } async tryInsert(partialRow: PartialOrSQL, where?: SQLStatement) { @@ -264,10 +284,20 @@ export class DatabaseTable { if (err.code === 'ER_DUP_ENTRY') { return undefined; } + if ( + this.db.type === 'sqlite' && + [1555, 2067].includes(err.errcode) + ) return undefined; throw err; } } upsert(partialRow: PartialOrSQL, partialUpdate = partialRow, where?: SQLStatement) { + if (this.db.type === 'sqlite') { + if (!this.primaryKeyName) throw new Error(`Cannot upsert() without a single-column primary key`); + return this.queryExec( + )`INSERT INTO "${this.name}" (${partialRow as any}) ON CONFLICT ("${this.primaryKeyName + }") DO UPDATE SET ${partialUpdate as any} ${where}`; + } if (this.db.type === 'pg') { return this.queryExec( )`INSERT INTO "${this.name}" (${partialRow as any}) ON CONFLICT (${this.primaryKeyName @@ -290,6 +320,9 @@ export class DatabaseTable { } delete(primaryKey: BasicSQLValue) { if (!this.primaryKeyName) throw new Error(`Cannot delete() without a single-column primary key`); + if (this.db.type === 'sqlite') { + return this.deleteAll()`WHERE "${this.primaryKeyName}" = ${primaryKey}`; + } return this.deleteAll()`WHERE "${this.primaryKeyName}" = ${primaryKey} LIMIT 1`; } update(primaryKey: BasicSQLValue, data: PartialOrSQL) { @@ -391,3 +424,71 @@ export class PGDatabase extends Database { + override type = 'sqlite'; + + constructor(config: SQLiteDatabaseConfig = {}) { + super(new DatabaseSync(config.path || ':memory:'), config.prefix || ''); + } + + override _resolveSQL(query: SQLStatement): [query: string, values: BasicSQLValue[]] { + let sql = query.sql[0]; + const values = []; + for (let i = 0; i < query.values.length; i++) { + const value = query.values[i]; + if (query.sql[i + 1].startsWith('`') || query.sql[i + 1].startsWith('"')) { + sql = sql.slice(0, -1) + this.escapeId(`${value as any}`) + query.sql[i + 1].slice(1); + } else { + sql += '?' + query.sql[i + 1]; + values.push(value); + } + } + return [sql, values]; + } + + override _query(query: string, values: BasicSQLValue[]) { + return Promise.resolve().then(() => { + const rows = this.connection.prepare(query).all(...values); + return rows.map(row => ({ ...(row as Record) })); + }); + } + + override _queryExec(query: string, values: BasicSQLValue[]) { + return Promise.resolve().then(() => { + const result = this.connection.prepare(query).run(...values); + return { + affectedRows: Number(result.changes), + insertId: Number(result.lastInsertRowid), + }; + }); + } + + override escapeId(id: string) { + return `"${id.replace(/"/g, '""')}"`; + } + + override close() { + return Promise.resolve().then(() => this.connection.close()); + } +} + +export class MockDatabase extends SQLiteDatabase { + readonly name: string; + + constructor(config: SQLiteDatabaseConfig | null | undefined, name: string) { + super({ path: ':memory:', prefix: config?.prefix }); + this.name = name; + if (config?.path) this.connection.exec(fs.readFileSync(config.path, 'utf8')); + } +} diff --git a/src/server.ts b/src/server.ts index ba57be444..ae25e398b 100644 --- a/src/server.ts +++ b/src/server.ts @@ -8,6 +8,7 @@ import * as https from 'https'; import * as child from 'child_process'; import * as dns from 'dns'; import * as fs from 'fs'; +import * as net from 'net'; import { toID, md5 } from './utils'; import { Config } from './config-loader'; import { actions } from './actions'; @@ -331,14 +332,14 @@ export class Server { awaitingEnd?: () => void; closing?: Promise; activeRequests = 0; - constructor(port = (Config.port || 8000), host = (Config.bindaddress || "0.0.0.0")) { + constructor(port: number | null = (Config.port || 8000), host = (Config.bindaddress || "0.0.0.0")) { this.host = host; - this.port = port; + this.port = port || 0; this.server = http.createServer((req, res) => void this.handle(req, res)); - this.server.listen(port, host); + if (port !== null) this.server.listen(port, host); this.httpsServer = null; - if (Config.ssl) { + if (Config.ssl && port !== null) { this.httpsServer = https.createServer(Config.ssl, (req, res) => void this.handle(req, res)); this.httpsServer.listen(Config.ssl.port || 8043); } @@ -402,11 +403,45 @@ export class Server { this.activeRequests--; if (!this.activeRequests) this.awaitingEnd?.(); } + async request(url: string, bodyData?: Partial) { + const socket = new net.Socket(); + const req = new http.IncomingMessage(socket); + req.url = url; + req.method = bodyData ? 'POST' : 'GET'; + if (bodyData) { + req.push(new URLSearchParams(bodyData as Record).toString()); + } + req.push(null); + + const res = new http.ServerResponse(req); + let body = ''; + const result = new Promise<{ + statusCode: number, + body: string, + }>(resolve => { + const originalWrite = res.write.bind(res); + const originalEnd = res.end.bind(res); + res.write = ((chunk: any, ...args: any[]) => { + if (chunk) body += chunk; + return originalWrite(chunk, ...args); + }) as typeof res.write; + res.end = ((chunk: any, ...args: any[]) => { + if (chunk) body += chunk; + originalEnd(chunk, ...args); + resolve({ statusCode: res.statusCode, body }); + return res; + }) as typeof res.end; + }); + + await this.handle(req, res); + return result; + } ensureHeaders(res: http.ServerResponse) { if (this.awaitingEnd) res.setHeader('Connection', 'close'); } close() { if (this.closing) return this.closing; + if (!this.server.listening && !this.activeRequests) return Promise.resolve(); this.server.close(); if (!this.activeRequests) return Promise.resolve(); this.closing = new Promise(resolve => { diff --git a/src/tables.ts b/src/tables.ts index 8f10ad121..425659f63 100644 --- a/src/tables.ts +++ b/src/tables.ts @@ -1,15 +1,14 @@ /** * Login server database tables */ -import { MySQLDatabase, PGDatabase } from './database'; -import { MockDatabase } from './database-mock'; +import { MockDatabase, MySQLDatabase, PGDatabase, SQLiteDatabase } from './database'; import { Config } from './config-loader'; import type { LadderEntry } from './ladder'; import type { ReplayRow } from './replays'; import type { Suspect } from './actions'; -type DatabaseDriver = 'mysql' | 'postgres' | 'mock'; +type DatabaseDriver = 'mysql' | 'postgres' | 'sqlite' | 'mock'; type DatabaseConfig = { driver?: DatabaseDriver, prefix?: string, @@ -28,6 +27,7 @@ function createDatabase( const driver = config?.driver || defaultDriver; if (driver === 'mock') return new MockDatabase(config, name) as unknown as DB; if (!config) throw new Error(`Database config "${name}" is required for ${driver}.`); + if (driver === 'sqlite') return new SQLiteDatabase(stripDriver(config)) as unknown as DB; if (driver === 'mysql') return new MySQLDatabase(stripDriver(config) as any) as DB; if (driver === 'postgres') return new PGDatabase(stripDriver(config) as any) as DB; throw new Error(`Unsupported database driver for ${name}.`); diff --git a/src/test/fixtures/database.sql b/src/test/fixtures/database.sql new file mode 100644 index 000000000..1cc3483a7 --- /dev/null +++ b/src/test/fixtures/database.sql @@ -0,0 +1,52 @@ +CREATE TABLE replays ( + id TEXT PRIMARY KEY, + format TEXT NOT NULL, + players TEXT NOT NULL, + log TEXT NOT NULL, + inputlog TEXT, + uploadtime INTEGER NOT NULL, + views INTEGER NOT NULL DEFAULT 0, + formatid TEXT NOT NULL, + rating INTEGER, + private INTEGER NOT NULL DEFAULT 0, + password TEXT +); + +INSERT INTO replays ( + id, format, players, log, inputlog, uploadtime, views, formatid, rating, private, password +) VALUES ( + 'oumonotype-82345404', 'OU Monotype', 'kdarewolf,Onox', + '|join|kdarewolf +|join|Onox +|player|p1|kdarewolf|37 +|player|p2|Onox|159 +|gametype|singles +|gen|6 +|tier|OU Monotype +|clearpoke +|poke|p1|Kecleon, F, shiny +|poke|p1|Diggersby, M +|poke|p1|Girafarig, M, shiny +|poke|p1|Heliolisk, F +|poke|p1|Chansey, F, shiny +|poke|p1|Staraptor, F, shiny +|poke|p2|Espeon, M, shiny +|poke|p2|Metagross, shiny +|poke|p2|Reuniclus, M, shiny +|poke|p2|Alakazam, M, shiny +|poke|p2|Delphox, M, shiny +|poke|p2|Gardevoir, M, shiny +|teampreview +|callback|decision +| +|start +|switch|p1a: May Day Parade|Kecleon, F, shiny|324/324 +|switch|p2a: AMagicalFox|Delphox, M, shiny|292/292 +|turn|1 +|callback|decision +| +|move|p1a: May Day Parade|Fake Out|p2a: AMagicalFox +|-damage|p2a: AMagicalFox|213/292 +|cant|p2a: AMagicalFox|flinch', + NULL, 1390960565, 5468, '', NULL, 0, NULL +); diff --git a/src/test/server.test.ts b/src/test/server.test.ts index 09a624c73..c7f03a619 100644 --- a/src/test/server.test.ts +++ b/src/test/server.test.ts @@ -1,7 +1,9 @@ import { strict as assert } from 'assert'; import * as http from 'http'; import test from 'node:test'; +import { Replays } from '../replays'; import { Server } from '../server'; +import { replays } from '../tables'; async function waitForListening(server: Server) { await new Promise((resolve, reject) => { @@ -68,3 +70,23 @@ void test('/api/test reports unknown request type', async () => { await closeServer(server); } }); + +void test('/api/replays/get.json uses the mock database', async () => { + const server = new Server(null); + try { + assert.equal(replays.db.type, 'sqlite'); + const firstResponse = await server.request('/api/replays/get.json', { id: 'oumonotype-82345404' }); + assert.equal(firstResponse.statusCode, 200); + assert.equal(JSON.parse(firstResponse.body).views, 5468); + + const replay = await Replays.get('oumonotype-82345404'); + assert(replay); + assert.equal(replay.views, 5468); + + const secondResponse = await server.request('/api/replays/get.json', { id: 'oumonotype-82345404' }); + assert.equal(secondResponse.statusCode, 200); + assert.equal(JSON.parse(secondResponse.body).views, 5469); + } finally { + await server.close(); + } +});