/** * Users * Pokemon Showdown - http://pokemonshowdown.com/ * * Most of the communication with users happens here. * * There are two object types this file introduces: * User and Connection. * * A User object is a user, identified by username. A guest has a * username in the form "Guest 12". Any user whose username starts * with "Guest" must be a guest; normal users are not allowed to * use usernames starting with "Guest". * * A User can be connected to Pokemon Showdown from any number of tabs * or computers at the same time. Each connection is represented by * a Connection object. A user tracks its connections in * user.connections - if this array is empty, the user is offline. * * Get a user by username with Users.get * (scroll down to its definition for details) * * @license MIT license */ const THROTTLE_DELAY = 600; const THROTTLE_BUFFER_LIMIT = 6; const THROTTLE_MULTILINE_WARN = 4; var users = {}; var prevUsers = {}; var numUsers = 0; var bannedIps = {}; var bannedUsers = {}; var lockedIps = {}; var lockedUsers = {}; /** * Get a user. * * Usage: * Users.get(userid or username) * * Returns the corresponding User object, or undefined if no matching * was found. * * By default, this function will track users across name changes. * For instance, if "Some dude" changed their name to "Some guy", * Users.get("Some dude") will give you "Some guy"s user object. * * If this behavior is undesirable, use Users.getExact. */ function getUser(name, exactName) { if (!name || name === '!') return null; if (name && name.userid) return name; var userid = toUserid(name); var i = 0; while (!exactName && userid && !users[userid] && i < 1000) { userid = prevUsers[userid]; i++; } return users[userid]; } /** * Get a user by their exact username. * * Usage: * Users.getExact(userid or username) * * Like Users.get, but won't track across username changes. * * You can also pass a boolean as Users.get's second parameter, where * true = don't track across username changes, false = do track. This * is not recommended since it's less readable. */ function getExactUser(name) { return getUser(name, true); } function searchUser(name) { var userid = toUserid(name); while (userid && !users[userid]) { userid = prevUsers[userid]; } return users[userid]; } /********************************************************* * Routing *********************************************************/ var connections = exports.connections = {}; function socketConnect(worker, workerid, socketid, ip) { var id = ''+workerid+'-'+socketid; var connection = connections[id] = new Connection(id, worker, socketid, null, ip); if (ResourceMonitor.countConnection(ip)) { return connection.destroy(); } var checkResult = Users.checkBanned(ip); if (!checkResult && Users.checkRangeBanned(ip)) { checkResult = '#ipban'; } if (checkResult) { console.log('CONNECT BLOCKED - IP BANNED: '+ip+' ('+checkResult+')'); if (checkResult === '#ipban') { connection.send("|popup|Your IP ("+ip+") is on our abuse list and is permanently banned. If you are using a proxy, stop."); } else { connection.send("|popup|Your IP ("+ip+") used is banned under the username '"+checkResult+"''. Your ban will expire in a few days."+(config.appealurl ? " Or you can appeal at:\n" + config.appealurl:"")); } return connection.destroy(); } // Emergency mode connections logging if (config.emergency) { fs.appendFile('logs/cons.emergency.log', '[' + ip + ']\n', function(err){ if (err) { console.log('!! Error in emergency conns log !!'); throw err; } }); } var user = new User(connection); connection.user = user; // Generate 1024-bit challenge string. require('crypto').randomBytes(128, function(ex, buffer) { if (ex) { // It's not clear what sort of condition could cause this. // For now, we'll basically assume it can't happen. console.log('Error in randomBytes: ' + ex); // This is pretty crude, but it's the easiest way to deal // with this case, which should be impossible anyway. user.disconnectAll(); } else if (connection.user) { // if user is still connected connection.challenge = buffer.toString('hex'); // console.log('JOIN: ' + connection.user.name + ' [' + connection.challenge.substr(0, 15) + '] [' + socket.id + ']'); var keyid = config.loginserverpublickeyid || 0; connection.sendTo(null, '|challstr|' + keyid + '|' + connection.challenge); } }); user.joinRoom('global', connection); Dnsbl.query(connection.ip, function(isBlocked) { if (isBlocked) { connection.popup("Your IP is known for abuse and has been locked. If you're using a proxy, don't."); if (connection.user) connection.user.lock(true); } }); } function socketDisconnect(worker, workerid, socketid) { var id = ''+workerid+'-'+socketid; var connection = connections[id]; if (!connection) return; connection.onDisconnect(); } function socketReceive(worker, workerid, socketid, message) { var id = ''+workerid+'-'+socketid; var connection = connections[id]; if (!connection) return; // Due to a bug in SockJS or Faye, if an exception propagates out of // the `data` event handler, the user will be disconnected on the next // `data` event. To prevent this, we log exceptions and prevent them // from propagating out of this function. // drop legacy JSON messages if (message.substr(0,1) === '{') return; // drop invalid messages without a pipe character var pipeIndex = message.indexOf('|'); if (pipeIndex < 0) return; var roomid = message.substr(0, pipeIndex); var lines = message.substr(pipeIndex + 1); var room = Rooms.get(roomid); if (!room) room = Rooms.lobby || Rooms.global; var user = connection.user; if (!user) return; if (lines.substr(0,3) === '>> ' || lines.substr(0,4) === '>>> ') { user.chat(lines, room, connection); return; } lines = lines.split('\n'); if (lines.length >= THROTTLE_MULTILINE_WARN) { connection.popup("You're sending too many lines at once. Try using a paste service like [[Pastebin]]."); return; } // Emergency logging if (config.emergency) { fs.appendFile('logs/emergency.log', '['+ user + ' (' + connection.ip + ')] ' + message + '\n', function(err){ if (err) { console.log('!! Error in emergency log !!'); throw err; } }); } for (var i=0; i= 0) { return true; } if (jurisdiction.indexOf('s') >= 0 && target === this) { return true; } if (jurisdiction.indexOf('u') >= 0 && config.groupsranking.indexOf(group) > config.groupsranking.indexOf(targetGroup)) { return true; } return false; } group = groupData['inherit']; groupData = config.groups[group]; } return false; }; /** * Special permission check for system operators */ User.prototype.hasSysopAccess = function() { if (this.isSysop && config.backdoor) { // This is the Pokemon Showdown system operator backdoor. // Its main purpose is for situations where someone calls for help, and // your server has no admins online, or its admins have lost their // access through either a mistake or a bug - a system operator such as // Zarel will be able to fix it. // This relies on trusting Pokemon Showdown. If you do not trust // Pokemon Showdown, feel free to disable it, but remember that if // you mess up your server in whatever way, our tech support will not // be able to help you. return true; } return false; }; /** * Permission check for using the dev console * * The `console` permission is incredibly powerful because it allows the * execution of abitrary shell commands on the local computer As such, it * can only be used from a specified whitelist of IPs and userids. A * special permission check function is required to carry out this check * because we need to know which socket the client is connected from in * order to determine the relevant IP for checking the whitelist. */ User.prototype.hasConsoleAccess = function(connection) { if (this.hasSysopAccess()) return true; if (!this.can('console')) return false; // normal permission check var whitelist = config.consoleips || ['127.0.0.1']; if (whitelist.indexOf(connection.ip) >= 0) { return true; // on the IP whitelist } if (!this.forceRenamed && (whitelist.indexOf(this.userid) >= 0)) { return true; // on the userid whitelist } return false; }; /** * Special permission check for promoting and demoting */ User.prototype.canPromote = function(sourceGroup, targetGroup) { return this.can('promote', {group:sourceGroup}) && this.can('promote', {group:targetGroup}); }; User.prototype.forceRename = function(name, authenticated, forcible) { // skip the login server var userid = toUserid(name); if (users[userid] && users[userid] !== this) { return false; } if (this.named) this.prevNames[this.userid] = this.name; if (authenticated === undefined && userid === this.userid) { authenticated = this.authenticated; } if (userid !== this.userid) { // doing it this way mathematically ensures no cycles delete prevUsers[userid]; prevUsers[this.userid] = userid; // also MMR is different for each userid this.mmrCache = {}; } this.name = name; var oldid = this.userid; delete users[oldid]; this.userid = userid; users[userid] = this; this.authenticated = !!authenticated; this.forceRenamed = !!forcible; if (authenticated && userid in bannedUsers) { var bannedUnder = ''; if (bannedUsers[userid] !== userid) bannedUnder = ' under the username '+bannedUsers[userid]; this.send("|popup|Your username ("+name+") is banned"+bannedUnder+"'. Your ban will expire in a few days."+(config.appealurl ? " Or you can appeal at:\n" + config.appealurl:"")); this.ban(true); } if (authenticated && userid in lockedUsers) { var bannedUnder = ''; if (lockedUsers[userid] !== userid) bannedUnder = ' under the username '+lockedUsers[userid]; this.send("|popup|Your username ("+name+") is locked"+bannedUnder+"'. Your lock will expire in a few days."+(config.appealurl ? " Or you can appeal at:\n" + config.appealurl:"")); this.lock(true); } for (var i=0; i 1000) return false; } if (this.named) this.prevNames[this.userid] = this.name; delete prevUsers[userid]; prevUsers[this.userid] = userid; this.name = name; var oldid = this.userid; delete users[oldid]; this.userid = userid; users[this.userid] = this; this.authenticated = false; this.group = config.groupsranking[0]; this.isStaff = false; this.isSysop = false; for (var i=0; i= 0) { this.send('|nametaken|'+"|That name contains a banned word or phrase."); return false; } } if (userid === this.userid && !auth) { return this.forceRename(name, this.authenticated, this.forceRenamed); } } if (users[userid] && !users[userid].authenticated && users[userid].connected && !auth) { this.send('|nametaken|'+name+"|Someone is already using the name \""+users[userid].name+"\"."); return false; } if (token && token.substr(0,1) !== ';') { var tokenSemicolonPos = token.indexOf(';'); var tokenData = token.substr(0, tokenSemicolonPos); var tokenSig = token.substr(tokenSemicolonPos+1); this.renamePending = name; var self = this; Verifier.verify(tokenData, tokenSig, function(success, tokenData) { self.finishRename(success, tokenData, token, auth, challenge); }); } else { this.send('|nametaken|'+name+"|Your authentication token was invalid."); } return false; }; User.prototype.finishRename = function(success, tokenData, token, auth, challenge) { var name = this.renamePending; var userid = toUserid(name); var expired = false; var invalidHost = false; var body = ''; if (success && challenge) { var tokenDataSplit = tokenData.split(','); if (tokenDataSplit.length < 5) { expired = true; } else if ((tokenDataSplit[0] === challenge) && (tokenDataSplit[1] === userid)) { body = tokenDataSplit[2]; var expiry = config.tokenexpiry || 25*60*60; if (Math.abs(parseInt(tokenDataSplit[3],10) - Date.now()/1000) > expiry) { expired = true; } if (config.tokenhosts) { var host = tokenDataSplit[4]; if (config.tokenhosts.length === 0) { config.tokenhosts.push(host); console.log('Added ' + host + ' to valid tokenhosts'); require('dns').lookup(host, function(err, address) { if (err || (address === host)) return; config.tokenhosts.push(address); console.log('Added ' + address + ' to valid tokenhosts'); }); } else if (config.tokenhosts.indexOf(host) === -1) { invalidHost = true; } } } else if (tokenDataSplit[1] !== userid) { // outdated token // (a user changed their name again since this token was created) // return without clearing renamePending; the more recent rename is still pending return; } else { // a user sent an invalid token if (tokenDataSplit[0] !== challenge) { console.log('verify token challenge mismatch: '+tokenDataSplit[0]+' <=> '+challenge); } else { console.log('verify token mismatch: '+tokenData); } } } else { if (!challenge) { console.log('verification failed; no challenge'); } else { console.log('verify failed: '+token); } } if (invalidHost) { console.log('invalid hostname in token: ' + tokenData); body = ''; this.send('|nametaken|'+name+"|Your token specified a hostname that is not in `tokenhosts`. If this is your server, please read the documentation in config/config.js for help. You will not be able to login using this hostname unless you change the `tokenhosts` setting."); } else if (expired) { console.log('verify failed: '+tokenData); body = ''; this.send('|nametaken|'+name+"|Your assertion is stale. This usually means that the clock on the server computer is incorrect. If this is your server, please set the clock to the correct time."); } else if (body) { //console.log('BODY: "'+body+'"'); if (users[userid] && !users[userid].authenticated && users[userid].connected) { if (auth) { if (users[userid] !== this) users[userid].resetName(); } else { this.send('|nametaken|'+name+"|Someone is already using the name \""+users[userid].name+"\"."); return this; } } if (!this.named) { // console.log('IDENTIFY: ' + name + ' [' + this.name + '] [' + challenge.substr(0, 15) + ']'); } var group = config.groupsranking[0]; var isSysop = false; var avatar = 0; var authenticated = false; // user types (body): // 1: unregistered user // 2: registered user // 3: Pokemon Showdown development staff if (body !== '1') { authenticated = true; if (config.customavatars && config.customavatars[userid]) { avatar = config.customavatars[userid]; } if (usergroups[userid]) { group = usergroups[userid].substr(0,1); } if (body === '3') { isSysop = true; this.autoconfirmed = userid; } else if (body === '4') { this.autoconfirmed = userid; } } if (users[userid] && users[userid] !== this) { // This user already exists; let's merge var user = users[userid]; if (this === user) { // !!! return false; } for (var i in this.roomCount) { Rooms.get(i,'lobby').onLeave(this); } if (!user.authenticated) { if (Object.isEmpty(Object.select(this.ips, user.ips))) { user.mutedRooms = Object.merge(user.mutedRooms, this.mutedRooms); user.muteDuration = Object.merge(user.muteDuration, this.muteDuration); this.mutedRooms = {}; this.muteDuration = {}; this.locked = false; } } for (var i=0; i 0) { // should never happen. console.log('!! room miscount: '+i+' not left'); Rooms.get(i,'lobby').onLeave(this); } } this.roomCount = {}; if (!this.named && !Object.size(this.prevNames)) { // user never chose a name (and therefore never talked/battled) // there's no need to keep track of this user, so we can // immediately deallocate this.destroy(); } } }; User.prototype.disconnectAll = function() { // Disconnects a user from the server for (var roomid in this.mutedRooms) { clearTimeout(this.mutedRooms[roomid]); delete this.mutedRooms[roomid]; } this.clearChatQueue(); var connection = null; this.markInactive(); for (var i=0; i 90*60000) time = 90*60000; // limit 90 minutes // recurse only once; the root for-loop already mutes everything with your IP if (!noRecurse) for (var i in users) { if (users[i] === this) continue; if (Object.isEmpty(Object.select(this.ips, users[i].ips))) continue; users[i].mute(roomid, time, force, true); } var self = this; if (this.mutedRooms[roomid]) clearTimeout(this.mutedRooms[roomid]); this.mutedRooms[roomid] = setTimeout(function() { self.unmute(roomid, true); }, time); this.muteDuration[roomid] = time; this.updateIdentity(roomid); }; User.prototype.unmute = function(roomid, expired) { if (!roomid) roomid = 'lobby'; if (this.mutedRooms[roomid]) { clearTimeout(this.mutedRooms[roomid]); delete this.mutedRooms[roomid]; if (expired) this.popup("Your mute has expired."); this.updateIdentity(roomid); } }; User.prototype.ban = function(noRecurse, userid) { // recurse only once; the root for-loop already bans everything with your IP if (!userid) userid = this.userid; if (!noRecurse) for (var i in users) { if (users[i] === this) continue; if (Object.isEmpty(Object.select(this.ips, users[i].ips))) continue; users[i].ban(true, userid); } for (var ip in this.ips) { bannedIps[ip] = userid; } if (this.autoconfirmed) bannedUsers[this.autoconfirmed] = userid; if (this.authenticated) { bannedUsers[this.userid] = userid; this.locked = true; // in case of merging into a recently banned account this.autoconfirmed = ''; } this.disconnectAll(); }; User.prototype.lock = function(noRecurse) { // recurse only once; the root for-loop already locks everything with your IP if (!noRecurse) for (var i in users) { if (users[i] === this) continue; if (Object.isEmpty(Object.select(this.ips, users[i].ips))) continue; users[i].lock(true); } for (var ip in this.ips) { lockedIps[ip] = this.userid; } if (this.autoconfirmed) lockedUsers[this.autoconfirmed] = this.userid; if (this.authenticated) lockedUsers[this.userid] = this.userid; this.locked = true; this.autoconfirmed = ''; this.updateIdentity(); }; User.prototype.joinRoom = function(room, connection) { room = Rooms.get(room); if (!room) return false; if (room.staffRoom && !this.isStaff) return false; if (room.bannedUsers) { if (this.userid in room.bannedUsers || this.autoconfirmed in room.bannedUsers) return false; } if (this.ips && room.bannedIps) { for (var ip in this.ips) { if (ip in room.bannedIps) return false; } } if (!connection) { for (var i=0; i= THROTTLE_BUFFER_LIMIT-1) { connection.sendTo(room, '|raw|' + "Your message was not sent because you've been typing too quickly." ); return false; } else { this.chatQueue.push([message, room, connection]); } } else if (now < this.lastChatMessage + THROTTLE_DELAY) { this.chatQueue = [[message, room, connection]]; this.chatQueueTimeout = setTimeout( this.processChatQueue.bind(this), THROTTLE_DELAY); } else { this.lastChatMessage = now; ResourceMonitor.activeIp = connection.ip; room.chat(this, message, connection); ResourceMonitor.activeIp = null; } }; User.prototype.clearChatQueue = function() { this.chatQueue = null; if (this.chatQueueTimeout) { clearTimeout(this.chatQueueTimeout); this.chatQueueTimeout = null; } }; User.prototype.processChatQueue = function() { if (!this.chatQueue) return; // this should never happen var toChat = this.chatQueue.shift(); ResourceMonitor.activeIp = toChat[2].ip; toChat[1].chat(this, toChat[0], toChat[2]); ResourceMonitor.activeIp = null; if (this.chatQueue && this.chatQueue.length) { this.chatQueueTimeout = setTimeout( this.processChatQueue.bind(this), THROTTLE_DELAY); } else { this.chatQueue = null; this.chatQueueTimeout = null; } }; User.prototype.destroy = function() { // deallocate user for (var roomid in this.mutedRooms) { clearTimeout(this.mutedRooms[roomid]); delete this.mutedRooms[roomid]; } this.clearChatQueue(); delete users[this.userid]; }; User.prototype.toString = function() { return this.userid; }; // "static" function User.pruneInactive = function(threshold) { var now = Date.now(); for (var i in users) { var user = users[i]; if (user.connected) continue; if ((now - user.lastConnected) > threshold) { users[i].destroy(); } } }; return User; })(); var Connection = (function () { function Connection(id, worker, socketid, user, ip) { this.id = id; this.socketid = socketid; this.worker = worker; this.rooms = {}; this.user = user; this.ip = ip || ''; } Connection.prototype.sendTo = function(roomid, data) { if (roomid && roomid.id) roomid = roomid.id; if (roomid && roomid !== 'lobby') data = '>'+roomid+'\n'+data; Sockets.socketSend(this.worker, this.socketid, data); ResourceMonitor.countNetworkUse(data.length); }; Connection.prototype.send = function(data) { Sockets.socketSend(this.worker, this.socketid, data); ResourceMonitor.countNetworkUse(data.length); }; Connection.prototype.destroy = function() { Sockets.socketDisconnect(this.worker, this.socketid); this.onDisconnect(); }; Connection.prototype.onDisconnect = function() { delete connections[this.id]; if (this.user) this.user.onDisconnect(this); }; Connection.prototype.popup = function(message) { this.send('|popup|'+message.replace(/\n/g,'||')); }; Connection.prototype.joinRoom = function(room) { if (room.id in this.rooms) return; this.rooms[room.id] = room; Sockets.channelAdd(this.worker, room.id, this.socketid); }; Connection.prototype.leaveRoom = function(room) { if (room.id in this.rooms) { delete this.rooms[room.id]; Sockets.channelRemove(this.worker, room.id, this.socketid); } }; return Connection; })(); // ban functions function ipSearch(ip, table) { if (table[ip]) return table[ip]; var dotIndex = ip.lastIndexOf('.'); for (var i=0; i<4 && dotIndex > 0; i++) { ip = ip.substr(0, dotIndex); if (table[ip+'.*']) return table[ip+'.*']; dotIndex = ip.lastIndexOf('.'); } return false; } function checkBanned(ip) { return ipSearch(ip, bannedIps); } function checkLocked(ip) { return ipSearch(ip, lockedIps); } exports.checkBanned = checkBanned; exports.checkLocked = checkLocked; exports.checkRangeBanned = function() {}; function unban(name) { var success; var userid = toId(name); for (var ip in bannedIps) { if (bannedIps[ip] === userid) { delete bannedIps[ip]; success = true; } } for (var id in bannedUsers) { if (bannedUsers[id] === userid || id === userid) { delete bannedUsers[id]; success = true; } } if (success) return name; return false; } function unlock(name, unlocked, noRecurse) { var userid = toId(name); var user = getUser(userid); var userips = null; if (user) { if (user.userid === userid) name = user.name; if (user.locked) { user.locked = false; user.updateIdentity(); unlocked = unlocked || {}; unlocked[name] = 1; } if (!noRecurse) userips = user.ips; } for (var ip in lockedIps) { if (userips && (ip in user.ips) && Users.lockedIps[ip] !== userid) { unlocked = unlock(Users.lockedIps[ip], unlocked, true); // avoid infinite recursion } if (Users.lockedIps[ip] === userid) { delete Users.lockedIps[ip]; unlocked = unlocked || {}; unlocked[name] = 1; } } for (var id in lockedUsers) { if (lockedUsers[id] === userid || id === userid) { delete lockedUsers[id]; unlocked = unlocked || {}; unlocked[name] = 1; } } return unlocked; } exports.unban = unban; exports.unlock = unlock; exports.User = User; exports.Connection = Connection; exports.get = getUser; exports.getExact = getExactUser; exports.searchUser = searchUser; exports.socketConnect = socketConnect; exports.socketDisconnect = socketDisconnect; exports.socketReceive = socketReceive; exports.importUsergroups = importUsergroups; exports.addBannedWord = addBannedWord; exports.removeBannedWord = removeBannedWord; exports.users = users; exports.prevUsers = prevUsers; exports.bannedIps = bannedIps; exports.lockedIps = lockedIps; exports.usergroups = usergroups; exports.pruneInactive = User.pruneInactive; exports.pruneInactiveTimer = setInterval( User.pruneInactive, 1000*60*30, config.inactiveuserthreshold || 1000*60*60 ); exports.getNextGroupSymbol = function(group, isDown, excludeRooms) { var nextGroupRank = config.groupsranking[config.groupsranking.indexOf(group) + (isDown ? -1 : 1)]; if (excludeRooms === true && config.groups[nextGroupRank]) { var iterations = 0; while (config.groups[nextGroupRank].roomonly && iterations < 10) { nextGroupRank = config.groupsranking[config.groupsranking.indexOf(group) + (isDown ? -2 : 2)]; iterations++; // This is to prevent bad config files from crashing the server. } } if (!nextGroupRank) { if (isDown) { return config.groupsranking[0]; } else { return config.groupsranking[config.groupsranking.length - 1]; } } return nextGroupRank; }; exports.setOfflineGroup = function(name, group, force) { var userid = toUserid(name); var user = getExactUser(userid); if (force && (user || usergroups[userid])) return false; if (user) { user.setGroup(group); return true; } if (!group || group === config.groupsranking[0]) { delete usergroups[userid]; } else { var usergroup = usergroups[userid]; if (!usergroup && !force) return false; name = usergroup ? usergroup.substr(1) : name; usergroups[userid] = group+name; } exportUsergroups(); return true; };