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:
Guangcong Luo 2023-12-02 11:07:08 -05:00
parent 13c8c19064
commit 6b42b4f6b2
6 changed files with 3669 additions and 26 deletions

379
lib/database.ts Normal file
View 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

File diff suppressed because it is too large Load Diff

View File

@ -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
View 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;

View File

@ -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;

View File

@ -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,
};