mirror of
https://github.com/smogon/pokemon-showdown.git
synced 2026-03-21 17:25:10 -05:00
Experimental direct replay uploading
We have a new replay server on a new engine using a new schema. Everything's gone remarkably well, considering.
This commit is contained in:
parent
13c8c19064
commit
6b42b4f6b2
379
lib/database.ts
Normal file
379
lib/database.ts
Normal file
|
|
@ -0,0 +1,379 @@
|
|||
/**
|
||||
* Database abstraction layer that's vaguely ORM-like.
|
||||
* Modern (Promises, strict types, tagged template literals), but ORMs
|
||||
* are a bit _too_ magical for me, so none of that magic here.
|
||||
*
|
||||
* @author Zarel
|
||||
*/
|
||||
|
||||
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<SQLRow> | BasicSQLValue[] | undefined;
|
||||
|
||||
export class SQLStatement {
|
||||
sql: string[];
|
||||
values: BasicSQLValue[];
|
||||
constructor(strings: TemplateStringsArray, values: SQLValue[]) {
|
||||
this.sql = [strings[0]];
|
||||
this.values = [];
|
||||
for (let i = 0; i < strings.length; i++) {
|
||||
this.append(values[i], strings[i + 1]);
|
||||
}
|
||||
}
|
||||
append(value: SQLValue, nextString = ''): this {
|
||||
if (value instanceof SQLStatement) {
|
||||
if (!value.sql.length) return this;
|
||||
const oldLength = this.sql.length;
|
||||
this.sql = this.sql.concat(value.sql.slice(1));
|
||||
this.sql[oldLength - 1] += value.sql[0];
|
||||
this.values = this.values.concat(value.values);
|
||||
if (nextString) this.sql[this.sql.length - 1] += nextString;
|
||||
} else if (typeof value === 'string' || typeof value === 'number' || value === null) {
|
||||
this.values.push(value);
|
||||
this.sql.push(nextString);
|
||||
} else if (value === undefined) {
|
||||
this.sql[this.sql.length - 1] += nextString;
|
||||
} else if (Array.isArray(value)) {
|
||||
if ('"`'.includes(this.sql[this.sql.length - 1].slice(-1))) {
|
||||
// "`a`, `b`" syntax
|
||||
const quoteChar = this.sql[this.sql.length - 1].slice(-1);
|
||||
for (const col of value) {
|
||||
this.append(col, `${quoteChar}, ${quoteChar}`);
|
||||
}
|
||||
this.sql[this.sql.length - 1] = this.sql[this.sql.length - 1].slice(0, -4) + nextString;
|
||||
} else {
|
||||
// "1, 2" syntax
|
||||
for (const val of value) {
|
||||
this.append(val, `, `);
|
||||
}
|
||||
this.sql[this.sql.length - 1] = this.sql[this.sql.length - 1].slice(0, -2) + nextString;
|
||||
}
|
||||
} else if (this.sql[this.sql.length - 1].endsWith('(')) {
|
||||
// "(`a`, `b`) VALUES (1, 2)" syntax
|
||||
this.sql[this.sql.length - 1] += `"`;
|
||||
for (const col in value) {
|
||||
this.append(col, `", "`);
|
||||
}
|
||||
this.sql[this.sql.length - 1] = this.sql[this.sql.length - 1].slice(0, -4) + `") VALUES (`;
|
||||
for (const col in value) {
|
||||
this.append(value[col], `, `);
|
||||
}
|
||||
this.sql[this.sql.length - 1] = this.sql[this.sql.length - 1].slice(0, -2) + nextString;
|
||||
} else if (this.sql[this.sql.length - 1].toUpperCase().endsWith(' SET ')) {
|
||||
// "`a` = 1, `b` = 2" syntax
|
||||
this.sql[this.sql.length - 1] += `"`;
|
||||
for (const col in value) {
|
||||
this.append(col, `" = `);
|
||||
this.append(value[col], `, "`);
|
||||
}
|
||||
this.sql[this.sql.length - 1] = this.sql[this.sql.length - 1].slice(0, -3) + nextString;
|
||||
} else {
|
||||
throw new Error(
|
||||
`Objects can only appear in (obj) or after SET; ` +
|
||||
`unrecognized: ${this.sql[this.sql.length - 1]}[obj]${nextString}`
|
||||
);
|
||||
}
|
||||
return this;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Tag function for SQL, with some magic.
|
||||
*
|
||||
* * `` SQL`UPDATE table SET a = ${'hello"'}` ``
|
||||
* * `` `UPDATE table SET a = 'hello'` ``
|
||||
*
|
||||
* Values surrounded by `"` or `` ` `` become identifiers:
|
||||
*
|
||||
* * ``` SQL`SELECT * FROM "${'table'}"` ```
|
||||
* * `` `SELECT * FROM "table"` ``
|
||||
*
|
||||
* (Make sure to use `"` for Postgres and `` ` `` for MySQL.)
|
||||
*
|
||||
* Objects preceded by SET become setters:
|
||||
*
|
||||
* * `` SQL`UPDATE table SET ${{a: 1, b: 2}}` ``
|
||||
* * `` `UPDATE table SET "a" = 1, "b" = 2` ``
|
||||
*
|
||||
* Objects surrounded by `()` become keys and values:
|
||||
*
|
||||
* * `` SQL`INSERT INTO table (${{a: 1, b: 2}})` ``
|
||||
* * `` `INSERT INTO table ("a", "b") VALUES (1, 2)` ``
|
||||
*
|
||||
* Arrays become lists; surrounding by `"` or `` ` `` turns them into lists of names:
|
||||
*
|
||||
* * `` SQL`INSERT INTO table ("${['a', 'b']}") VALUES (${[1, 2]})` ``
|
||||
* * `` `INSERT INTO table ("a", "b") VALUES (1, 2)` ``
|
||||
*/
|
||||
export function SQL(strings: TemplateStringsArray, ...values: SQLValue[]) {
|
||||
return new SQLStatement(strings, values);
|
||||
}
|
||||
|
||||
export interface ResultRow {[k: string]: BasicSQLValue}
|
||||
|
||||
export const connectedDatabases: Database[] = [];
|
||||
|
||||
export abstract class Database<Pool extends mysql.Pool | pg.Pool = mysql.Pool | pg.Pool, OkPacket = unknown> {
|
||||
connection: Pool;
|
||||
prefix: string;
|
||||
type = '';
|
||||
constructor(connection: Pool, prefix = '') {
|
||||
this.prefix = prefix;
|
||||
this.connection = connection;
|
||||
connectedDatabases.push(this);
|
||||
}
|
||||
abstract _resolveSQL(query: SQLStatement): [query: string, values: BasicSQLValue[]];
|
||||
abstract _query(sql: string, values: BasicSQLValue[]): Promise<any>;
|
||||
abstract _queryExec(sql: string, values: BasicSQLValue[]): Promise<OkPacket>;
|
||||
abstract escapeId(param: string): string;
|
||||
query<T = ResultRow>(sql: SQLStatement): Promise<T[]>;
|
||||
query<T = ResultRow>(): (strings: TemplateStringsArray, ...rest: SQLValue[]) => Promise<T[]>;
|
||||
query<T = ResultRow>(sql?: SQLStatement) {
|
||||
if (!sql) return (strings: any, ...rest: any) => this.query<T>(new SQLStatement(strings, rest));
|
||||
|
||||
const [query, values] = this._resolveSQL(sql);
|
||||
return this._query(query, values);
|
||||
}
|
||||
queryOne<T = ResultRow>(sql: SQLStatement): Promise<T | undefined>;
|
||||
queryOne<T = ResultRow>(): (strings: TemplateStringsArray, ...rest: SQLValue[]) => Promise<T | undefined>;
|
||||
queryOne<T = ResultRow>(sql?: SQLStatement) {
|
||||
if (!sql) return (strings: any, ...rest: any) => this.queryOne<T>(new SQLStatement(strings, rest));
|
||||
|
||||
return this.query<T>(sql).then(res => Array.isArray(res) ? res[0] : res);
|
||||
}
|
||||
queryExec(sql: SQLStatement): Promise<OkPacket>;
|
||||
queryExec(): (strings: TemplateStringsArray, ...rest: SQLValue[]) => Promise<OkPacket>;
|
||||
queryExec(sql?: SQLStatement) {
|
||||
if (!sql) return (strings: any, ...rest: any) => this.queryExec(new SQLStatement(strings, rest));
|
||||
|
||||
const [query, values] = this._resolveSQL(sql);
|
||||
return this._queryExec(query, values);
|
||||
}
|
||||
getTable<Row>(name: string, primaryKeyName: keyof Row & string | null = null) {
|
||||
return new DatabaseTable<Row, this>(this, name, primaryKeyName);
|
||||
}
|
||||
close() {
|
||||
void this.connection.end();
|
||||
}
|
||||
}
|
||||
|
||||
type PartialOrSQL<T> = {
|
||||
[P in keyof T]?: T[P] | SQLStatement;
|
||||
};
|
||||
|
||||
type OkPacketOf<DB extends Database> = DB extends Database<any, infer T> ? T : never;
|
||||
|
||||
// Row extends SQLRow but TS doesn't support closed types so we can't express this
|
||||
export class DatabaseTable<Row, DB extends Database> {
|
||||
db: DB;
|
||||
name: string;
|
||||
primaryKeyName: keyof Row & string | null;
|
||||
constructor(
|
||||
db: DB,
|
||||
name: string,
|
||||
primaryKeyName: keyof Row & string | null = null
|
||||
) {
|
||||
this.db = db;
|
||||
this.name = db.prefix + name;
|
||||
this.primaryKeyName = primaryKeyName;
|
||||
}
|
||||
escapeId(param: string) {
|
||||
return this.db.escapeId(param);
|
||||
}
|
||||
|
||||
// raw
|
||||
|
||||
query<T = Row>(sql: SQLStatement): Promise<T[]>;
|
||||
query<T = Row>(): (strings: TemplateStringsArray, ...rest: SQLValue[]) => Promise<T[]>;
|
||||
query<T = Row>(sql?: SQLStatement) {
|
||||
return this.db.query<T>(sql as any) as any;
|
||||
}
|
||||
queryOne<T = Row>(sql: SQLStatement): Promise<T | undefined>;
|
||||
queryOne<T = Row>(): (strings: TemplateStringsArray, ...rest: SQLValue[]) => Promise<T | undefined>;
|
||||
queryOne<T = Row>(sql?: SQLStatement) {
|
||||
return this.db.queryOne<T>(sql as any) as any;
|
||||
}
|
||||
queryExec(sql: SQLStatement): Promise<OkPacketOf<DB>>;
|
||||
queryExec(): (strings: TemplateStringsArray, ...rest: SQLValue[]) => Promise<OkPacketOf<DB>>;
|
||||
queryExec(sql?: SQLStatement) {
|
||||
return this.db.queryExec(sql as any) as any;
|
||||
}
|
||||
|
||||
// low-level
|
||||
|
||||
selectAll<T = Row>(entries?: (keyof Row & string)[] | SQLStatement):
|
||||
(strings: TemplateStringsArray, ...rest: SQLValue[]) => Promise<T[]> {
|
||||
if (!entries) entries = SQL`*`;
|
||||
if (Array.isArray(entries)) entries = SQL`"${entries}"`;
|
||||
return (strings, ...rest) =>
|
||||
this.query<T>()`SELECT ${entries} FROM "${this.name}" ${new SQLStatement(strings, rest)}`;
|
||||
}
|
||||
selectOne<T = Row>(entries?: (keyof Row & string)[] | SQLStatement):
|
||||
(strings: TemplateStringsArray, ...rest: SQLValue[]) => Promise<T | undefined> {
|
||||
if (!entries) entries = SQL`*`;
|
||||
if (Array.isArray(entries)) entries = SQL`"${entries}"`;
|
||||
return (strings, ...rest) =>
|
||||
this.queryOne<T>()`SELECT ${entries} FROM "${this.name}" ${new SQLStatement(strings, rest)} LIMIT 1`;
|
||||
}
|
||||
updateAll(partialRow: PartialOrSQL<Row>):
|
||||
(strings: TemplateStringsArray, ...rest: SQLValue[]) => Promise<OkPacketOf<DB>> {
|
||||
return (strings, ...rest) =>
|
||||
this.queryExec()`UPDATE "${this.name}" SET ${partialRow as any} ${new SQLStatement(strings, rest)}`;
|
||||
}
|
||||
updateOne(partialRow: PartialOrSQL<Row>):
|
||||
(strings: TemplateStringsArray, ...rest: SQLValue[]) => Promise<OkPacketOf<DB>> {
|
||||
return (s, ...r) =>
|
||||
this.queryExec()`UPDATE "${this.name}" SET ${partialRow as any} ${new SQLStatement(s, r)} LIMIT 1`;
|
||||
}
|
||||
deleteAll():
|
||||
(strings: TemplateStringsArray, ...rest: SQLValue[]) => Promise<OkPacketOf<DB>> {
|
||||
return (strings, ...rest) =>
|
||||
this.queryExec()`DELETE FROM "${this.name}" ${new SQLStatement(strings, rest)}`;
|
||||
}
|
||||
deleteOne():
|
||||
(strings: TemplateStringsArray, ...rest: SQLValue[]) => Promise<OkPacketOf<DB>> {
|
||||
return (strings, ...rest) =>
|
||||
this.queryExec()`DELETE FROM "${this.name}" ${new SQLStatement(strings, rest)} LIMIT 1`;
|
||||
}
|
||||
eval<T>():
|
||||
(strings: TemplateStringsArray, ...rest: SQLValue[]) => Promise<T | undefined> {
|
||||
return (strings, ...rest) =>
|
||||
this.queryOne<{result: T}>(
|
||||
)`SELECT ${new SQLStatement(strings, rest)} AS result FROM "${this.name}" LIMIT 1`
|
||||
.then(row => row?.result);
|
||||
}
|
||||
|
||||
// high-level
|
||||
|
||||
insert(partialRow: PartialOrSQL<Row>, where?: SQLStatement) {
|
||||
return this.queryExec()`INSERT INTO "${this.name}" (${partialRow as SQLValue}) ${where}`;
|
||||
}
|
||||
insertIgnore(partialRow: PartialOrSQL<Row>, where?: SQLStatement) {
|
||||
return this.queryExec()`INSERT IGNORE INTO "${this.name}" (${partialRow as SQLValue}) ${where}`;
|
||||
}
|
||||
async tryInsert(partialRow: PartialOrSQL<Row>, where?: SQLStatement) {
|
||||
try {
|
||||
return await this.insert(partialRow, where);
|
||||
} catch (err: any) {
|
||||
if (err.code === 'ER_DUP_ENTRY') {
|
||||
return undefined;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
upsert(partialRow: PartialOrSQL<Row>, 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<Row>, 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);
|
||||
}
|
||||
replace(partialRow: PartialOrSQL<Row>, where?: SQLStatement) {
|
||||
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<Row>) {
|
||||
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<mysql.Pool, mysql.OkPacket> {
|
||||
override type = 'mysql' as const;
|
||||
constructor(config: mysql.PoolOptions & {prefix?: string}) {
|
||||
const prefix = config.prefix || "";
|
||||
if (config.prefix) {
|
||||
config = {...config};
|
||||
delete config.prefix;
|
||||
}
|
||||
super(mysql.createPool(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) + query.sql[i + 1].slice(1);
|
||||
} else {
|
||||
sql += '?' + query.sql[i + 1];
|
||||
values.push(value);
|
||||
}
|
||||
}
|
||||
return [sql, values];
|
||||
}
|
||||
override _query(query: string, values: BasicSQLValue[]): Promise<any> {
|
||||
return new Promise((resolve, reject) => {
|
||||
this.connection.query(query, values, (e, results: any) => {
|
||||
if (e) {
|
||||
return reject(new Error(`${e.message} (${query}) (${values}) [${e.code}]`));
|
||||
}
|
||||
if (Array.isArray(results)) {
|
||||
for (const row of results) {
|
||||
for (const col in row) {
|
||||
if (Buffer.isBuffer(row[col])) row[col] = row[col].toString();
|
||||
}
|
||||
}
|
||||
}
|
||||
return resolve(results);
|
||||
});
|
||||
});
|
||||
}
|
||||
override _queryExec(sql: string, values: BasicSQLValue[]): Promise<mysql.OkPacket> {
|
||||
return this._query(sql, values);
|
||||
}
|
||||
override escapeId(id: string) {
|
||||
return mysql.escapeId(id);
|
||||
}
|
||||
}
|
||||
|
||||
export class PGDatabase extends Database<pg.Pool, {affectedRows: number | null}> {
|
||||
override type = 'pg' as const;
|
||||
constructor(config: pg.PoolConfig) {
|
||||
super(new pg.Pool(config));
|
||||
}
|
||||
override _resolveSQL(query: SQLStatement): [query: string, values: BasicSQLValue[]] {
|
||||
let sql = query.sql[0];
|
||||
const values = [];
|
||||
let paramCount = 0;
|
||||
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) + query.sql[i + 1].slice(1);
|
||||
} else {
|
||||
paramCount++;
|
||||
sql += `$${paramCount}` + query.sql[i + 1];
|
||||
values.push(value);
|
||||
}
|
||||
}
|
||||
return [sql, values];
|
||||
}
|
||||
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<never>(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);
|
||||
}
|
||||
}
|
||||
3012
package-lock.json
generated
3012
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
|
|
@ -5,6 +5,7 @@
|
|||
"main": "dist/sim/index.js",
|
||||
"dependencies": {
|
||||
"esbuild": "^0.16.10",
|
||||
"mysql2": "^3.6.5",
|
||||
"preact": "^10.5.15",
|
||||
"preact-render-to-string": "^5.1.19",
|
||||
"probe-image-size": "^7.2.3",
|
||||
|
|
@ -18,7 +19,7 @@
|
|||
"node-static": "^0.7.11",
|
||||
"nodemailer": "^6.4.6",
|
||||
"permessage-deflate": "^0.1.7",
|
||||
"pg": "^8.8.0",
|
||||
"pg": "^8.11.3",
|
||||
"sql-template-strings": "^2.2.2",
|
||||
"sqlite": "^3.0.6",
|
||||
"sucrase": "^3.15.0"
|
||||
|
|
|
|||
237
server/replays.ts
Normal file
237
server/replays.ts
Normal file
|
|
@ -0,0 +1,237 @@
|
|||
/**
|
||||
* Code for uploading and managing replays.
|
||||
*
|
||||
* Ported to TypeScript by Annika and Mia.
|
||||
*/
|
||||
import {SQL, PGDatabase} from '../lib/database';
|
||||
|
||||
export const replaysDB = Config.replaysdb ? new PGDatabase(Config.replaysdb) : null!;
|
||||
|
||||
export const replays = replaysDB?.getTable<
|
||||
ReplayRow
|
||||
>('replays', 'id');
|
||||
|
||||
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');
|
||||
|
||||
// must be a type and not an interface to qualify as an SQLRow
|
||||
// eslint-disable-next-line
|
||||
export type ReplayRow = {
|
||||
id: 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,
|
||||
formatid: string,
|
||||
rating: number | null,
|
||||
/**
|
||||
* 0 = public
|
||||
* 1 = private (with or without password)
|
||||
* 2 = NOT USED; ONLY USED IN PREPREPLAY
|
||||
* 3 = deleted
|
||||
*/
|
||||
private: 0 | 1 | 2 | 3,
|
||||
password: string | null,
|
||||
};
|
||||
type Replay = Omit<ReplayRow, 'formatid' | 'players' | 'password' | 'views'> & {
|
||||
players: string[],
|
||||
views?: number,
|
||||
password?: string | null,
|
||||
};
|
||||
|
||||
export const Replays = new class {
|
||||
db = replaysDB as unknown;
|
||||
replaysTable = replays as unknown;
|
||||
replayPlayersTable = replayPlayers as unknown;
|
||||
readonly passwordCharacters = '0123456789abcdefghijklmnopqrstuvwxyz';
|
||||
|
||||
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));
|
||||
}
|
||||
|
||||
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 add(replay: Replay) {
|
||||
const fullid = replay.id + (replay.password ? `-${replay.password}pw` : '');
|
||||
|
||||
// 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 id = ${replay.id}`;
|
||||
}
|
||||
return fullid;
|
||||
}
|
||||
|
||||
async get(id: string): Promise<Replay | null> {
|
||||
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: Replay) {
|
||||
const replayData = this.toReplayRow(replay);
|
||||
await replays.update(replay.id, {private: replayData.private, password: replayData.password});
|
||||
}
|
||||
|
||||
generatePassword(length = 31) {
|
||||
let password = '';
|
||||
for (let i = 0; i < length; i++) {
|
||||
password += this.passwordCharacters[Math.floor(Math.random() * this.passwordCharacters.length)];
|
||||
}
|
||||
|
||||
return password;
|
||||
}
|
||||
|
||||
search(args: {
|
||||
page?: number, isPrivate?: boolean, byRating?: boolean,
|
||||
format?: string, username?: string, username2?: string,
|
||||
}): Promise<Replay[]> {
|
||||
const page = args.page || 0;
|
||||
if (page > 100) return Promise.resolve([]);
|
||||
|
||||
let limit1 = 50 * (page - 1);
|
||||
if (limit1 < 0) limit1 = 0;
|
||||
|
||||
const isPrivate = args.isPrivate ? 1 : 0;
|
||||
|
||||
const format = args.format ? toID(args.format) : null;
|
||||
|
||||
if (args.username) {
|
||||
const order = args.byRating ? SQL`ORDER BY rating DESC` : SQL`ORDER BY uploadtime DESC`;
|
||||
const userid = toID(args.username);
|
||||
if (args.username2) {
|
||||
const userid2 = toID(args.username2);
|
||||
if (format) {
|
||||
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} LIMIT ${limit1}, 51;`.then(this.toReplays);
|
||||
} else {
|
||||
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} LIMIT ${limit1}, 51;`.then(this.toReplays);
|
||||
}
|
||||
} else {
|
||||
if (format) {
|
||||
return replays.query()`SELECT uploadtime, id, format, players, rating, password FROM replayplayers
|
||||
WHERE playerid = ${userid} AND formatid = ${format} AND private = ${isPrivate}
|
||||
${order} LIMIT ${limit1}, 51;`.then(this.toReplays);
|
||||
} else {
|
||||
return replays.query()`SELECT uploadtime, id, format, players, rating, password FROM replayplayers
|
||||
WHERE playerid = ${userid} private = ${isPrivate}
|
||||
${order} LIMIT ${limit1}, 51;`.then(this.toReplays);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (args.byRating) {
|
||||
return replays.query()`SELECT uploadtime, id, format, players, rating, password
|
||||
FROM replays
|
||||
WHERE private = ${isPrivate} AND formatid = ${format} ORDER BY rating DESC LIMIT ${limit1}, 51`
|
||||
.then(this.toReplays);
|
||||
} else {
|
||||
return replays.query()`SELECT uploadtime, id, format, players, rating, password
|
||||
FROM replays
|
||||
WHERE private = ${isPrivate} AND formatid = ${format} ORDER BY uploadtime DESC LIMIT ${limit1}, 51`
|
||||
.then(this.toReplays);
|
||||
}
|
||||
}
|
||||
|
||||
fullSearch(term: string, page = 0): Promise<Replay[]> {
|
||||
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 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, players, rating FROM ps_replays
|
||||
WHERE private = 0 AND log LIKE ${patterns[0]} ${secondPattern}
|
||||
ORDER BY uploadtime DESC LIMIT 10;`.then(this.toReplays);
|
||||
}
|
||||
|
||||
recent() {
|
||||
return replays.selectAll(
|
||||
SQL`uploadtime, id, format, players, rating`
|
||||
)`WHERE private = 0 ORDER BY uploadtime DESC LIMIT 50`.then(this.toReplays);
|
||||
}
|
||||
};
|
||||
|
||||
export default Replays;
|
||||
|
|
@ -535,7 +535,7 @@ export class RoomBattle extends RoomGames.RoomGame<RoomBattlePlayer> {
|
|||
ended: boolean;
|
||||
active: boolean;
|
||||
needsRejoin: Set<ID> | null;
|
||||
replaySaved: boolean;
|
||||
replaySaved: boolean | 'auto';
|
||||
forcedSettings: {modchat?: string | null, privacy?: string | null} = {};
|
||||
p1: RoomBattlePlayer;
|
||||
p2: RoomBattlePlayer;
|
||||
|
|
@ -920,7 +920,8 @@ export class RoomBattle extends RoomGames.RoomGame<RoomBattlePlayer> {
|
|||
if (this.replaySaved || Config.autosavereplays) {
|
||||
const uploader = Users.get(winnerid || p1id);
|
||||
if (uploader?.connections[0]) {
|
||||
Chat.parse('/savereplay silent', this.room, uploader, uploader.connections[0]);
|
||||
const command = Config.autosavereplays === 'private' ? '/savereplay auto' : '/savereplay silent';
|
||||
Chat.parse(command, this.room, uploader, uploader.connections[0]);
|
||||
}
|
||||
}
|
||||
const parentGame = this.room.parent && this.room.parent.game;
|
||||
|
|
|
|||
|
|
@ -42,6 +42,7 @@ import {Roomlogs} from './roomlogs';
|
|||
import * as crypto from 'crypto';
|
||||
import {RoomAuth} from './user-groups';
|
||||
import {PartialModlogEntry, mainModlog} from './modlog';
|
||||
import {Replays} from './replays';
|
||||
|
||||
/*********************************************************
|
||||
* the Room object.
|
||||
|
|
@ -2037,7 +2038,7 @@ export class GameRoom extends BasicRoom {
|
|||
* That's why this function requires a connection. For details, see the top
|
||||
* comment inside this function.
|
||||
*/
|
||||
async uploadReplay(user: User, connection: Connection, options?: 'forpunishment' | 'silent') {
|
||||
async uploadReplay(user?: User, connection?: Connection, options?: 'forpunishment' | 'silent' | 'auto') {
|
||||
// The reason we don't upload directly to the loginserver, unlike every
|
||||
// other interaction with the loginserver, is because it takes so much
|
||||
// bandwidth that it can get identified as a DoS attack by PHP, Apache, or
|
||||
|
|
@ -2066,16 +2067,61 @@ export class GameRoom extends BasicRoom {
|
|||
if (format.team && battle.ended) hideDetails = false;
|
||||
|
||||
const data = this.getLog(hideDetails ? 0 : -1);
|
||||
const datahash = crypto.createHash('md5').update(data.replace(/[^(\x20-\x7F)]+/g, '')).digest('hex');
|
||||
let rating = 0;
|
||||
if (battle.ended && this.rated) rating = this.rated;
|
||||
const {id, password} = this.getReplayData();
|
||||
const silent = options === 'forpunishment' || options === 'silent' || options === 'auto';
|
||||
const hidden = options === 'forpunishment' || (this as any).unlistReplay ? 2 :
|
||||
this.settings.isPrivate || this.hideReplay ? 1 :
|
||||
options === 'auto' ? 2 :
|
||||
0;
|
||||
|
||||
if (battle.replaySaved !== true && options === 'auto') {
|
||||
battle.replaySaved = 'auto';
|
||||
} else {
|
||||
battle.replaySaved = true;
|
||||
}
|
||||
|
||||
// If we have a direct connetion to a Replays database, just upload the replay
|
||||
// directly.
|
||||
|
||||
if (Replays.db) {
|
||||
const idWithServer = Config.serverid === 'showdown' ? id : `${Config.serverid}-${id}`;
|
||||
try {
|
||||
const fullid = await Replays.add({
|
||||
id: idWithServer,
|
||||
log: data,
|
||||
players: [battle.p1.name, battle.p2.name],
|
||||
format: format.name,
|
||||
rating: rating || null,
|
||||
private: hidden,
|
||||
password,
|
||||
inputlog: battle.inputLog?.join('\n') || null,
|
||||
uploadtime: Math.trunc(Date.now() / 1000),
|
||||
});
|
||||
if (!silent) {
|
||||
const url = `https://${Config.routes.replays}/${fullid}`;
|
||||
connection?.popup(
|
||||
`|html|<p>Your replay has been uploaded! It's available at:</p><p> <a class="no-panel-intercept" href="${url}" target="_blank">${url}</a>`
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
if (!silent) {
|
||||
connection?.popup(`Your replay could not be saved: ${e}`);
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// requires connection
|
||||
if (!connection) return;
|
||||
|
||||
// STEP 1: Directly tell the login server that a replay is coming
|
||||
// (also include all the data, including a hash of the replay itself,
|
||||
// so it can't be spoofed.)
|
||||
|
||||
battle.replaySaved = true;
|
||||
const datahash = crypto.createHash('md5').update(data.replace(/[^(\x20-\x7F)]+/g, '')).digest('hex');
|
||||
const [success] = await LoginServer.request('prepreplay', {
|
||||
id: id,
|
||||
loghash: datahash,
|
||||
|
|
@ -2083,8 +2129,7 @@ export class GameRoom extends BasicRoom {
|
|||
p2: battle.p2.name,
|
||||
format: format.id,
|
||||
rating,
|
||||
hidden: options === 'forpunishment' || (this as any).unlistReplay ?
|
||||
'2' : this.settings.isPrivate || this.hideReplay ? '1' : '',
|
||||
hidden,
|
||||
inputlog: battle.inputLog?.join('\n') || null,
|
||||
});
|
||||
if (success?.errorip) {
|
||||
|
|
@ -2098,7 +2143,7 @@ export class GameRoom extends BasicRoom {
|
|||
log: data,
|
||||
id: id,
|
||||
password: password,
|
||||
silent: options === 'forpunishment' || options === 'silent',
|
||||
silent,
|
||||
}));
|
||||
}
|
||||
|
||||
|
|
@ -2263,4 +2308,6 @@ export const Rooms = {
|
|||
RoomBattlePlayer,
|
||||
RoomBattleTimer,
|
||||
PM: RoomBattlePM,
|
||||
|
||||
Replays,
|
||||
};
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user