This commit is contained in:
Mia 2021-10-11 20:47:59 -05:00
commit 794f814bf2
25 changed files with 3196 additions and 0 deletions

277
.eslintrc.json Normal file
View File

@ -0,0 +1,277 @@
{
"parserOptions": {
"ecmaVersion": 11,
"sourceType": "module",
"ecmaFeatures": {
"globalReturn": true
}
},
"ignorePatterns": ["node_modules/", "dist/"],
"env": {
"es6": true,
"node": true
},
"extends": "eslint:recommended",
"rules": {
"no-console": "off",
// bad code, modern (new code patterns we don't like because they're less readable or performant)
"no-restricted-globals": ["error", "Proxy", "Reflect", "WeakSet"],
// bad code, deprecated (deprecated/bad patterns that should be written a different way)
"eqeqeq": "error",
"func-names": "off", // has minor advantages but way too verbose, hurting readability
"guard-for-in": "off", // guarding is a deprecated pattern, we just use no-extend-native instead
"init-declarations": "off", // TypeScript lets us delay initialization safely
"no-caller": "error",
"no-eval": "error",
"no-extend-native": "error",
"no-implied-eval": "error",
"no-inner-declarations": ["error", "functions"],
"no-iterator": "error",
"no-labels": ["error", {"allowLoop": true, "allowSwitch": true}],
"no-multi-str": "error",
"no-new-func": "error",
"no-new-wrappers": "error",
"no-proto": "error",
"no-restricted-syntax": ["error", "WithStatement"],
"no-sparse-arrays": "error",
"no-var": "error",
"no-with": "error",
// probably bugs (code with no reason to exist, probably typos)
"array-callback-return": "error",
"block-scoped-var": "error", // not actually used; precluded by no-var
"callback-return": [2, ["callback", "cb", "done"]],
"consistent-this": "off", // we use arrow functions instead
"constructor-super": "error",
"default-case": "off", // hopefully TypeScript will let us skip `default` for things that are exhaustive
"no-case-declarations": "off", // meh, we have no-shadow
"no-duplicate-case": "error",
"no-empty": ["error", {"allowEmptyCatch": true}],
"no-extra-bind": "error",
"no-extra-label": "error",
"no-fallthrough": "error",
"no-label-var": "error",
"no-new-require": "error",
"no-new": "error",
"no-redeclare": "off", // Useful with type namespaces
"no-self-compare": "error",
"no-sequences": "error",
"no-shadow-restricted-names": "error",
"no-shadow": "error",
"no-template-curly-in-string": "error",
"no-throw-literal": "error",
"no-undef": ["error", {"typeof": true}],
"no-unmodified-loop-condition": "error",
"no-unused-expressions": "error",
"no-unsafe-finally": "error",
"no-unused-labels": "error",
"no-use-before-define": ["error", {"functions": false, "classes": false, "variables": false}],
"use-isnan": "error",
"valid-typeof": "error",
// style choices
"no-constant-condition": ["error", {"checkLoops": false}],
"no-lonely-if": "off",
"radix": ["error", "as-needed"],
// naming style
"camelcase": "off", // mostly only so we can import `child_process`
"id-length": "off",
"id-match": "off",
"new-cap": ["error", {"newIsCap": true, "capIsNew": false}],
"no-underscore-dangle": "off",
// syntax style (local syntactical, usually autofixable formatting decisions)
"arrow-parens": "off",
"arrow-body-style": "error",
"brace-style": ["error", "1tbs", {"allowSingleLine": true}],
"comma-dangle": ["error", {
"arrays": "always-multiline",
"objects": "always-multiline",
"imports": "always-multiline",
"exports": "always-multiline",
"functions": "ignore"
}],
"comma-style": ["error", "last"],
"curly": ["error", "multi-line", "consistent"],
"dot-notation": "off",
"max-len": ["error", {"code": 120, "ignoreUrls": true}],
"new-parens": "error",
"no-array-constructor": "error",
"no-div-regex": "error",
"no-duplicate-imports": "error",
"no-extra-parens": "off",
"no-floating-decimal": "error",
"no-mixed-requires": "error",
"no-multi-spaces": "error",
"no-new-object": "error",
"no-octal-escape": "error",
"no-return-assign": ["error", "except-parens"],
"no-undef-init": "off",
"no-unneeded-ternary": "error",
"no-useless-call": "error",
"no-useless-computed-key": "error",
"no-useless-concat": "off",
"no-useless-rename": "error",
"object-shorthand": ["error", "methods"],
"one-var": "off",
"operator-assignment": "off",
"prefer-arrow-callback": "off",
"prefer-const": ["error", {"destructuring": "all"}],
"quote-props": "off",
"quotes": "off",
"semi": ["error", "always"],
"sort-vars": "off",
"vars-on-top": "off",
"wrap-iife": ["error", "inside"],
"wrap-regex": "off",
"yoda": ["error", "never", { "exceptRange": true }],
// whitespace
"array-bracket-spacing": ["error", "never"],
"arrow-spacing": ["error", {"before": true, "after": true}],
"block-spacing": ["error", "always"],
"comma-spacing": ["error", {"before": false, "after": true}],
"computed-property-spacing": ["error", "never"],
"dot-location": ["error", "property"],
"eol-last": ["error", "always"],
"func-call-spacing": "error",
"function-paren-newline": ["error", "consistent"],
"indent": [2, "tab"],
"key-spacing": "error",
"keyword-spacing": ["error", {"before": true, "after": true}],
"lines-around-comment": "off",
"no-mixed-spaces-and-tabs": "error",
"no-multiple-empty-lines": ["error", {"max": 2, "maxEOF": 1}],
"no-trailing-spaces": ["error", {"ignoreComments": false}],
"object-curly-spacing": ["error", "never"],
"operator-linebreak": ["error", "after", { "overrides": { "?": "before", ":": "before" } }],
"padded-blocks": ["error", "never"],
"padding-line-between-statements": "off",
"rest-spread-spacing": ["error", "never"],
"semi-spacing": ["error", {"before": false, "after": true}],
"space-before-blocks": ["error", "always"],
"space-before-function-paren": ["error", {"anonymous": "always", "named": "never"}],
"spaced-comment": ["error", "always", {"exceptions": ["*"]}],
"space-in-parens": ["error", "never"],
"space-infix-ops": "error",
"space-unary-ops": ["error", {"words": true, "nonwords": false}],
"template-curly-spacing": ["error", "never"]
},
"overrides": [
{
"files": ["**/*.ts", "**/*.tsx", "**/*.test.ts"],
"parser": "@typescript-eslint/parser",
"parserOptions": {
"ecmaVersion": 11,
"sourceType": "module",
"tsconfigRootDir": ".",
"project": ["./tsconfig.json"]
},
"extends": [
"plugin:@typescript-eslint/recommended",
"plugin:@typescript-eslint/recommended-requiring-type-checking"
],
"plugins": [
"import"
],
"rules": {
// TODO revisit
"@typescript-eslint/explicit-function-return-type": "off",
"@typescript-eslint/member-ordering": "off",
// "@typescript-eslint/no-extraneous-class": "error",
// "@typescript-eslint/no-type-alias": "error",
"@typescript-eslint/no-namespace": "off",
"new-parens": "off", // used for the `new class {...}` pattern
"no-prototype-builtins": "off",
// typescript-eslint defaults too strict
"@typescript-eslint/ban-ts-comment": "off",
"@typescript-eslint/no-empty-function": "off",
"@typescript-eslint/no-explicit-any": "off",
"@typescript-eslint/no-non-null-assertion": "off",
"@typescript-eslint/unbound-method": ["error", {"ignoreStatic": true}],
// disable additional typescript-eslint 3.0 defaults
"@typescript-eslint/explicit-module-boundary-types": "off",
"@typescript-eslint/no-unsafe-member-access": "off",
"@typescript-eslint/no-unsafe-assignment": "off",
"@typescript-eslint/no-unsafe-call": "off",
"@typescript-eslint/no-unsafe-return": "off",
// probably bugs
"@typescript-eslint/ban-types": "error",
"@typescript-eslint/no-dupe-class-members": "error",
"@typescript-eslint/no-empty-interface": "error",
"@typescript-eslint/no-extra-non-null-assertion": "error",
"@typescript-eslint/no-misused-new": "error",
"@typescript-eslint/no-non-null-asserted-optional-chain": "error",
"@typescript-eslint/return-await": ["error", "in-try-catch"],
"import/no-extraneous-dependencies": "error",
"no-dupe-class-members": "off",
"no-unused-expressions": ["error", {"allowTernary": true}], // ternary is used to convert callbacks to Promises
// naming style
"@typescript-eslint/camelcase": "off",
// syntax style (local syntactical, usually autofixable formatting decisions)
"@typescript-eslint/adjacent-overload-signatures": "error",
// "@typescript-eslint/array-type": "error",
"@typescript-eslint/consistent-type-assertions": ["error", {"assertionStyle": "as"}],
"@typescript-eslint/consistent-type-definitions": ["error", "interface"],
"@typescript-eslint/explicit-member-accessibility": ["error", {"accessibility": "no-public"}],
"@typescript-eslint/member-delimiter-style": "error",
"@typescript-eslint/no-parameter-properties": "error",
"@typescript-eslint/no-this-alias": "error",
"@typescript-eslint/no-var-requires": "off",
"@typescript-eslint/prefer-as-const": "error",
"@typescript-eslint/prefer-for-of": "error",
"@typescript-eslint/prefer-function-type": "error",
"@typescript-eslint/prefer-includes": "error",
"@typescript-eslint/prefer-namespace-keyword": "error",
"prefer-object-spread": "error",
"@typescript-eslint/prefer-optional-chain": "error",
"@typescript-eslint/triple-slash-reference": "error",
"@typescript-eslint/unified-signatures": "error",
"quotes": "off",
"semi": "off",
"@typescript-eslint/semi": ["error", "always"],
// whitespace
"@typescript-eslint/type-annotation-spacing": "error",
"spaced-comment": ["error", "always", {"exceptions": ["*", "/"]}],
// overriding base
"@typescript-eslint/indent": ["error", "tab", {"flatTernaryExpressions": true}],
"no-use-before-define": "off",
"@typescript-eslint/no-use-before-define": ["error", {"functions": false, "classes": false, "variables": false}],
"no-shadow": "off",
"@typescript-eslint/no-shadow": ["error"],
// types
"@typescript-eslint/restrict-plus-operands": "off",
"@typescript-eslint/restrict-template-expressions": "off",
"@typescript-eslint/prefer-string-starts-ends-with": "off",
// "@typescript-eslint/switch-exhaustiveness-check": "error",
// types - probably bugs
"@typescript-eslint/no-floating-promises": [
"error", {"ignoreVoid": true, "ignoreIIFE": true}
],
"@typescript-eslint/no-for-in-array": "error",
"@typescript-eslint/no-misused-promises": "error",
"@typescript-eslint/no-throw-literal": "error",
// syntax style (local syntactical, usually autofixable formatting decisions)
"@typescript-eslint/no-unnecessary-qualifier": "off",
"@typescript-eslint/no-unnecessary-type-arguments": "error",
"@typescript-eslint/no-unnecessary-type-assertion": "error",
"@typescript-eslint/prefer-regexp-exec": "error"
}
}
]
}

29
.github/workflows/test.yml vendored Normal file
View File

@ -0,0 +1,29 @@
# This workflow will do an install of node dependencies, build the source code and run tests across different versions of node
# For more information see: https://help.github.com/actions/language-and-framework-guides/using-nodejs-with-github-actions
name: Node.js CI
on:
push:
branches: [ master ]
pull_request:
branches: [ master ]
jobs:
build:
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [14.x]
steps:
- uses: actions/checkout@v2
- name: Use Node.js ${{ matrix.node-version }}
with:
node-version: ${{ matrix.node-version }}
- run: npm install
- run: npm run lint
env:
CI: true

3
.gitignore vendored Normal file
View File

@ -0,0 +1,3 @@
node_modules
.dist
.eslintcache

1
.npmrc Normal file
View File

@ -0,0 +1 @@
package-lock=false

9
README.md Normal file
View File

@ -0,0 +1,9 @@
**Pokemon Showdown Loginserver.**
This is the PS loginserver.
Build it with `npm run build`, and run it with `npm run start`.
You can access it via either `/action.php`, or `/api/[desired action]`.
See `src/actions.ts` for a list of the actions.
(Actions can be added by adding a function to that file)

33
package.json Normal file
View File

@ -0,0 +1,33 @@
{
"name": "pokemon-showdown-login",
"version": "0.1.0",
"homepage": "https://pokemonshowdown.com",
"license": "AGPL-3.0",
"scripts": {
"lint": "eslint . --cache --ext .js,.ts",
"build": "npx tsc",
"start": "npm run build && npx pm2 start .dist"
},
"dependencies": {
"@types/node": "^15.12.4",
"bcrypt": "^5.0.1",
"eslint-plugin-import": "^2.24.2",
"google-auth-library": "^3.1.2",
"mysql": "^2.18.1",
"pm2": "^5.1.2",
"sql-template-strings": "^2.2.2"
},
"devDependencies": {
"@types/bcrypt": "^5.0.0",
"@types/mocha": "^5.2.6",
"@types/mysql": "^2.15.18",
"@typescript-eslint/eslint-plugin": "^4.30.0",
"@typescript-eslint/parser": "^4.30.0",
"eslint": "^7.32.0",
"eslint-plugin-import": "^2.22.1",
"mocha": "^6.0.2",
"nodemailer": "^6.6.5",
"typescript": "^4.4.3"
},
"private": true
}

329
src/actions.ts Normal file
View File

@ -0,0 +1,329 @@
/**
* This file handles all loginserver actions. Each of these can be requested by making a request to
* /api/actionname, or to action.php?act=actname
* By Mia
* @author mia-pi-git
*/
import {Config} from './config-loader';
import {ActionError, Dispatcher, QueryHandler} from './dispatcher';
import * as fs from 'fs';
import SQL from 'sql-template-strings';
import {NTBBLadder} from './ladder';
import {Replays} from './replays';
import {toID} from './server';
import * as tables from './tables';
export const actions: {[k: string]: QueryHandler} = {
async register(params) {
const {username, password, cpassword, captcha} = params;
if (!username) {
throw new ActionError(`You must specify a username.`);
}
const userid = toID(username);
if (!/[a-z]/.test(userid)) {
throw new ActionError(`Your username must include at least one letter.`);
}
if (!password) {
throw new ActionError('You must specify a password.');
}
if (userid.startsWith('guest')) {
throw new ActionError(`Your username cannot start with 'guest'.`);
}
if (password.replace(/\s/ig, '').length < 5) {
throw new ActionError(`Your password must have at least 5 characters.`);
}
if (password !== cpassword) {
throw new ActionError(`Your passwords do not match.`);
}
if (toID(captcha) !== 'pikachu') {
throw new ActionError(`Answer the anti-spam question.`);
}
const regcount = await this.session.getRecentRegistrationCount(2 * 60 * 60);
if (regcount && regcount > 2) {
throw new ActionError(`You cannot register more than 2 names every 2 hours.`);
}
const user = await this.session.addUser(username, password);
if (user === null) {
throw new ActionError(`Your username is already taken.`);
}
const challengekeyid = parseInt(params.challengekeyid) || -1;
const challenge = params.challstr || "";
if (!challenge) throw new ActionError(`Invalid challenge string argument.`);
const assertion = await this.session.getAssertion(userid, challengekeyid, user, challenge);
return {
assertion,
actionsuccess: !assertion.startsWith(';'),
curuser: await user.getData(),
};
},
async logout(params) {
if (
this.request.method !== "POST" || !params.userid ||
params.userid !== this.user.id || this.user.id === 'guest'
) {
return {actionsuccess: false};
}
await this.session.logout();
return {actionsuccess: true};
},
async login(params) {
const challengeprefix = this.verifyCrossDomainRequest();
if (this.request.method !== 'POST') {
throw new ActionError(`For security reasons, logins must happen with POST data.`);
}
const userid = toID(params.name);
if (!userid || !params.pass) {
throw new ActionError(`incorrect login data, you need "name" and "pass" fields`);
}
const challengekeyid = parseInt(params.challengekeyid) || -1;
const actionsuccess = await this.session.login(params.name, params.pass);
if (!actionsuccess) return {actionsuccess, assertion: false};
const challenge = params.challstr || "";
const assertion = await this.session.getAssertion(
userid, challengekeyid, null, challenge, challengeprefix
);
this.session.updateCookie();
return {
actionsuccess: true,
assertion,
};
},
async updateuserstats(params) {
const server = this.getServer(true);
if (!server) {
return {actionsuccess: false};
}
const date = parseInt(params.date);
const usercount = parseInt(params.users || params.usercount);
if (isNaN(date) || isNaN(usercount)) {
return {actionsuccess: false};
}
await tables.userstats.insert({
serverid: server.id, date, usercount,
}, SQL`ON DUPLICATE KEY UPDATE \`date\`= ${date}, \`usercount\`= ${usercount}`);
if (server.id === Config.mainserver) {
await tables.userstatshistory.insert({date, usercount});
}
return {actionsuccess: true};
},
async upkeep(params) {
const challengeprefix = this.verifyCrossDomainRequest();
const res: {[k: string]: any} = {};
const curuser = this.user;
res.loggedin = curuser.loggedin;
let userid = '';
if (curuser.loggedin) {
res.username = curuser.name;
userid = curuser.id;
} else if (this.cookies.get('showdown_username')) {
res.username = this.cookies.get('showdown_username')!;
userid = toID(res.username);
}
if (userid !== '') {
const challengekeyid = !params.challengekeyid ? -1 : parseInt(params.challengekeyid);
const challenge = params.challstr || "";
res.assertion = await this.session.getAssertion(
userid, challengekeyid, curuser, challenge, challengeprefix
);
}
return res;
},
async json(params) {
const server = this.getServer(true);
if (!server) {
throw new ActionError(`Only registered servers can make more than one request.`);
}
if (!params.json) throw new ActionError(`No JSON sent.`);
let json: any[];
try {
json = JSON.parse(params.json);
} catch {
throw new ActionError(`Invalid JSON sent.`);
}
if (!Array.isArray(json)) {
throw new ActionError(`JSON sent must be an array of requests.`);
}
if (server.server !== Config.mainserver && json.length > 20) {
throw new ActionError(`Too many requests were sent. Send them in batches of 20.`);
}
const results = [];
for (const request of json) {
const dispatcher = new Dispatcher(this.request, this.response, {body: request});
try {
if (!request.act) throw new ActionError(`Must send a request type.`);
const data = await dispatcher.executeActions();
results.push(data);
} catch (e: any) {
if (e.name?.endsWith('ActionError')) {
results.push({actionerror: e.message});
continue;
}
throw e;
}
}
return results;
},
async prepreplay(params) {
const server = this.getServer(true);
let out: any = {};
if (!server) {
out.errorip = this.getIp();
return;
}
const extractedFormatId = /^([a-z0-9]+)-[0-9]+$/.exec((`${params.id}`));
const formatId = /^([a-z0-9]+)$/.exec((`${params.format}`));
if (
// the server must be registered
!server ||
// 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 ID must be valid
!formatId ||
// the format from the battle ID must match the format ID
(formatId[1] !== extractedFormatId[1])
) {
return 0;
}
if (server.id !== Config.mainserver) {
params.id = server.id + '-' + params.id;
}
params.serverid = server.id;
out = await Replays.prep(params);
this.setPrefix(''); // No need for prefix since only usable by server.
return out;
},
uploadreplay(params) {
this.setHeader('Content-Type', 'text/plain; charset=utf-8');
return Replays.upload(params, this);
},
invalidatecss() {
const server = this.getServer(true);
if (!server) {
return {errorip: this.getIp()};
}
// No need to sanitise server['id'] because it should be safe already.
const cssfile = `${__dirname}/../config/customcss/${server['id']}.css`;
return new Promise<{actionsuccess: boolean}>(resolve => {
fs.unlink(cssfile, err => resolve({actionsuccess: !err}));
});
},
async changepassword(params) {
if (this.request.method !== 'POST') {
throw new ActionError(`'changepassword' requests can only be made with POST data.`);
}
if (!params.oldpassword) {
throw new ActionError(`Specify your current password.`);
}
if (!params.password) {
throw new ActionError(`Specify your new password.`);
}
if (!params.cpassword) {
throw new ActionError(`Repeat your new password.`);
}
if (!this.user.loggedin) {
throw new ActionError('Your session has expired. Please log in again.');
}
if (params.password !== params.cpassword) {
throw new ActionError('Your new passwords do not match.');
}
if (!(await this.session.passwordVerify(this.user.id, params.oldpassword))) {
throw new ActionError('Your old password was incorrect.');
}
params.password = params.password.replace(/\s/ig, '');
if (params.password.length < 5) {
throw new ActionError('Your new password must be at least 5 characters long.');
}
const actionsuccess = await this.session.changePassword(this.user.id, params.password);
return {actionsuccess};
},
async changeusername(params) {
if (this.request.method !== 'POST') {
throw new ActionError('Invalid request (username changing must be done through POST requests).');
}
if (!params.username) {
throw new ActionError(`Specify a username.`);
}
if (!this.user.loggedin) {
throw new ActionError('Your session has expired. Please log in again.');
}
// safe to use userid directly because we've confirmed they've logged in.
const actionsuccess = await tables.users.update(this.user.id, {
username: params.username,
}).catch(() => false);
this.session.updateCookie();
return {actionsuccess};
},
async ladderupdate(params) {
const server = this.getServer(true);
if (server?.id !== Config.mainserver) {
return {errorip: "Your version of PS is too old for this ladder system. Please update."};
}
if (!params.format) throw new ActionError("Invalid format.");
const ladder = new NTBBLadder(params.format);
const p1 = NTBBLadder.getUserData(params.p1);
const p2 = NTBBLadder.getUserData(params.p2);
if (!p1 || !p2) {
// The server should not send usernames > 18 characters long.
// (getUserData returns falsy when the usernames are too long)
return 0;
}
const out: {[k: string]: any} = {};
await ladder.updateRating(p1, p2, parseFloat(params.score));
out.actionsuccess = true;
out.p1rating = p1.rating;
out.p2rating = p2.rating;
delete out.p1rating.rpdata;
delete out.p2rating.rpdata;
this.setPrefix(''); // No need for prefix since only usable by server.
return out;
},
async ladderget(params) {
const server = this.getServer(true);
if (server?.id !== Config.mainserver) {
return {errorip: true};
}
if (!params.format) throw new ActionError(`Specify a format.`);
const ladder = new NTBBLadder(params.format);
const user = NTBBLadder.getUserData(params.user);
if (!user) return {errorip: true};
await ladder.getAllRatings(user);
return user.ratings;
},
async mmr(params) {
const server = this.getServer(true);
if (server?.id !== Config.mainserver) {
return {errorip: 'Your version of PS is too old for this ladder system. Please update.'};
}
if (!params.format) throw new ActionError("Specify a format.");
const ladder = new NTBBLadder(params.format);
const user = NTBBLadder.getUserData(params.user);
let result = 1000;
if (!user) {
return result;
}
await ladder.getRating(user);
if (user.rating) {
result = user.rating.elo;
}
return result;
},
};

43
src/config-loader.ts Normal file
View File

@ -0,0 +1,43 @@
/**
* Config handling - here because of strange babel errors.
* By Mia
* @author mia-pi-git
*/
import * as fs from 'fs';
import * as path from 'path';
// @ts-ignore no typedef file
import * as defaults from '../config/ls-config-example';
export type Configuration = typeof defaults & {[k: string]: any};
export function load(invalidate = false): Configuration {
if (invalidate) delete require.cache[path.resolve(__dirname, '../config/ls-config')];
const config: typeof defaults & {[k: string]: any} = defaults;
try {
Object.assign(config, require('../config/ls-config'));
} catch (err: any) {
if (err.code !== 'MODULE_NOT_FOUND' && err.code !== 'ENOENT') throw err; // Should never happen
console.log("config.js doesn't exist - creating one with default settings...");
fs.writeFileSync(
path.resolve(__dirname, '../config/ls-config.js'),
fs.readFileSync(path.resolve(__dirname, '../config/ls-config-example.js'))
);
}
// we really don't need to load from here afterwards since
// the configuration in the new ls-config will be the same as defaults.
if (!config.routes) {
config.routes = require('../config/routes');
}
if (!config.mainserver) {
config.mainserver = 'showdown';
}
return config;
}
export const Config = load();
if (Config.watchconfig) {
fs.watchFile(require.resolve('../config/ls-config'), () => ({...Config, ...load(true)}));
}

213
src/database.ts Normal file
View File

@ -0,0 +1,213 @@
/**
* Promise database implementation, with stricter typing.
* By Mia
* @author mia-pi-git
*/
import * as mysql from 'mysql';
import SQL, {SQLStatement} from 'sql-template-strings';
import {Config} from './config-loader';
export type SQLInput = string | number | null;
export interface ResultRow {[k: string]: SQLInput}
export const databases: PSDatabase[] = [];
export class PSDatabase {
pool: mysql.Pool;
prefix: string;
constructor(config: {[k: string]: any} = Config.mysql) {
this.pool = mysql.createPool(config);
this.prefix = config.prefix || 'ntbb_';
if (!databases.includes(this)) databases.push(this);
}
query<T = ResultRow>(query: SQLStatement) {
return new Promise<T[]>((resolve, reject) => {
this.pool.query(query.sql, query.values, (e, results) => {
if (e) {
return reject(
e.sqlMessage ? new Error(`${e.sqlMessage} ('${e.sql}') [${e.code}]`) : new Error(e.message)
);
}
if (Array.isArray(results)) {
for (const chunk of results) {
for (const k in chunk) {
if (Buffer.isBuffer(chunk[k])) chunk[k] = chunk[k].toString();
}
}
}
return resolve(results);
});
});
}
async get<T = ResultRow>(query: SQLStatement): Promise<T | null> {
// if (!queryString.includes('LIMIT')) queryString += ` LIMIT 1`;
// limit it yourself, consumers
const rows = await this.query(query);
if (Array.isArray(rows)) {
if (!rows.length) return null;
return rows[0] as unknown as T;
}
return rows ?? null;
}
async execute(query: SQLStatement): Promise<mysql.OkPacket> {
if (!['UPDATE', 'INSERT', 'DELETE', 'REPLACE'].some(i => query.sql.includes(i))) {
throw new Error('Use `query` or `get` for non-insertion / update statements.');
}
return this.get(query) as Promise<mysql.OkPacket>;
}
close() {
this.pool.end();
}
connect(config: {[k: string]: any}) {
this.pool = mysql.createPool(config);
}
}
// direct access
export const psdb = new PSDatabase();
export class DatabaseTable<T> {
database: PSDatabase;
name: string;
primaryKeyName: string;
constructor(
name: string,
primaryKeyName: string,
prefix = 'ntbb_',
config = Config.mysql
) {
this.name = name;
if (prefix) {
config.prefix = prefix;
}
this.database = config ? new PSDatabase(config) : psdb;
this.primaryKeyName = primaryKeyName;
}
async selectOne(
entries: string | string[],
where?: SQLStatement
): Promise<T | null> {
const query = where || SQL``;
query.append(' LIMIT 1');
const rows = await this.selectAll(entries, query);
return rows?.[0] || null;
}
selectAll(
entries: string | string[],
where?: SQLStatement
): Promise<T[]> {
const query = SQL`SELECT `;
if (typeof entries === 'string') {
query.append(' * ');
} else {
for (let i = 0; i < entries.length; i++) {
const key = entries[i];
query.append(this.format(key));
if (typeof entries[i + 1] !== 'undefined') query.append(', ');
}
query.append(' ');
}
query.append(`FROM ${this.getName()} `);
if (where) {
query.append(' WHERE ');
query.append(where);
}
return this.database.query<T>(query);
}
get(entries: string | string[], keyId: SQLInput) {
const query = SQL``;
query.append(this.format(this.primaryKeyName));
query.append(SQL` = ${keyId}`);
return this.selectOne(entries, query);
}
updateAll(toParams: Partial<T>, where?: SQLStatement, limit?: number) {
const to = Object.entries(toParams);
const query = SQL`UPDATE `;
query.append(this.getName() + ' SET ');
for (let i = 0; i < to.length; i++) {
const [k, v] = to[i];
query.append(`${this.format(k)} = `);
query.append(SQL`${v}`);
if (typeof to[i + 1] !== 'undefined') {
query.append(', ');
}
}
if (where) {
query.append(SQL` WHERE `);
query.append(where);
}
if (limit) query.append(SQL` LIMIT ${limit}`);
return this.database.execute(query);
}
updateOne(to: Partial<T>, where?: SQLStatement) {
return this.updateAll(to, where, 1);
}
private getName() {
return this.format((this.database.prefix || '') + this.name);
}
deleteAll(where?: SQLStatement, limit?: number) {
const query = SQL`DELETE FROM `;
query.append(this.getName());
if (where) {
query.append(' WHERE ');
query.append(where);
}
if (limit) {
query.append(SQL` LIMIT ${limit}`);
}
return this.database.execute(query);
}
delete(keyEntry: SQLInput) {
const query = SQL``;
query.append(this.format(this.primaryKeyName));
query.append(SQL` = ${keyEntry}`);
return this.deleteOne(query);
}
deleteOne(where: SQLStatement) {
return this.deleteAll(where, 1);
}
insert(colMap: Partial<T>, rest?: SQLStatement, isReplace = false) {
const query = SQL``;
query.append(`${isReplace ? 'REPLACE' : 'INSERT'} INTO ${this.getName()} (`);
const keys = Object.keys(colMap);
for (let i = 0; i < keys.length; i++) {
const key = keys[i];
query.append(this.format(key));
if (typeof keys[i + 1] !== 'undefined') query.append(', ');
}
query.append(') VALUES(');
for (let i = 0; i < keys.length; i++) {
const key = keys[i];
query.append(SQL`${colMap[key as keyof T]}`);
if (typeof keys[i + 1] !== 'undefined') query.append(', ');
}
query.append(') ');
if (rest) query.append(rest);
return this.database.execute(query);
}
replace(cols: Partial<T>, rest?: SQLStatement) {
return this.insert(cols, rest, true);
}
format(param: string) {
// todo: figure out a better way to do this. backticks are only needed
// for reserved words, but we tend to have a lot of those (like `session` in ntbb_sessions)
// so for now + consistency's sake, we're going to keep this. but we might be able to hardcode that out?
// not sure.
return `\`${param}\``;
}
update(primaryKey: SQLInput, data: Partial<T>) {
const query = SQL``;
query.append(this.primaryKeyName + ' = ');
query.append(SQL`${primaryKey}`);
return this.updateOne(data, query);
}
// catch-alls for "we can't fit this query into any of the wrapper functions"
query(sql: SQLStatement) {
return this.database.query<T>(sql);
}
execute(sql: SQLStatement) {
return this.database.execute(sql);
}
}

186
src/dispatcher.ts Normal file
View File

@ -0,0 +1,186 @@
/**
* Request handling.
* By Mia
* @author mia-pi-git
*/
import {actions} from './actions';
import * as child from 'child_process';
import {Config} from './config-loader';
import * as fs from 'fs';
import * as http from 'http';
import {Session} from './session';
import {User} from './user';
/**
* Throw this to end a request with an `actionerror` message.
*/
export class ActionError extends Error {
constructor(message: string) {
super(message);
this.name = 'ActionError';
Error.captureStackTrace(this, ActionError);
}
}
export interface RegisteredServer {
name: string;
id: string;
server: string;
port: number;
token?: string;
}
export type QueryHandler = (
this: Dispatcher, params: {[k: string]: string}
) => {[k: string]: any} | Promise<{[k: string]: any}>;
export interface DispatcherOpts {
body: {[k: string]: string | number};
act: string;
}
export class Dispatcher {
readonly request: http.IncomingMessage;
readonly response: http.ServerResponse;
readonly session: Session;
readonly user: User;
readonly opts: Partial<DispatcherOpts>;
readonly cookies: Map<string, string>;
private prefix: string | null = null;
constructor(
req: http.IncomingMessage,
res: http.ServerResponse,
opts: Partial<DispatcherOpts> = {}
) {
this.request = req;
this.response = res;
this.session = new Session(this);
this.user = new User(this.session);
this.opts = opts;
this.cookies = Dispatcher.parseCookie(this.request.headers.cookie);
}
async executeActions() {
const data = this.parseRequest();
if (data === null) {
return data;
}
const {act, body} = data;
if (!act) throw new ActionError('You must specify a request type.');
await this.session.checkLoggedIn();
const handler = actions[act];
if (!handler) {
throw new ActionError('That request type was not found.');
}
return handler.call(this, body);
}
parseRequest() {
const [pathname, queryString] = this.request.url?.split('?') || [];
const body: {[k: string]: any} = this.opts.body || {};
let act = body.act; // checking for an act in the preset body
if (!this.opts.body && queryString) {
const parts = queryString.split('&');
for (const [k, v] of parts.map(p => p.split('='))) body[k] = v;
}
// check for an act in the url body (parsing url body above)
if (body.act) act = body.act;
// legacy handling of action.php - todo remove
// (this is endsWith because we call /~~showdown/action.php a lot in the client)
if (act && pathname.endsWith('/action.php')) {
return {act, body};
}
if (pathname.includes('/api/')) {
// support requesting {server}/api/actionnname as well as
// action.php?act=actionname (TODO: deprecate action.php)
for (const action in actions) {
if (pathname.endsWith(`/api/${action}`)) {
return {act: action, body};
}
}
throw new ActionError('Invalid request passed to /api/. Request /api/{action} instead.');
}
return null;
}
verifyCrossDomainRequest(): string {
if (typeof this.prefix === 'string') return this.prefix;
// No cross-domain multi-requests for security reasons.
// No need to do anything if this isn't a cross-domain request.
const origin = this.request.headers.origin;
if (!origin) {
return '';
}
let prefix = null;
for (const [regex, host] of Config.cors) {
if (!regex.test(origin)) continue;
prefix = host;
}
if (prefix === null) {
// Bogus request.
return '';
}
// Valid CORS request.
this.setHeader('Access-Control-Allow-Origin', origin);
this.setHeader('Access-Control-Allow-Credentials', 'true');
this.prefix = prefix;
return prefix;
}
setPrefix(prefix: string) {
this.prefix = prefix;
}
getIp() {
const ip = this.request.socket.remoteAddress;
let forwarded = this.request.headers['x-forwarded-for'] || '';
if (!Array.isArray(forwarded)) forwarded = forwarded.split(',');
if (forwarded.length && Config.trustedproxies?.includes(ip)) {
return forwarded.pop() as string;
}
return ip || '';
}
setHeader(name: string, value: string | string[]) {
this.response.setHeader(name, value);
}
getServer(requireToken = false): RegisteredServer | null {
const body = this.parseRequest()?.body || {};
const server = Dispatcher.servers[body.serverid];
if (server) {
if (requireToken && server.token && (
!body.servertoken || body.servertoken !== server.token
)) {
throw new ActionError('You sent an invalid server token.');
}
return server;
}
return null;
}
static parseCookie(cookieString?: string) {
const list = new Map<string, string>();
if (!cookieString) return list;
const parts = cookieString.split(';');
for (const part of parts) {
const [curName, val] = part.split('=').map(i => i.trim());
list.set(curName, decodeURIComponent(val));
}
return list;
}
static servers: {[k: string]: RegisteredServer} = (() => {
const exists = (path: string) => fs.existsSync(`${__dirname}/../config/${path}`);
const servers = {};
try {
if (exists('servers.json')) {
return JSON.parse(fs.readFileSync(`${__dirname}/../config/servers.json`, 'utf-8'));
} else if (exists('servers.inc.php')) {
const stdout = child.execSync('php -f api/lib/load-servers.php').toString();
const json = JSON.parse(stdout);
fs.writeFileSync(`${__dirname}/../config/servers.json`, stdout);
return json;
} else {
return {};
}
} catch (e: any) {
if (e.code !== 'ENOENT') throw e;
}
return servers;
})();
static ActionError = ActionError;
}

29
src/index.ts Normal file
View File

@ -0,0 +1,29 @@
/**
* Initialization.
*/
import {Router} from './server';
export const server = new Router();
import {databases} from './database';
console.log(`Server listening on ${server.port}`);
process.on('uncaughtException', (err: Error) => {
console.log(`${err.message}\n${err.stack}`);
});
process.on('unhandledRejection', (err: Error) => {
console.log(`A promise crashed: ${err.message}\n${err.stack}`);
});
// graceful shutdown.
process.once('SIGINT', () => {
void server.close().then(() => {
// we are no longer accepting requests and all requests have been handled.
// now it's safe to close DBs
for (const database of databases) {
database.close();
}
process.exit(0);
});
});

468
src/ladder.ts Normal file
View File

@ -0,0 +1,468 @@
/**
* Ladder handling.
* Ported by Mia. Originally by Zarel.
* @author mia-pi-git
*/
import {time} from './session';
import {toID} from './server';
import {ladder} from './tables';
import SQL from 'sql-template-strings';
import type {User} from './user';
export interface LadderEntry {
entryid: number;
formatid: string;
userid: string;
username: string;
w: number;
l: number;
t: number;
gxe: number;
r: number;
rd: number;
sigma: number;
rptime: number;
rpr: number;
rprd: number;
rpsigma: number;
rpdata: string;
elo: number;
col1: number;
oldelo: number;
}
interface MatchElement {
R: number;
RD: number;
score: number;
}
export interface FakeUser {
name: string;
id: string;
rating?: LadderEntry;
ratings?: LadderEntry[];
}
export class NTBBLadder {
formatid: string;
rplen: number;
rpoffset: number;
constructor(format: string) {
this.formatid = toID(format);
this.rplen = 24 * 60 * 60;
this.rpoffset = 9 * 60 * 60;
}
getRP() {
const rpnum = ((time() - this.rpoffset) / this.rplen) + 1;
return rpnum * this.rplen + this.rpoffset;
}
nextRP(rp: number) {
const rpnum = (rp / this.rplen);
return (rpnum + 1) * this.rplen + this.rpoffset;
}
clearRating(name: string | User | FakeUser) {
return ladder.updateOne({
elo: 1000, col1: 0, w: 0, l: 0, t: 0,
}, SQL`userid = ${toID(name)} AND formatid = ${this.formatid}`);
}
clearWL(name: User | string) {
return ladder.updateOne({
w: 0, l: 0, t: 0,
}, SQL`userid = ${toID(name)} AND formatid = ${this.formatid}`);
}
async getRating(user: User | FakeUser, create = false) {
if (!user.rating) {
const data = await ladder.selectOne('*', SQL`userid = ${user.id} AND formatid = ${this.formatid}`);
if (!data) {
if (!create) {
return false;
}
const rp = this.getRP();
const res = await ladder.insert({
formatid: this.formatid, username: user.name, userid: user.id,
rptime: rp, rpdata: '', col1: 0,
});
user.rating = {
entryid: res.insertId,
formatid: this.formatid,
userid: user.id,
username: user.name,
r: 1500,
rd: 130,
sigma: 0,
rpr: 1500,
rprd: 130,
rpsigma: 0,
rptime: rp,
rpdata: '',
w: 0,
l: 0,
t: 0,
gxe: 50,
elo: 1000,
col1: 0,
oldelo: 0,
};
return user.rating;
}
user.rating = data;
}
return user.rating;
}
async getAllRatings(user: User | FakeUser) {
if (!user.ratings) {
const res = await ladder.selectAll('*', SQL`userid = ${user.id}`);
if (!res) {
return false;
}
user.ratings = res;
for (const row of user.ratings) {
delete (row as any).rpdata;
}
}
return true;
}
async getTop(prefix: string | null = null) {
let needUpdate = true;
let top = [];
let i = 0;
while (needUpdate) {
i++;
if (i > 2) break;
needUpdate = false;
top = [];
let res: LadderEntry[];
const limit = 500;
// if (isset($GLOBALS.curuser) && $GLOBALS.curuser.group != 0) {
// $limit = 1000;
// }
if (prefix) {
// The ladder database can't really handle large queries which aren't indexed, so we instead perform
// an indexed query for additional rows and filter them down further. This is obviously *not* guaranteed
// to return exactly $limit results, but should be 'good enough' in practice.
const overfetch = limit * 2;
const query = SQL`SELECT * FROM `;
query.append(SQL`(SELECT * FROM \`ntbb_ladder\` WHERE \`formatid\` = ${this.formatid} `);
query.append(SQL`ORDER BY \`elo\` DESC LIMIT ${limit})`);
query.append(SQL`AS \`unusedalias\` WHERE \`userid\` LIKE ${prefix} LIMIT ${overfetch}`);
res = await ladder.query(query);
} else {
res = await ladder.selectAll('*', SQL`formatid = ${this.formatid} ORDER BY elo DESC`);
}
for (const row of res) {
// if ($row.col1 < 0 && $j > 50) break;
const user: FakeUser = {
name: row.username,
id: row.userid,
rating: row,
};
if (this.update(user as FakeUser & {rating: LadderEntry})) {
await this.saveRating(user);
needUpdate = true;
}
delete (row as any).rpdata;
top.push(row);
}
}
return top;
}
clearAllRatings() {
return ladder.deleteAll(SQL`formatid = ${this.formatid}`);
}
async saveRating(user: User | FakeUser) {
if (!user.rating) return false;
const {
w, l, t, r, rd, sigma,
rptime, rpr, elo, rprd,
rpsigma, rpdata, gxe,
col1, entryid,
} = user.rating;
return !!(await ladder.update(entryid, {
elo, w, l, t, r, rd, sigma, rptime, rpr, rprd, rpsigma, rpdata, gxe, col1,
}));
}
update(
user: (FakeUser | User) & {rating: LadderEntry},
newM: MatchElement | false = false,
newMelo = 1000,
force = false
) {
let offset = 0;
const rp = this.getRP();
if (rp <= user.rating.rptime && !newM && !force) {
return false;
}
let elo = user.rating.elo;
const rating = new GlickoPlayer(user.rating.r, user.rating.rd);
if (user.rating.rpdata) {
const rpdata = user.rating.rpdata.split('##');
if (rpdata.length > 1) offset = parseFloat(rpdata[1]);
rating.m = JSON.parse(rpdata[0]);
}
if (rp > user.rating.rptime) {
let i = 0;
while (rp > user.rating.rptime) {
i++;
if (i > 1000) break;
// decay
if (elo >= 1400) {
let decay = 0;
if (rating.m.length > 5) {
// user was very active
} else if (rating.m) {
// user was active
decay = 0 + (elo - 1400) / 100;
} else {
// user was inactive
decay = 1 + (elo - 1400) / 50;
}
switch (this.formatid) {
case 'gen8randombattle':
case 'gen8ou':
break;
default:
decay -= 2;
break;
}
if (decay > 0) elo -= decay;
}
rating.update();
if (offset) {
rating.rating += offset;
offset = 0;
}
user.rating.rptime = this.nextRP(user.rating.rptime);
}
user.rating.r = rating.rating;
user.rating.rd = rating.rd;
user.rating.elo = elo;
}
if (!user.rating.col1) {
user.rating.col1 = user.rating.w + user.rating.l + user.rating.t;
}
if (newM) {
rating.m.push(newM);
if (newM.score > 0.99) {
user.rating.w++;
} else if (newM.score < 0.01) {
user.rating.l++;
} else {
user.rating.t++;
}
user.rating.col1++;
}
if (rating.m) {
user.rating.rpdata = JSON.stringify(rating.m);
} else {
user.rating.rpdata = '';
}
rating.update();
user.rating.rpr = rating.rating;
user.rating.rprd = rating.rd;
const exp = ((1500 - rating.rating) / 400 / Math.sqrt(1 + 0.0000100724 * (rating.rd * rating.rd + 130 * 130)));
user.rating.gxe = Math.round(
100 / (1 + Math.pow(10, exp))
);
// if ($newM) {
// // compensate for Glicko2 bug: don't lose rating on win, don't gain rating on lose
// if ($newM.score > .9 && $rating->rating < $oldrpr) {
// $delta = $oldrpr - $rating->rating;
// $offset += $delta;
// $user.rating.rpr += $delta;
// }
// if ($newM.score < .1 && $rating->rating > $oldrpr) {
// $delta = $oldrpr - $rating->rating;
// $offset += $delta;
// $user.rating.rpr += $delta;
// }
// }
if (offset) {
user.rating.rpdata += '##' + offset;
}
if (newM) {
user.rating.oldelo = elo;
let K = 50;
if (elo < 1100) {
if (newM.score < 0.5) {
K = 20 + (elo - 1000) * 30 / 100;
} else if (newM.score > 0.5) {
K = 80 - (elo - 1000) * 30 / 100;
}
} else if (elo > 1300) {
K = 40;
}
const E = 1 / (1 + Math.pow(10, (newMelo - elo) / 400));
elo += K * (newM.score - E);
if (elo < 1000) elo = 1000;
user.rating.elo = elo;
}
return true;
}
async updateRating(
p1: FakeUser | User,
p2: FakeUser | User,
p1score: number,
p1M?: MatchElement,
p2M?: MatchElement
) {
if (!p1.rating) await this.getRating(p1, true);
if (!p2.rating) await this.getRating(p2, true);
let p2score = 1 - p1score;
if (p1score < 0) {
p1score = 0;
p2score = 0;
}
if (!p1M) {
const p2rating = new GlickoPlayer(p2.rating!.r, p2.rating!.rd);
p1M = p2rating.matchElement(p1score)[0];
}
if (!p2M) {
const p1rating = new GlickoPlayer(p1.rating!.r, p1.rating!.rd);
p2M = p1rating.matchElement(p2score)[0];
}
p1M.score = p1score;
p2M.score = 1 - p1score;
const p1Melo = p2.rating!.elo;
const p2Melo = p1.rating!.elo;
this.update(p1 as (User | FakeUser) & {rating: LadderEntry}, p1M, p1Melo);
this.update(p2 as (User | FakeUser) & {rating: LadderEntry}, p2M, p2Melo);
return Promise.all(
[p1, p2].map(p => this.saveRating(p))
) as Promise<[boolean, boolean]>;
}
static getUserData(username?: string): FakeUser | null {
if (!username) username = '';
const userid = toID(username);
if (userid.length > 18 || !userid) return null;
return {
name: username, id: userid,
};
}
}
export class GlickoPlayer {
rating: number;
rd: number;
readonly piSquared = Math.PI ** 2;
readonly RDmax = 130.0;
readonly RDmin = 25.0;
c: number;
readonly q = 0.00575646273;
m: MatchElement[] = [];
constructor(rating = 1500, rd = 130.0) {
// Step 1
this.rating = rating;
this.rd = rd;
this.c = Math.sqrt((this.RDmax * this.RDmax - this.RDmin * this.RDmin) / 365.0);
}
addWin(otherPlayer: GlickoPlayer) {
this.m = otherPlayer.matchElement(1);
}
addLoss(otherPlayer: GlickoPlayer) {
this.m = otherPlayer.matchElement(0);
}
addDraw(otherPlayer: GlickoPlayer) {
this.m = otherPlayer.matchElement(0.5);
}
update() {
const results = this.addMatches(this.m);
this.rating = results.R;
this.rd = results.RD;
this.m = [];
}
matchElement(score: number) {
return [{
R: this.rating,
RD: this.rd,
score,
}];
}
addMatches(m: MatchElement[]) {
// This is where the Glicko rating calculation actually happens
// Follow along the steps using: http://www.glicko.net/glicko/glicko.pdf
if (m.length === 0) {
const RD = Math.sqrt((this.rd * this.rd) + (this.c * this.c));
return {R: this.rating, RD};
}
let A = 0.0;
let d2 = 0.0;
for (const cur of m) {
const E = this.E(this.rating, cur.R, cur.RD);
const g = this.g(cur.RD);
d2 += (g * g * E * (1 - E));
A += g * (cur.score - E);
}
d2 = 1.0 / this.q / this.q / d2;
let RD = 1.0 / Math.sqrt(1.0 / (this.rd * this.rd) + 1.0 / d2);
const R = this.rating + this.q * (RD * RD) * A;
if (RD > this.RDmax) {
RD = this.RDmax;
}
if (RD < this.RDmin) {
RD = this.RDmin;
}
return {R, RD};
}
g(RD: number) {
return 1.0 / Math.sqrt(1.0 + 3.0 * this.q * this.q * RD * RD / this.piSquared);
}
E(R: number, rJ: number, rdJ: number) {
return 1.0 / (1.0 + Math.pow(10.0, -this.g(rdJ) * (R - rJ) / 400.0));
}
}

7
src/lib/load-servers.php Normal file
View File

@ -0,0 +1,7 @@
<?php
include_once __DIR__ . '/../../config/servers.inc.php';
$json = json_encode($PokemonServers, JSON_FORCE_OBJECT);
if ($json === false) print("{}");
else print($json);

25
src/lib/validate-token.js Normal file
View File

@ -0,0 +1,25 @@
#!/usr/bin/env node
// mostly deprecated as api/session.ts handles this internally
// but i'm keeping it here just in case
const gal = require('google-auth-library');
const CLIENT_ID = '912270888098-jjnre816lsuhc5clj3vbcn4o2q7p4qvk.apps.googleusercontent.com';
const token = process.argv[2];
// make static on session
const client = new gal.OAuth2Client(CLIENT_ID, '', '');
client.verifyIdToken({
idToken: token,
audience: CLIENT_ID,
},
// Or, if multiple clients access the backend:
// [CLIENT_ID_1, CLIENT_ID_2, CLIENT_ID_3],
function (e, login) {
if (e) return console.log(e);
const payload = login?.getPayload() || {};
// var userid = payload['sub'];
console.log(JSON.stringify(payload));
// If request specified a G Suite domain:
// var domain = payload['hd'];
});

294
src/replays.ts Normal file
View File

@ -0,0 +1,294 @@
/**
* Code for uploading and managing replays.
*
* Ported to TypeScript by Annika and Mia.
*/
import * as crypto from 'crypto';
import {ActionError, Dispatcher} from './dispatcher';
import SQL from 'sql-template-strings';
import {Session, time} from './session';
import {toID} from './server';
import {prepreplays, replays} from './tables';
import {Config} from './config-loader';
export interface ReplayData {
id: string;
p1: string;
p2: string;
format: string;
log: string;
inputlog: string | null;
uploadtime: number;
views: number;
p1id: string;
p2id: string;
formatid: string;
rating: number;
/** a boolean stored as a number in MySQL */
private: number;
password: string | null;
}
export interface PreparedReplay {
id: string;
p1: string;
p2: string;
format: string;
private: number;
loghash: string;
inputlog: string;
rating: number;
uploadtime: number;
}
export function stripNonAscii(str: string) {
return str.replace(/[^(\x20-\x7F)]+/g, '');
}
export function md5(str: string) {
return crypto.createHash('md5').update(str).digest('hex');
}
export const Replays = new class {
readonly passwordCharacters = '0123456789abcdefghijklmnopqrstuvwxyz';
async prep(params: {[k: string]: any}) {
const id = params.id;
let isPrivate = 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;
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, private: isPrivate,
});
return !!out.affectedRows;
}
generatePassword(length = 31) {
let password = '';
for (let i = 0; i < length; i++) {
password += this.passwordCharacters[Math.floor(Math.random() * this.passwordCharacters.length)];
}
return password;
}
async get(id: string): Promise<ReplayData | null> {
const replay = await replays.get('*', id);
if (!replay) return null;
for (const player of ['p1', 'p2'] as const) {
if (replay[player].startsWith('!')) replay[player] = replay[player].slice(1);
}
await replays.query(SQL`UPDATE ps_replays SET views = views + 1 WHERE id = ${replay.id}`);
return replay;
}
async edit(replay: ReplayData) {
if (replay.private === 3) {
await replays.updateOne({private: 3, password: null}, SQL`id = ${replay.id}`);
} else if (replay.private === 2) {
await replays.updateOne({private: 1, password: null}, SQL`id = ${replay.id}`);
} else if (replay.private) {
if (!replay.password) replay.password = this.generatePassword();
await replays.updateOne({private: 1, password: replay.password}, SQL`id = ${replay.id}`);
} else {
await replays.updateOne({private: 1, password: null}, SQL`id = ${replay.id}`);
}
}
async search(args: {
page?: number; isPrivate?: boolean; byRating?: boolean;
format?: string; username?: string; username2?: string;
}): Promise<ReplayData[]> {
const page = args.page || 0;
if (page > 100) return [];
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 ? 'rating' : 'uploadtime';
const userid = toID(args.username);
if (args.username2) {
const userid2 = toID(args.username2);
if (format) {
const query = SQL`(SELECT uploadtime, id, format, p1, p2, password FROM ps_replays `;
query.append(`FORCE INDEX (p1) `);
query.append(SQL`WHERE private = ${isPrivate} AND p1id = ${userid} `);
query.append(SQL`AND p2id = ${userid2} AND format = ${format} `);
query.append('ORDER BY ');
query.append(`${order} DESC)`);
query.append(' UNION ');
query.append(SQL`(SELECT uploadtime, id, format, p1, p2, password `);
query.append(SQL`FROM ps_replays FORCE INDEX (p1) `);
query.append(SQL`WHERE private = ${isPrivate} AND p1id = ${userid2} AND p2id = ${userid} `);
query.append(SQL`AND format = ${format}`);
query.append(` ORDER BY ${order} DESC)`);
query.append(` ORDER BY ${order} DESC LIMIT ${limit1}, 51;`);
return replays.query(query);
} else {
const query = SQL`(SELECT uploadtime, id, format, p1, p2, password FROM ps_replays `;
query.append(`FORCE INDEX (p1) `);
query.append(SQL`WHERE private = ${isPrivate} AND p1id = ${userid} AND p2id = ${userid2}`);
query.append(` ORDER BY ${order} DESC)`);
query.append(' UNION ');
query.append('(SELECT uploadtime, id, format, p1, p2, password FROM ps_replays FORCE INDEX (p1) ');
query.append(SQL`WHERE private = ${isPrivate} AND p1id = ${userid2} AND p2id = ${userid} `);
query.append(`ORDER BY ${order} DESC)`);
query.append(` ORDER BY ${order} DESC LIMIT ${limit1}, 51;`);
return replays.query(query);
}
} else {
if (format) {
const query = SQL`(SELECT uploadtime, id, format, p1, p2, password FROM ps_replays `;
query.append(`FORCE INDEX (p1) `);
query.append(SQL`WHERE private = ${isPrivate} AND p1id = ${userid} AND format = ${format} `);
query.append(`ORDER BY ${order} DESC) `);
query.append(' UNION ');
query.append('(SELECT uploadtime, id, format, p1, p2, password FROM ps_replays FORCE INDEX (p2)');
query.append(SQL` WHERE private = ${isPrivate} AND p2id = ${userid} AND format = ${format} `);
query.append(`ORDER BY ${order} DESC)`);
query.append(SQL` ORDER BY ${order} DESC LIMIT ${limit1}, 51;`);
return replays.query(query);
} else {
const query = SQL`(SELECT uploadtime, id, format, p1, p2, password FROM ps_replays `;
query.append(`FORCE INDEX (p1) `);
query.append(SQL`WHERE private = ${isPrivate} AND p1id = ${userid} ORDER BY ${order} DESC)`);
query.append(' UNION ');
query.append('(SELECT uploadtime, id, format, p1, p2, password FROM ps_replays FORCE INDEX (p2) ');
query.append(SQL`WHERE private = ${isPrivate} AND p2id = ${userid} ORDER BY ${order} DESC)`);
query.append(SQL` ORDER BY ${order} DESC LIMIT ${limit1}, 51;`);
return replays.query(query);
}
}
}
if (args.byRating) {
const query = SQL`SELECT uploadtime, id, format, p1, p2, rating, password `;
query.append(SQL`FROM ps_replays FORCE INDEX (top) `);
query.append(SQL`WHERE private = ${isPrivate} AND formatid = ${format} ORDER BY rating DESC LIMIT 51`);
return replays.query(query);
} else {
const query = SQL`SELECT uploadtime, id, format, p1, p2, rating, password `;
query.append(SQL`FROM ps_replays FORCE INDEX (format) `);
query.append(SQL`WHERE private = ${isPrivate} AND formatid = ${format} ORDER BY rating DESC LIMIT 51`);
return replays.query(query);
}
}
async fullSearch(term: string, page = 0) {
if (page > 0) return [];
const patterns = term.split(',').map(subterm => {
const escaped = subterm.replace(/%/g, '\\%').replace(/_/g, '\\_');
return `%${escaped}%`;
});
if (patterns.length !== 1 && patterns.length !== 2) return [];
const query = SQL`SELECT /*+ MAX_EXECUTION_TIME(10000) */ `;
query.append(`uploadtime, id, format, p1, p2, password FROM ps_replays `);
query.append(SQL`FORCE INDEX (recent) WHERE private = 0 AND log LIKE ${patterns[0]} `);
if (patterns.length === 2) {
query.append(SQL`AND log LIKE ${patterns[1]} `);
}
query.append(SQL`ORDER BY uploadtime DESC LIMIT 10;`);
return replays.query(query);
}
async recent() {
const query = SQL`SELECT uploadtime, id, format, p1, p2 FROM ps_replays `;
query.append(SQL`FORCE INDEX (recent) WHERE private = 0 ORDER BY uploadtime `);
query.append('DESC LIMIT 50');
return replays.query(query);
}
normalizeUsername(username: string) {
return username.toLowerCase().replace(/[^A-Za-z0-9]+/g, '');
}
async upload(params: {[k: string]: any}, dispatcher: Dispatcher) {
let id = params.id;
if (!toID(id)) throw new ActionError('Battle ID needed.');
const preppedReplay = await prepreplays.get('*', id);
const replay = await replays.get(['id', 'private', 'password'], id);
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 = null;
if (preppedReplay.private && preppedReplay.private !== 2) {
if (replay?.password) {
password = replay.password;
} else if (!replay?.private) {
password = this.generatePassword();
}
}
if (params.password) password = params.password;
let fullid = id;
if (password) fullid += '-' + password + 'pw';
if (md5(stripNonAscii(params.log)) !== preppedReplay.loghash) {
params.log = params.log.replace('\r', '');
if (md5(stripNonAscii(params.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;
}
}
if (password?.length > 31) {
dispatcher.setHeader('HTTP/1.1', '403 Forbidden');
return 'password must be 31 or fewer chars long';
}
const p1id = toID(preppedReplay.p1);
const p2id = toID(preppedReplay.p2);
const formatid = toID(preppedReplay.format);
const privacy = preppedReplay.private ? 1 : 0;
const {p1, p2, format, uploadtime, rating, inputlog} = preppedReplay;
const onDupe = SQL`ON DUPLICATE KEY UPDATE log = ${params.log}, `;
onDupe.append(SQL`inputlog = ${inputlog}, rating = ${rating}, `);
onDupe.append(SQL` private = ${privacy}, \`password\` = ${password}`);
await replays.insert({
id, p1, p2, format, p1id, p2id,
formatid, uploadtime,
private: privacy, rating, log: params.log,
inputlog, password,
}, onDupe);
await prepreplays.deleteOne(SQL`id = ${id} AND loghash = ${preppedReplay.loghash}`);
return 'success:' + fullid;
}
};
export default Replays;

88
src/server.ts Normal file
View File

@ -0,0 +1,88 @@
/**
* HTTP server routing.
* By Mia.
* @author mia-pi-git
*/
import {Config} from './config-loader';
import {Dispatcher} from './dispatcher';
import * as http from 'http';
import * as https from 'https';
const DISPATCH_PREFIX = ']';
export function toID(text: any): string {
if (text?.id) {
text = text.id;
} else if (text?.userid) {
text = text.userid;
}
if (typeof text !== 'string' && typeof text !== 'number') return '';
return ('' + text).toLowerCase().replace(/[^a-z0-9]+/g, '');
}
export class Router {
server: http.Server;
port: number;
awaitingEnd?: () => void;
activeRequests = 0;
constructor(port = (Config.port || 8000)) {
this.port = port;
const handle = (
req: http.IncomingMessage, res: http.ServerResponse
) => void this.handle(req, res);
this.server = Config.ssl
? https.createServer(Config.ssl, handle)
: http.createServer(handle);
this.server.listen(port);
}
static crashlog(error: any, source = '', details = {}) {
try {
const {crashlogger} = require('../data/pokemon-showdown');
crashlogger(error, source, details, Config.crashguardemail);
} catch (e) {
// don't have data/pokemon-showdown built? something else went wrong? oh well
console.log('CRASH', error);
console.log('SUBCRASH', e);
}
}
async handle(req: http.IncomingMessage, res: http.ServerResponse) {
const dispatcher = new Dispatcher(req, res);
this.activeRequests++;
try {
const result = await dispatcher.executeActions();
this.activeRequests--;
if (!this.activeRequests && this.awaitingEnd) this.awaitingEnd();
if (result === null) {
// didn't make a request to action.php or /api/ - custom response here
// supports delegation to apache?
if (Config.customhttpend) return Config.customhttpend.call(this, req, res, dispatcher);
return res.writeHead(404).end();
}
res.end(Router.stringify(result));
} catch (e: any) {
this.activeRequests--;
if (!this.activeRequests && this.awaitingEnd) this.awaitingEnd();
if (e.name?.endsWith('ActionError')) {
return res.end(Router.stringify({actionerror: e.message}));
}
const {body} = dispatcher.parseRequest()!;
for (const k of ['pass', 'password']) delete body[k];
Router.crashlog(e, 'an API request', body);
res.writeHead(503).end();
throw e;
}
}
close() {
this.server.close();
return new Promise<void>(resolve => {
this.awaitingEnd = resolve;
});
}
static stringify(response: {[k: string]: any}) {
return DISPATCH_PREFIX + JSON.stringify(response);
}
}

444
src/session.ts Normal file
View File

@ -0,0 +1,444 @@
/**
* User handling - represents the current user's PS session.
* Handles authentication, renaming, etc.
* (a Session instance is equivalent to $curuser on the client)
* By Mia.
* @author mia-pi-git
*/
import * as bcrypt from 'bcrypt';
import {Config} from './config-loader';
import * as crypto from 'crypto';
import {ActionError, Dispatcher} from './dispatcher';
import * as gal from 'google-auth-library';
import SQL from 'sql-template-strings';
import {toID} from './server';
import {ladder, loginthrottle, sessions, users, usermodlog} from './tables';
import type {User} from './user';
const SID_DURATION = 2 * 7 * 24 * 60 * 60;
const LOGINTIME_INTERVAL = 24 * 60 * 60;
export function time() {
// php has this with unix seconds. so we have to as well.
// for legacy reasons. Yes, I hate it too.
return Math.round(Date.now() / 1000);
}
export class Session {
sidhash = '';
dispatcher: Dispatcher;
session = 0;
constructor(dispatcher: Dispatcher) {
this.dispatcher = dispatcher;
}
getSid() {
if (this.sidhash) return this.sidhash;
const cached = this.dispatcher.cookies.get('sid');
if (cached) {
const [, sessionId, sid] = cached.split(',');
this.sidhash = sid;
this.session = parseInt(sessionId);
return this.sidhash;
}
return '';
}
getName() {
return this.dispatcher.cookies.get('showdown_username') || this.dispatcher.user.name;
}
makeSid() {
if (Config.makeSid) return Config.makeSid.call(this);
return crypto.randomBytes(24).toString('hex');
}
async setSid() {
if (!this.sidhash) {
this.sidhash = await this.makeSid();
}
this.updateCookie();
return this.sidhash;
}
deleteCookie() {
this.dispatcher.setHeader('Set-Cookie', [
`sid=; Max-Age=0; Domain=${Config.routes.root}; Path=/; Secure; SameSite=None`,
]);
}
async getRecentRegistrationCount(period: number) {
const ip = this.dispatcher.getIp();
const timestamp = time() - period;
const query = SQL`SELECT COUNT(*) AS \`registrationcount\` FROM \`ntbb_users\``;
query.append(SQL`WHERE \`ip\` = ${ip} AND \`registertime\` > ${timestamp}`);
const rows = await users.database.get(query);
if (!rows) return 0;
return rows['registrationcount'];
}
async addUser(username: string, password: string) {
const hash = await bcrypt.hash(password, Config.passwordSalt);
const userid = toID(username);
const exists = await users.get(['userid'], userid);
if (exists) return null;
const ip = this.dispatcher.getIp();
const result = await users.insert({
userid, username, passwordhash: hash, email: null, registertime: time(), ip,
});
if (!result.affectedRows) {
throw new Error(`User could not be created. (${userid}, ${ip})`);
}
return this.login(username, password);
}
async login(name: string, pass: string) {
const curTime = time();
await this.logout();
const userid = toID(name);
const info = await users.get('*', userid);
if (!info) {
// unregistered. just do the thing
return this.dispatcher.user;
}
// previously, there was a case for banstate here in the php.
// this is not necessary, as getAssertion handles that. Proceed to verification.
const verified = await this.passwordVerify(name, pass);
if (!verified) {
throw new ActionError('Wrong password.');
}
const timeout = (curTime + SID_DURATION);
const ip = this.dispatcher.getIp();
const sidhash = await this.makeSid();
const res = await sessions.insert({userid, sid: sidhash, time: time(), timeout, ip});
this.sidhash = sidhash;
this.session = res.insertId || 0;
this.dispatcher.user.login(name);
this.updateCookie();
return this.dispatcher.user;
}
async logout() {
if (!this.session) return false;
await sessions.delete(this.session);
this.sidhash = '';
this.session = 0;
this.deleteCookie();
this.dispatcher.user.logout();
}
updateCookie() {
const name = this.getName();
if (toID(name) === 'guest') return;
if (!this.sidhash) {
return this.deleteCookie();
}
const rawsid = encodeURIComponent([name, this.session, this.sidhash].join(','));
this.dispatcher.setHeader(
'Set-Cookie',
`sid=${rawsid}; Max-Age=31363200; Domain=${Config.routes.root}; Path=/; Secure; SameSite=None`
);
}
async getAssertion(
userid: string,
challengekeyid = -1,
user: User | null,
challenge = '',
challengeprefix = ''
) {
if (userid.startsWith('guest')) {
return ';;Your username cannot start with \'guest\'.';
} else if (userid.length > 18) {
return ';;Your username must be less than 19 characters long.';
} else if (!Session.isUseridAllowed(userid)) {
return ';;Your username contains disallowed text.';
}
let data = '';
const ip = this.dispatcher.getIp();
let forceUsertype: string | false = false;
if (!user) user = this.dispatcher.user;
if (Config.autolockip?.includes(ip)) {
forceUsertype = '5';
}
let userType = '';
const userData = await user.getData();
const {banstate, registertime: regtime} = userData;
const server = this.dispatcher.getServer();
const serverHost = server?.server || 'sim3.psim.us';
if (user.id === userid && user.loggedin) {
// already logged in
userType = '2';
if (Config.sysops?.includes(user.id)) {
userType = '3';
} else {
const customType = Config.getUserType?.call(this, user, banstate, server);
if (forceUsertype) {
userType = forceUsertype;
} else if (customType) {
userType = customType;
} else if (banstate <= -10) {
userType = '4';
} else if (banstate >= 100) {
return ';;Your username is no longer available.';
} else if (banstate >= 40) {
userType = '2';
} else if (banstate >= 30) {
userType = '6';
} else if (banstate >= 20) {
userType = '5';
} else if (banstate === 0) {
// should we update autoconfirmed status? check to see if it's been long enough
if (regtime && time() - regtime > (7 * 24 * 60 * 60)) {
const ladders = await ladder.selectOne('formatid', SQL`userid = ${userid} AND w != 0`);
if (ladders) {
userType = '4';
void users.update(userid, {banstate: -10});
}
}
}
}
const logintime = userData.logintime;
if (!logintime || time() - logintime > LOGINTIME_INTERVAL) {
await users.update(userid, {logintime: time(), loginip: ip});
}
data = user.id + ',' + userType + ',' + time() + ',' + serverHost;
} else {
if (userid.length < 1 || !/[a-z]/.test(userid)) {
return ';;Your username must contain at least one letter.';
}
const userstate = await users.get('*', userid);
if (userstate) {
if (userstate.banstate >= 100 || ((userstate as any).password && userstate.nonce)) {
return ';;Your username is no longer available.';
}
if (userstate.email?.endsWith('@')) {
return ';;@gmail';
}
return ';';
} else {
// Unregistered username.
userType = '1';
if (forceUsertype) userType = forceUsertype;
data = user + ',' + userType + ',' + time() + ',' + serverHost;
this.updateCookie();
}
}
let splitChallenge: string[] = [];
for (const delim of [';', '%7C', '|']) {
splitChallenge = challenge.split(delim);
if (splitChallenge.length > 1) break;
}
let challengetoken;
if (splitChallenge.length > 1) {
challengekeyid = parseInt(splitChallenge[0]);
challenge = splitChallenge[1];
if (splitChallenge[2] && !challengetoken) challengetoken = splitChallenge[2];
}
if (!toID(challenge)) {
// Bogus challenge.
return ';;Corrupt challenge';
}
if (challengekeyid < 1) {
return (
';;This server is requesting an invalid login key. ' +
'This probably means that either you are not connected to a server, ' +
'or the server is set up incorrectly.'
);
} else if (Config.compromisedkeys?.includes(challengekeyid)) {
// Compromised keys - no longer supported.
return (
`;;This server is using login key ${challengekeyid}, which is no longer supported. ` +
`Please tell the server operator to update their config.js file.`
);
} else if (!Config.privatekeys?.[challengekeyid]) {
// Bogus key id.
return ';;Unknown key ID';
} else {
// Include the challenge in the assertion.
data = (challengeprefix || '') + challenge + ',' + data;
}
this.updateCookie();
if (Config.validateassertion) {
data = await Config.validateassertion.call(
this, challengetoken, user, data, serverHost
);
}
const sign = crypto.createSign('RSA-SHA1');
sign.update(data);
sign.end();
const sig = sign.sign(Config.privatekeys[challengekeyid], 'hex');
return data + ';' + sig;
}
static isUseridAllowed(userid: string) {
const disallowed = [
...(Config.bannedTerms || []),
'nigger', 'nigga', 'faggot',
/(lol|ror)icon/, 'lazyafrican',
'tranny',
];
for (const term of disallowed) {
if (typeof term === 'object' ? term.test(userid) : userid.includes(term)) {
return false;
}
}
return true;
}
static wordfilter(user: string) {
const disallowed = [
...(Config.bannedTerms || []),
'nigger', 'nigga', 'faggot',
/(lol|ror)icon/, 'lazyafrican',
'tranny',
];
for (const term of disallowed) {
user = user.replace(term, '*');
}
return user;
}
async changePassword(name: string, pass: string) {
const userid = toID(name);
const userData = await users.get('*', userid);
if (!userData) return false;
const entry = 'Password changed from: ' + userData.passwordhash;
await usermodlog.insert({
userid, actorid: userid, date: time(), ip: this.dispatcher.getIp(), entry,
});
const passwordhash = await bcrypt.hash(pass, Config.passwordSalt);
await users.update(userid, {
passwordhash, nonce: null,
});
await sessions.deleteOne(SQL`userid = ${userid}`);
if (this.dispatcher.user.id === userid) {
await this.login(name, pass);
}
return true;
}
async passwordVerify(name: string, pass: string) {
const ip = this.dispatcher.getIp();
const userid = toID(name);
let throttleTable = await (loginthrottle.get(
['count', 'time'], ip
) as Promise<{count: number; time: number}>) || null;
if (throttleTable) {
if (throttleTable.count > 500) {
throttleTable.count++;
await loginthrottle.update(ip, {
count: throttleTable.count,
lastuserid: userid,
time: time(),
});
return false;
} else if (throttleTable.time + 24 * 60 * 60 < time()) {
throttleTable = {
count: 0,
time: time(),
};
}
}
const userData = await users.get('*', userid);
if (userData?.email?.endsWith('@')) {
const client = new gal.OAuth2Client(Config.galclient, '', '');
try {
const payload = await new Promise<{[k: string]: any} | null>((resolve, reject) => {
client.verifyIdToken({
idToken: pass,
audience: Config.galclient,
}, (e, login) => {
if (e) return reject(e);
resolve(login?.getPayload() || null);
});
});
if (!payload) return false; // dunno why this would happen.
if (!payload.aud.includes(Config['gapi_clientid'])) return false;
if (payload.email === userData['email'].slice(0, -1)) {
return true;
}
return false;
} catch {
return false;
}
}
let rehash = false;
if (userData?.passwordhash) {
if (!(await bcrypt.compare(pass, userData.passwordhash))) {
if (throttleTable) {
throttleTable.count++;
await loginthrottle.update(ip, {
count: throttleTable.count, lastuserid: userid, time: time(),
});
} else {
await loginthrottle.insert({
ip, count: 1, lastuserid: userid, time: time(),
});
}
return false;
}
// i don't know how often this is actually necessary. so let's make this configurable.
rehash = await Config.passwordNeedsRehash?.call(this, userid, userData['passwordhash']);
} else {
return false;
}
if (rehash) {
// create a new password hash for the user
const hash = await bcrypt.hash(pass, Config.passwordSalt);
if (hash) {
await users.update(toID(name), {
passwordhash: hash, nonce: null,
});
}
}
return true;
}
async checkLoggedIn() {
const ctime = time();
const {body} = this.dispatcher.parseRequest()!;
// see if we're logged in
const scookie = body.sid || this.dispatcher.cookies.get('sid');
if (!scookie) {
// nope, not logged in
return;
}
let sid = '';
let session = 0;
const scsplit = scookie.split(',');
let cookieName;
if (scsplit.length === 3) {
cookieName = scsplit[0];
session = parseInt(scsplit[1]);
sid = scsplit[2];
this.sidhash = sid;
}
if (!session) {
return;
}
const query = SQL`SELECT sid, timeout, \`ntbb_users\`.* `;
query.append(SQL`FROM \`ntbb_sessions\`, \`ntbb_users\` `);
query.append(SQL`WHERE \`session\` = ${session} `);
query.append(SQL`AND \`ntbb_sessions\`.\`userid\` = \`ntbb_users\`.\`userid\` `);
query.append(' LIMIT 1');
const res = await users.database.get<{sid: string; timeout: number}>(query);
if (!res || !(await this.validateSid(sid, res.sid))) {
// invalid session ID
this.deleteCookie();
return;
}
if (res.timeout < ctime) {
// session expired
await sessions.deleteAll(SQL`timeout = ${ctime}`);
this.deleteCookie();
return;
}
// okay, legit session ID - you're logged in now.
this.dispatcher.user.login(cookieName);
this.sidhash = sid;
this.session = session;
}
validateSid(cachedSid: string, databaseSid: string): boolean | Promise<boolean> {
if (Config.validateSid) return Config.validateSid.call(this, cachedSid, databaseSid);
return cachedSid === databaseSid;
}
}

64
src/tables.ts Normal file
View File

@ -0,0 +1,64 @@
/**
* Classes for interfacing with specific database tables.
* Original design by Zarel in https://github.com/Zarel/telepic/blob/master/server/db.ts, redone by Mia
* @author mia-pi-git
*/
import {Config} from './config-loader';
import {DatabaseTable} from './database';
import type {LadderEntry} from './ladder';
import type {PreparedReplay, ReplayData} from './replays';
import type {UserInfo} from './user';
export const users = new DatabaseTable<UserInfo>('users', 'userid');
export const ladder = new DatabaseTable<LadderEntry>(
'ladder', 'entryid', '', Config.ladderdb
);
export const prepreplays = new DatabaseTable<PreparedReplay>(
'prepreplays', 'id', 'ps_', Config.replaysdb
);
export const replays = new DatabaseTable<ReplayData>(
'replays', 'id', 'ps_', Config.replaysdb
);
export const sessions = new DatabaseTable<{
session: number;
sid: string;
userid: string;
time: number;
timeout: number;
ip: string;
}>('sessions', 'session');
export const userstats = new DatabaseTable<{
id: number;
serverid: string;
usercount: number;
date: number;
}>('userstats', 'id');
export const loginthrottle = new DatabaseTable<{
ip: string;
count: number;
time: number;
lastuserid: string;
}>('loginthrottle', 'ip');
export const usermodlog = new DatabaseTable<{
entryid: number;
userid: string;
actorid: string;
date: number;
ip: string;
entry: string;
}>('usermodlog', 'entryid');
export const userstatshistory = new DatabaseTable<{
id: number;
date: number;
usercount: number;
programid: 'showdown' | 'po';
}>('userstatshistory', 'id');

150
src/test/actions.test.ts Normal file
View File

@ -0,0 +1,150 @@
/**
* Tests for all actions the loginserver can perform.
* By Mia.
* @author mia-pi-git
*/
import {Config} from '../config-loader';
import {strict as assert} from 'assert';
import {NTBBLadder} from '../ladder';
import * as utils from './test-utils';
import SQL from 'sql-template-strings';
import * as tables from '../tables';
(Config.testdb ? describe : describe.skip)('Loginserver actions', () => {
const server = utils.addServer({
id: 'showdown',
name: 'Etheria',
port: 8000,
server: 'despondos.psim.us',
token: '42354y6dhgfdsretr',
});
it('Should properly log userstats and userstats history', async () => {
const {result} = await utils.testDispatcher({
act: 'updateuserstats',
users: '20',
date: `${Date.now()}`,
servertoken: server.token,
serverid: 'showdown',
});
assert(result.actionsuccess);
});
// users
describe('Users features', () => {
it('Should properly register users', async () => {
// erase the user so the test runs uncorrupted
await tables.users.delete('catra').catch(() => null);
const {result} = await utils.testDispatcher({
act: 'register',
username: 'Catra',
password: 'applesauce',
cpassword: 'applesauce',
captcha: 'pikachu',
challstr: await utils.randomBytes(),
challengekeyid: 1,
});
assert(result.curuser.userid === 'catra');
assert(result.actionsuccess);
});
it('Should log in a user', async () => {
const {result} = await utils.testDispatcher({
act: 'login',
name: 'catra',
pass: 'applesauce',
challengekeyid: 1,
challstr: await utils.randomBytes(),
}, dispatcher => dispatcher.session.addUser('Catra', 'applesauce').catch(() => null));
assert(result.actionsuccess, 'User was not logged in');
assert(result.assertion.split(';').length > 1);
});
it("should change the user's password", async () => {
const {result} = await utils.testDispatcher({
act: 'changepassword',
username: 'Catra',
oldpassword: 'applesauce',
cpassword: 'greyskull',
password: 'greyskull',
}, dispatcher => dispatcher.user.login('catra'));
assert(result, 'Received falsy success');
});
});
it('Should prepare replays', async () => {
// clear old
await tables.prepreplays.deleteOne(SQL`id = ${'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: server.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');
});
describe('Ladder', () => {
it('Should update the ladder', async () => {
for (const id of ['catra', 'adora']) {
await tables.ladder.deleteOne(
SQL`userid = ${id} AND formatid = ${'gen1randombattle'}`,
); // clear their ratings entirely
}
const {result} = await utils.testDispatcher({
act: 'ladderupdate',
serverid: 'showdown',
servertoken: server.token,
p1: 'Catra',
p2: 'Adora',
format: 'gen1randombattle',
score: 1, // score 1 means p1 wins
});
assert(result.p1rating.elo === 1040, 'Received winner elo of ' + result.p1rating.elo);
assert(result.p2rating.elo === 1000, 'Received loser elo of ' + result.p2rating.elo);
});
it('Should fetch the MMR for a given user', async () => {
const ladder = new NTBBLadder('gen5randombattle');
const p1 = NTBBLadder.getUserData('shera')!;
const p2 = NTBBLadder.getUserData('catra')!;
for (const player of [p1, p2]) {
await tables.ladder.deleteAll(
SQL`userid = ${player.id} AND formatid = ${ladder.formatid}`,
);
}
await ladder.updateRating(p1, p2, 1);
const {result} = await utils.testDispatcher({
act: 'mmr',
format: 'gen5randombattle',
user: 'shera',
serverid: 'showdown',
servertoken: server.token,
});
assert.strictEqual(p1.rating!.elo, result, `Expected elo ${p1.rating!.elo}, got ${result}`);
});
});
});

12
src/test/compose.yml Normal file
View File

@ -0,0 +1,12 @@
services:
mysql:
image: mysql
environment:
MYSQL_HOST: localhost
MYSQL_DATABASE: test
MYSQL_USER: test
MYSQL_PASSWORD: test
ports:
- 3307
expose:
- 3307

View File

@ -0,0 +1,68 @@
/**
* Tests for dispatcher functions.
* By Mia.
* @author mia-pi-git
*/
import {strict as assert} from 'assert';
import {Config} from '../config-loader';
import * as utils from './test-utils';
describe('Dispatcher features', () => {
const dispatcher = utils.makeDispatcher({
serverid: 'etheria',
servertoken: '42354y6dhgfdsretr',
act: 'mmr',
});
const server = utils.addServer({
id: 'etheria',
name: 'Etheria',
port: 8000,
server: 'despondos.psim.us',
token: '42354y6dhgfdsretr',
});
it('Should properly detect servers', () => {
const cur = dispatcher.getServer();
assert(server.id === cur?.id);
});
it('Should validate servertokens', () => {
const cur = dispatcher.getServer(true);
assert(cur);
assert(server.id === cur.id);
// invalidate the servertoken, we shouldn't find the server now
(dispatcher.opts.body as {[k: string]: string}).servertoken = '';
assert.throws(() => dispatcher.getServer(true));
});
it('Should validate CORS requests', () => {
Config.cors = [
[/etheria/, 'server_'],
];
dispatcher.request.headers['origin'] = 'https://etheria.psim.us/';
let prefix = dispatcher.verifyCrossDomainRequest();
assert(prefix === 'server_', 'Wrong challengeprefix: ' + prefix);
assert(dispatcher.response.hasHeader('Access-Control-Allow-Origin'), 'missing CORS header');
dispatcher.response.removeHeader('Access-Control-Allow-Origin');
dispatcher.request.headers['origin'] = 'nevergonnagiveyouup';
dispatcher.setPrefix('');
prefix = dispatcher.verifyCrossDomainRequest();
assert(prefix === '', 'has improper challengeprefix: ' + prefix);
const header = dispatcher.response.hasHeader('Access-Control-Allow-Origin');
assert(!header, 'has CORS header where it should not: ' + header);
});
it('Should support requesting /api/[action]', () => {
dispatcher.request.url = '/api/mmr?userid=mia';
delete dispatcher.opts.body;
const {act} = dispatcher.parseRequest() || {};
assert(act === 'mmr');
});
it('Should support requesting action.php with an `act` param', () => {
dispatcher.request.url = '/action.php?act=mmr&userid=mia';
delete dispatcher.opts.body;
const {act, body} = dispatcher.parseRequest() || {};
assert(act === 'mmr');
assert(body?.userid === 'mia');
});
});

229
src/test/replays.test.ts Normal file
View File

@ -0,0 +1,229 @@
/*
* Tests for replays.ts.
* @author Annika
*/
import {Config} from '../config-loader';
import {Replays, md5, stripNonAscii, ReplayData} from '../replays';
import {prepreplays, replays} from '../tables';
import {strict as assert} from 'assert';
import SQL from 'sql-template-strings';
import * as utils from './test-utils';
(Config.testdb ? describe : 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('*', SQL`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',
format: 'gen8ou',
});
const replay = await Replays.get('gettest');
assert(replay);
assert.equal(replay.views, 2);
});
it('should support editing replays', async () => {
await replays.insert({
id: 'edittest',
views: 1,
p1: 'annika',
p2: 'annikatesting',
format: 'gen8ou',
});
const original = await Replays.get('edittest');
assert(original);
assert.equal(original.p1, 'annika');
original.p1 = 'somerandomreg';
await Replays.edit(original);
await Replays.get('edittest');
assert.equal(original.p1, 'somerandomreg');
});
it('should properly upload replays', async () => {
const dispatcher = utils.makeDispatcher({});
/* 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: Partial<ReplayData> = {
id: 'uploadtest',
password: 'hunter2',
p1: 'Annika', p2: 'Heart of Etheria',
format: toPrep.format,
log,
};
const result = await Replays.upload(replay, dispatcher);
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);
});
describe('searching replays', () => {
async function search(args: any) {
const res = await Replays.search(args);
return res
.map(r => r.id)
.filter(id => id.startsWith('searchtest'));
}
before(async () => {
await replays.insert({
id: 'searchtest1', private: 1, views: 1, p1: 'somerandomreg',
p2: '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,
});
await replays.insert({
id: 'searchtest3', private: 1, views: 1, p1: 'annika',
p2: '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,
});
await replays.insert({
id: 'searchtest5', private: 0, views: 1, p1: 'heartofetheria',
p2: '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,
log: 'yxmördaren Julia Blomqvist på fäktning i Schweiz',
});
});
it('should support searching for replays by privacy', async () => {
const results = await search({isPrivate: true});
assert.deepEqual(results, ['searchtest1', 'searchtest2', 'searchtest3']);
});
it('should support searching for replays by format', async () => {
const results = await search({format: 'gen8ou'});
assert.deepEqual(results, ['searchtest3', 'searchtest4']);
});
it('should support searching for replays by username', async () => {
const oneName = await search({username: 'somerandomreg'});
assert.deepEqual(oneName, ['searchtest1', 'searchtest2', 'searchtest3', 'searchtest4']);
const twoNames = await search({username: 'somerandomreg', username2: 'annikaskywalker'});
assert.deepEqual(twoNames, ['searchtest1']);
const reversed = await search({username: 'annikaskywalker', username2: 'somerandomreg'});
assert.deepEqual(twoNames, reversed);
});
it('should support multple search parameters at once', async () => {
const results = await search({
username: 'somerandomreg', username2: 'annika', isPrivate: true, format: 'gen8randombattle',
});
assert.deepEqual(results, ['searchtest2']);
});
it('should support different orderings', async () => {
const rating = await search({format: 'gen8anythinggoes', byRating: true});
assert.deepEqual(rating, ['searchtest6', 'searchtest5']);
const uploadtime = await search({format: 'gen8anythinggoes'});
assert.deepEqual(uploadtime, ['searchtest5', 'searchtest6']);
});
it('should support searching the log', async () => {
const english = await Replays.fullSearch('over,fox');
assert.equal(english[0].id, 'searchtest5');
const swedish = await Replays.fullSearch('på,yxmördaren');
assert.equal(swedish[0].id, 'searchtest6');
});
});
});
describe('password generation', () => {
it('should generate 31-character passwords or the specified length', () => {
assert.equal(Replays.generatePassword().length, 31);
assert.equal(Replays.generatePassword(64).length, 64);
assert.equal(Replays.generatePassword(0).length, 0);
});
});

97
src/test/test-utils.ts Normal file
View File

@ -0,0 +1,97 @@
/**
* Miscellaneous utilities for tests.
* We also start up global hooks here.
* By Mia.
* @author mia-pi-git
*/
import * as net from 'net';
import {IncomingMessage, ServerResponse} from 'http';
import {Dispatcher, RegisteredServer} from '../dispatcher';
import {Config} from '../config-loader';
import * as crypto from 'crypto';
import {databases} from '../database';
import {strict as assert} from 'assert';
import * as fs from 'fs';
import SQL from 'sql-template-strings';
export let setup = false;
export async function setupDB(): Promise<void> {
if (setup) return;
setup = true;
/** Removing this as it does not work, but could be useful for future reference.
const commands = [
'docker run --name api-test -p 3308:3306 -e MYSQL_ROOT_PASSWORD=testpw -d mysql:latest',
];
for (const command of commands) execSync(command);
const config = {
password: 'testpw',
user: 'root',
host: '127.0.0.1',
port: 3308,
};
await wait(5000); // for docker to catch up */
if (!Config.testdb) throw new Error('Configure `Config.testdb` before using mocha.');
const sqlFiles = fs.readdirSync(`${__dirname}/../../lib/`)
.filter(f => f.endsWith('.sql'))
.map(k => `lib/${k}`)
.concat(['replays/ps_prepreplays.sql', 'replays/ps_replays.sql']);
for (const db of databases) {
db.connect(Config.testdb);
for (const file of sqlFiles) {
const schema = fs.readFileSync(`${__dirname}/../../${file}`, 'utf-8');
await db.query(SQL(schema)).catch(() => null);
}
}
}
export function makeDispatcher(body?: {[k: string]: any}, url?: string) {
const socket = new net.Socket();
const req = new IncomingMessage(socket);
if (body && !url) {
const params = Object.entries(body)
.filter(k => k[0] !== 'act')
.map(([k, v]) => `${k}=${v}`)
.join('&');
url = `/api/${body.act}?${params}`;
}
if (url) req.url = url;
return new Dispatcher(req, new ServerResponse(req), body ? {body} : undefined);
}
export function addServer(server: RegisteredServer) {
Dispatcher.servers[server.id] = server;
return server;
}
export async function testDispatcher(
opts: {[k: string]: any},
setupFunct?: (dispatcher: Dispatcher) => any | Promise<any>,
method = 'POST',
) {
const dispatcher = makeDispatcher(opts);
dispatcher.request.method = method;
if (setupFunct) await setupFunct(dispatcher);
let result: any;
try {
result = await dispatcher.executeActions();
} catch (e: any) {
assert(false, e.message);
}
// we return dispatcher in case we need to do more
return {result, dispatcher};
}
export async function randomBytes(size = 128) {
return new Promise((resolve, reject) => {
crypto.randomBytes(size, (err, buffer) => err ? reject(err) : resolve(buffer.toString('hex')));
});
}
before(async () => {
await setupDB();
});

80
src/user.ts Normal file
View File

@ -0,0 +1,80 @@
/**
* Wrapper around the current user.
* Mostly handles accessing the data for the user in `ntbb_users`, etc.
* By Mia.
* @author mia-pi-git
*/
import type {Dispatcher} from './dispatcher';
import type {LadderEntry} from './ladder';
import {toID} from './server';
import {time, Session} from './session';
import {users} from './tables';
export interface UserInfo {
userid: string;
usernum: number;
username: string;
nonce: string | null;
passwordhash: string | null;
email: string | null;
registertime: number;
group: number;
banstate: number;
ip: string;
avatar: number;
logintime: number;
loginip: string | null;
}
export class User {
name = 'Guest';
id = 'guest';
dispatcher: Dispatcher;
session: Session;
loggedin = false;
rating: LadderEntry | null = null;
ratings: LadderEntry[] = [];
constructor(session: Session) {
this.dispatcher = session.dispatcher;
this.session = session;
}
async getData() {
if (this.id === 'guest') return User.getUserDefaults();
const data = await users.get('*', this.id);
return data || User.getUserDefaults();
}
setName(name: string) {
this.name = name;
this.id = toID(name);
}
login(name: string) {
this.setName(name);
this.loggedin = true;
return this;
}
logout() {
this.setName('guest');
this.loggedin = false;
}
static getUserDefaults(): UserInfo {
return {
username: 'Guest',
userid: 'guest',
group: 0,
passwordhash: '',
email: null,
nonce: null,
usernum: 0,
registertime: 0,
banstate: 0,
ip: '',
logintime: time(),
loginip: '',
avatar: 0,
};
}
toString() {
return this.id;
}
}

18
tsconfig.json Normal file
View File

@ -0,0 +1,18 @@
{
"compilerOptions": {
"lib": ["es6"],
"module": "commonjs",
"target": "es6",
"moduleResolution": "node",
"outDir": ".dist",
"strict": true,
"allowJs": true,
"checkJs": true,
"incremental": true,
"allowUnreachableCode": false,
"skipLibCheck": true
},
"include": [
"./src"
],
}