pokemon-showdown-client/lib/dispatcher.lib.php
Guangcong Luo 00a877333f Stop autodetecting serverid for loginserver rqs
The automatic server detection just loops through all registered
servers and tries each of them to find the correct one. This process
will hang if any registered server isn't responding to DNS queries,
which at the number we currently have, makes all servers not sending
serverid fail.

We now hard-require the Config.serverid setting, to prevent this
problem.
2020-06-27 12:46:38 -04:00

598 lines
21 KiB
PHP

<?php
class ActionDispatcher {
private $reqs;
private $multiReqs = false;
private $reqData;
private $outPrefix = ']'; // JSON output should not be valid JavaScript
private $outArray = array();
public function __construct($handlers) {
$this->handlers = $handlers;
if (empty($_REQUEST)) {
$this->reqs = null;
if (substr($_SERVER["CONTENT_TYPE"] ?? '', 0, 16) === 'application/json') {
// screw you too Axios
// also come on PHP, you could just support JSON natively instead of putting me through this
$input = trim(file_get_contents('php://input'));
if ($input[0] === '[') {
$this->reqs = json_decode($input, true);
} else if ($input[0] === '{') {
$this->reqs = [json_decode($input, true)];
}
}
if (empty($this->reqs)) die("no request data found - you need to send some sort of data");
$_POST['is_post_request'] = true;
} else {
$this->reqs = [$_REQUEST];
}
if (@$_REQUEST['json']) {
$this->reqs = json_decode($_REQUEST['json'], true);
$this->multiReqs = true;
}
}
public function setPrefix($prefix) {
$this->outPrefix = $prefix;
}
public function getServerHostName($serverid) {
global $PokemonServers;
$server = @$PokemonServers[$serverid];
return $server ? $server['server'] : $serverid;
}
public function verifyCrossDomainRequest() {
global $psconfig;
// No cross-domain multi-requests for security reasons.
// No need to do anything if this isn't a cross-domain request.
if ($this->multiReqs || !isset($_SERVER['HTTP_ORIGIN'])) {
return '';
}
$origin = $_SERVER['HTTP_ORIGIN'];
$prefix = null;
foreach ($psconfig['cors'] as $i => &$j) {
if (!preg_match($i, $origin)) continue;
$prefix = $j;
break;
}
if ($prefix === null) {
// Bogus request.
return '';
}
// Valid CORS request.
header('Access-Control-Allow-Origin: ' . $origin);
header('Access-Control-Allow-Credentials: true');
return $prefix;
}
public function getIp() {
global $users;
return $users->getIp();
}
public function findServer() {
global $PokemonServers;
$serverid = @$this->reqData['serverid'];
$server = null;
$ip = $this->getIp();
if (!isset($PokemonServers[$serverid])) {
// Try to find the server by source IP, rather than by serverid.
if ($serverid === 'testtimeout') {
foreach ($PokemonServers as &$i) {
gethostbyname($i['server']);
}
}
return null;
} else {
$server =& $PokemonServers[$serverid];
if (empty($server['skipipcheck']) && empty($server['token']) && $serverid !== 'showdown') {
if (!isset($server['ipcache'])) {
$server['ipcache'] = gethostbyname($server['server']);
}
if ($ip !== $server['ipcache']) return null;
}
}
if (!empty($server['token'])) {
if ($server['token'] !== md5($this->reqData['servertoken'] ?? '')) return null;
}
return $server;
}
public function executeActions() {
$outArray = null;
if ($this->multiReqs) $outArray = array();
foreach ($this->reqs as $this->reqData) {
$this->reqData = array_merge($_REQUEST, $this->reqData);
if (!isset($this->reqData['act'])) die("action not found - make sure your request data includes act=something");
$action = $this->reqData['act'];
if (!ctype_alnum($action)) die("invalid action: " . var_export($action, true));
$out = array();
foreach ($this->handlers as &$i) {
if (is_callable(array($i, $action))) {
$i->$action($this, $this->reqData, $out);
}
}
if ($this->multiReqs) $outArray[] = $out;
}
// json output
if ($this->outPrefix !== '') {
// Technically this is not JSON because of the initial prefix.
header('Content-Type: text/plain; charset=utf-8');
} else {
header('Content-Type: application/json');
}
if ($this->multiReqs) {
die($this->outPrefix . json_encode($outArray));
} else {
die($this->outPrefix . json_encode($out));
}
}
}
class DefaultActionHandler {
public function login($dispatcher, &$reqData, &$out) {
global $users, $curuser;
$challengeprefix = $dispatcher->verifyCrossDomainRequest();
if (!$_POST) die('for security reasons, logins must happen with POST data');
if (empty($reqData['name']) || empty($reqData['pass'])) die('incorrect login data, you need "name" and "pass" fields');
try {
$users->login($reqData['name'], $reqData['pass']);
} catch (Exception $e) {
$out['error'] = $e->getMessage() . "\n" . $e->getFile() . '(' . $e->getLine() . ')' . "\n" . $e->getTraceAsString();
}
unset($curuser['userdata']);
$out['curuser'] = $curuser;
$out['actionsuccess'] = ($curuser ? $curuser['loggedin'] : false);
$serverhostname = '' . $dispatcher->getServerHostName(@$reqData['serverid']);
$challengekeyid = !isset($reqData['challengekeyid']) ? -1 : intval($reqData['challengekeyid']);
$challenge = !isset($reqData['challenge']) ? '' : $reqData['challenge'];
if (!$challenge) {
$challenge = !isset($reqData['challstr']) ? '' : $reqData['challstr'];
}
$out['assertion'] = $users->getAssertion($curuser['userid'], $serverhostname, null,
$challengekeyid, $challenge, $challengeprefix);
}
public function register($dispatcher, &$reqData, &$out) {
global $users, $curuser;
$challengeprefix = $dispatcher->verifyCrossDomainRequest();
$serverhostname = '' . $dispatcher->getServerHostName(@$reqData['serverid']);
$user = [
'username' => @$_POST['username'],
];
$userid = $users->userid($user['username']);
if ((mb_strlen($userid) < 1) || ctype_digit($userid)) {
$out['actionerror'] = 'Your username must contain at least one letter.';
} else if (substr($userid, 0, 5) === 'guest') {
$out['actionerror'] = 'Your username cannot start with \'guest\'.';
} else if (mb_strlen($user['username']) > 18) {
$out['actionerror'] = 'Your username must be less than 19 characters long.';
} else if (mb_strlen(@$_POST['password']) < 5) {
$out['actionerror'] = 'Your password must be at least 5 characters long.';
} else if (@$_POST['password'] !== @$_POST['cpassword']) {
$out['actionerror'] = 'Your passwords do not match.';
} else if (trim(strtolower(@$_POST['captcha'])) !== 'pikachu') {
$out['actionerror'] = 'Please answer the anti-spam question given.';
} else if (($registrationcount = $users->getRecentRegistrationCount()) === false) {
$out['actionerror'] = 'A database error occurred. Please try again.';
} else if ($registrationcount >= 2) {
$out['actionerror'] = 'You can\'t register more than two usernames every two hours. Try again later.';
} else if ($user = $users->addUser($user, $_POST['password'])) {
$challengekeyid = !isset($reqData['challengekeyid']) ? -1 : intval($reqData['challengekeyid']);
$challenge = !isset($reqData['challenge']) ? '' : $reqData['challenge'];
if (!$challenge) {
$challenge = !isset($reqData['challstr']) ? '' : $reqData['challstr'];
}
$out['curuser'] = $user;
$out['assertion'] = $users->getAssertion($user['userid'],
$serverhostname, $user, $challengekeyid, $challenge, $challengeprefix);
$out['actionsuccess'] = true;
} else {
$out['actionerror'] = 'Your username is already taken.';
}
}
public function changepassword($dispatcher, &$reqData, &$out) {
global $users, $curuser;
if (!$_POST ||
!isset($reqData['oldpassword']) ||
!isset($reqData['password']) ||
!isset($reqData['cpassword'])) {
$out['actionerror'] = 'Invalid request.';
} else if (!$curuser['loggedin']) {
$out['actionerror'] = 'Your session has expired. Please log in again.';
} else if ($reqData['password'] !== $reqData['cpassword']) {
$out['actionerror'] = 'Your new passwords do not match.';
} else if (!$users->passwordVerify($curuser['userid'], $reqData['oldpassword'])) {
$out['actionerror'] = 'Your old password was incorrect.';
} else if (mb_strlen($reqData['password']) < 5) {
$out['actionerror'] = 'Your new password must be at least 5 characters long.';
} else if (!$users->modifyUser($curuser['userid'], array(
'password' => $reqData['password']))) {
$out['actionerror'] = 'A database error occurred. Please try again.';
} else {
$out['actionsuccess'] = true;
}
}
public function changeusername($dispatcher, &$reqData, &$out) {
global $users, $curuser;
if (!$_POST ||
!isset($reqData['username'])) {
$out['actionerror'] = 'Invalid request.';
} else if (!$curuser['loggedin']) {
$out['actionerror'] = 'Your session has expired. Please log in again.';
} else if (!$users->modifyUser($curuser['userid'], array(
'username' => $reqData['username']))) {
$out['actionerror'] = 'A database error occurred. Please try again.';
} else {
$out['actionsuccess'] = true;
}
}
public function logout($dispatcher, &$reqData, &$out) {
global $users, $curuser;
if (!$_POST ||
!isset($reqData['userid']) ||
// some CSRF protection (client must know current userid)
($reqData['userid'] !== $curuser['userid'])) {
die;
}
$users->logout(); // this kills the `sid` cookie
setcookie('showdown_username', '', time()-60*60*24*2, '/', 'play.pokemonshowdown.com');
$out['actionsuccess'] = true;
}
public function getassertion($dispatcher, &$reqData, &$out) {
global $users, $curuser;
$challengeprefix = $dispatcher->verifyCrossDomainRequest();
$serverhostname = '' . $dispatcher->getServerHostName(@$reqData['serverid']);
$challengekeyid = !isset($reqData['challengekeyid']) ? -1 : intval($reqData['challengekeyid']);
$challenge = !isset($reqData['challenge']) ? '' : $reqData['challenge'];
if (!$challenge) {
$challenge = !isset($reqData['challstr']) ? '' : $reqData['challstr'];
}
header('Content-Type: text/plain; charset=utf-8');
if (empty($reqData['userid'])) {
$userid = $curuser['userid'];
if ($userid === 'guest') {
// Special error message for this case.
die(';');
}
} else {
$userid = $users->userid($reqData['userid']);
}
$serverhostname = htmlspecialchars($serverhostname); // Protect against theoretical IE6 XSS
die($users->getAssertion($userid, $serverhostname, null, $challengekeyid, $challenge, $challengeprefix));
}
public function upkeep($dispatcher, &$reqData, &$out) {
global $users, $curuser;
$challengeprefix = $dispatcher->verifyCrossDomainRequest();
$out['loggedin'] = $curuser['loggedin'];
$userid = '';
if ($curuser['loggedin']) {
$out['username'] = $curuser['username'];
$userid = $curuser['userid'];
} else if (isset($_COOKIE['showdown_username'])) {
$out['username'] = $_COOKIE['showdown_username'];
$userid = $users->userid($out['username']);
}
if ($userid !== '') {
$serverhostname = '' . $dispatcher->getServerHostName(@$reqData['serverid']);
$challengekeyid = !isset($reqData['challengekeyid']) ? -1 : intval($reqData['challengekeyid']);
$challenge = !isset($reqData['challenge']) ? '' : $reqData['challenge'];
if (!$challenge) {
$challenge = !isset($reqData['challstr']) ? '' : $reqData['challstr'];
}
$out['assertion'] = $users->getAssertion($userid, $serverhostname, null, $challengekeyid, $challenge, $challengeprefix);
}
}
public function updateuserstats($dispatcher, &$reqData, &$out) {
global $psdb;
$server = $dispatcher->findServer();
if (!$server) {
$out = 0;
return;
}
$date = @$reqData['date'];
$usercount = @$reqData['users'];
if (!is_numeric($date) || !is_numeric($usercount)) {
$out = 0;
return;
}
$out = !!$psdb->query(
"INSERT INTO `ntbb_userstats` (`serverid`, `date`, `usercount`) " .
"VALUES ('" . $psdb->escape($server['id']) . "', '" . $psdb->escape($date) . "', '" . $psdb->escape($usercount) . "') " .
"ON DUPLICATE KEY UPDATE `date`='" . $psdb->escape($date) . "', `usercount`='" . $psdb->escape($usercount) . "'");
if ($server['id'] === 'showdown') {
$psdb->query(
"INSERT INTO `ntbb_userstatshistory` (`date`, `usercount`) " .
"VALUES ('" . $psdb->escape($date) . "', '" . $psdb->escape($usercount) . "')");
}
$dispatcher->setPrefix(''); // No need for prefix since only usable by server.
}
public function prepreplay($dispatcher, &$reqData, &$out) {
global $psdb, $users;
// include_once dirname(__FILE__) . '/ntbb-ladder.lib.php'; // not clear if this is needed
$server = $dispatcher->findServer();
if (!$server) {
$out['errorip'] = $dispatcher->getIp();
return;
}
if (
// the server must be registered
!$server ||
// the server must send all the required values
!isset($reqData['id']) ||
!isset($reqData['format']) ||
!isset($reqData['loghash']) ||
!isset($reqData['p1']) ||
!isset($reqData['p2']) ||
// player usernames cannot be longer than 18 characters
(mb_strlen($reqData['p1']) > 18) ||
(mb_strlen($reqData['p2']) > 18) ||
// the battle ID must be valid
!preg_match('/^([a-z0-9]+)-[0-9]+$/', $reqData['id'], $m1) ||
// the format ID must be valid
!preg_match('/^([a-z0-9]+)$/', $reqData['format'], $m2) ||
// the format from the battle ID must match the format ID
($m1[1] !== $m2[1])) {
$out = 0;
return;
}
if ($server['id'] !== 'showdown') {
$reqData['id'] = $server['id'].'-'.$reqData['id'];
}
$reqData['serverid'] = $server['id'];
include_once __DIR__.'/../replays/replays.lib.php';
$out = $GLOBALS['Replays']->prepUpload($reqData);
$dispatcher->setPrefix(''); // No need for prefix since only usable by server.
}
public function uploadreplay($dispatcher, &$reqData, &$out) {
global $psdb, $users;
header('Content-Type: text/plain; charset=utf-8');
include __DIR__.'/../replays/replays.lib.php';
die($GLOBALS['Replays']->upload($reqData));
}
public function invalidatecss($dispatcher, &$reqData, &$out) {
$server = $dispatcher->findServer();
if (!$server) {
$out['errorip'] = $dispatcher->getIp();
return;
}
// No need to sanitise $server['id'] because it should be safe already.
$cssfile = dirname(__FILE__) . '/../../pokemonshowdown.com/config/customcss/' . $server['id'] . '.css';
@unlink($cssfile);
}
/**
* This function returns all friends of $curuser
* Formatting:
* [prefix][username]|[prefix][username]|...
* [prefix] is empty if the request has been accepted, a hash (symbol)
* if a request has been sent by the player and is still pending,
* or a comma if it is a received friend request.
* [username] is the username (yes, NAME, not id) of the player.
* Example: Zarel|,haunter|#chaos
* Zarel is already on the friend list, a friend request from haunter is
* still waiting for approval, and a friend request has been sent to
* chaos.
*/
public function getfriends($dispatcher, &$reqData, &$out) {
global $psdb, $curuser;
// A valid curuser array is needed
if (!@$curuser['loggedin'] || !@$curuser['userid']) {
die('Not using a valid nick; you should be registered and logged in in order to add friends.');
}
$player = $psdb->escape($curuser['userid']);
$friends = array();
$friendsQuery = $psdb->query(
"SELECT `us`.`username`, `fr`.`p1`, `fr`.`accepted` " .
"FROM `ntbb_friendlist` AS `fr` " .
"INNER JOIN `ntbb_users` AS `us` ON `us`.`userid` = IF(`fr`.`p1` = '" . $player . "', `fr`.`p2`, `fr`.`p1`) " .
"WHERE `p1`='" . $player . "' OR `p2`='" . $player . "'"
);
while ($friend = $psdb->fetch_assoc($friendsQuery)) {
$prefix = '';
if ((int) $friend['accepted'] === 0) {
if ($friend['p1'] === $curuser['userid']) {
// Request sent and pending
$prefix = '#';
} else {
// Received friend request
$prefix = ',';
}
}
$friends[] = $prefix . $friend['username'];
}
// We add ] here so that we can check in the client if it's a valid
// response that we've received (i.e. check if there are errors or not)
die(']' . implode('|', $friends));
}
/**
* This function does two things:
* - sends a friend request if the player is not in his/her friend list yet
* - accepts a friend request sent by the player given in the query string
*/
public function addfriend($dispatcher, &$reqData, &$out) {
global $psdb, $users, $curuser;
// A valid curuser array is needed
if (!@$curuser['loggedin'] || !@$curuser['userid']) {
die('Not using a valid nick; you should be registered and logged in in order to add friends.');
}
// Check if the other player exists
$id = $users->userid(@$reqData['player']);
if (!$id) die('Invalid playername given.');
$player = $users->getUser($id);
if (!$player) die('The given player does not exist.');
// Check if there isn't a friendship between those two already
$p1 = $psdb->escape($curuser['userid']);
$p2 = $psdb->escape($player['userid']);
$res = $psdb->query(
"SELECT p1, accepted " .
"FROM `ntbb_friendlist` " .
"WHERE (" .
"`p1`='" . $p1 . "' AND `p2`='" . $p2 . "'" .
") OR (" .
"`p1`='" . $p2 . "' AND `p2`='" . $p1 . "'" .
")"
);
$record = $psdb->fetch_assoc($res);
if ($record) {
// A record in the database exists. Now we check if it's accepted
// or not. If not, we'll accept it, otherwise send an error
if ($record['p1'] !== $curuser['userid'] && ((int) $record['accepted']) === 0) {
$psdb->query("UPDATE `ntbb_friendlist` SET `accepted` = '1' WHERE `p1`='" . $p2 . "' AND `p2`='" . $p1 . "'");
// The ] denotes that it was successful
die(']The friend request by ' . $player['username'] . ' has been accepted.');
} else {
die('This player is already in your friend list or a friend request is still pending.');
}
}
// Everything's okay, so insert it
$psdb->query("INSERT INTO `ntbb_friendlist` (`p1`, `p2`) VALUES ('" . $p1 . "', '" . $p2 . "')");
// The ] denotes that it was successful
die(']A friend request has been sent to ' . $player['username'] . '!');
}
/**
* This function simply removes the friend given in the query string.
*/
public function removefriend($dispatcher, &$reqData, &$out) {
global $psdb, $users, $curuser;
// A valid curuser array is needed
if (!@$curuser['loggedin'] || !@$curuser['userid']) {
die('Not using a valid nick; you should be registered and logged in in order to add friends.');
}
$userid = $psdb->escape($curuser['userid']);
$player = $psdb->escape($reqData['player']);
$res = $psdb->query(
"DELETE FROM `ntbb_friendlist` " .
"WHERE (" .
"`p1`='" . $userid . "' AND `p2`='" . $player . "'" .
") OR (" .
"`p1`='" . $player . "' AND `p2`='" . $userid . "'" .
") " .
"LIMIT 1"
);
if (mysqli_affected_rows($psdb->db)) die(']' . $reqData['player'] . ' has been removed from your friend list.');
die('Could not remove ' . $reqData['player'] . ' from your friend list.');
}
}
// This class should not depend on ntbb-session.lib.php.
class LadderActionHandler {
// There's no need to make a database query for this.
private function getUserData($username) {
if (!$username) $username = '';
$userid = strtr($username, "ABCDEFGHIJKLMNOPQRSTUVWXYZ", "abcdefghijklmnopqrstuvwxyz");
$userid = preg_replace('/[^A-Za-z0-9]+/', '', $userid);
if (mb_strlen($userid) > 18) return false;
return array('userid' => $userid, 'username' => $username);
}
public function ladderupdate($dispatcher, &$reqData, &$out) {
include_once dirname(__FILE__) . '/ntbb-ladder.lib.php';
$server = $dispatcher->findServer();
if (!$server || $server['id'] !== 'showdown') {
$out['errorip'] = "Your version of PS is too old for this ladder system. Please update.";
return;
}
$ladder = new NTBBLadder(@$reqData['format']);
$p1 = $this->getUserData(@$reqData['p1']);
$p2 = $this->getUserData(@$reqData['p2']);
if (!$p1 || !$p2) {
// The server should not send usernames > 18 characters long.
$out = 0;
return;
}
$ladder->updateRating($p1, $p2, floatval($reqData['score']));
$out['actionsuccess'] = true;
$out['p1rating'] = $p1['rating'];
$out['p2rating'] = $p2['rating'];
unset($out['p1rating']['rpdata']);
unset($out['p2rating']['rpdata']);
$dispatcher->setPrefix(''); // No need for prefix since only usable by server.
}
public function ladderget($dispatcher, &$reqData, &$out) {
global $PokemonServers;
include_once dirname(__FILE__) . '/ntbb-ladder.lib.php';
$server = @$PokemonServers[@$reqData['serverid']];
if (!$server || $server['id'] !== 'showdown') {
die;
}
$ladder = new NTBBLadder(@$reqData['format']);
$user = $this->getUserData(@$reqData['user']);
if (!$user) die;
$ladder->getAllRatings($user);
$out = $user['ratings'];
}
public function mmr($dispatcher, &$reqData, &$out) {
global $PokemonServers;
include_once dirname(__FILE__) . '/ntbb-ladder.lib.php';
$server = $dispatcher->findServer();
if (!$server || $server['id'] !== 'showdown') {
$out['errorip'] = "Your version of PS is too old for this ladder system. Please update.";
return;
}
$ladder = new NTBBLadder(@$reqData['format']);
$user = $this->getUserData(@$reqData['user']);
$out = 1000;
if ($user) {
$ladder->getRating($user);
if (@$user['rating']) {
$out = intval($user['rating']['elo']);
}
}
}
}