Spaces:
Running
Running
/** | |
* 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. | |
* | |
* `Users.users` is the global table of all users, a `Map` of `ID:User`. | |
* Users should normally be accessed with `Users.get(userid)` | |
* | |
* `Users.connections` is the global table of all connections, a `Map` of | |
* `string:Connection` (the string is mostly meaningless but see | |
* `connection.id` for details). Connections are normally accessed through | |
* `user.connections`. | |
* | |
* @license MIT | |
*/ | |
type StatusType = 'online' | 'busy' | 'idle'; | |
const THROTTLE_DELAY = 600; | |
const THROTTLE_DELAY_TRUSTED = 100; | |
const THROTTLE_DELAY_PUBLIC_BOT = 25; | |
const THROTTLE_BUFFER_LIMIT = 6; | |
const THROTTLE_MULTILINE_WARN = 3; | |
const THROTTLE_MULTILINE_WARN_STAFF = 6; | |
const THROTTLE_MULTILINE_WARN_ADMIN = 25; | |
const NAMECHANGE_THROTTLE = 2 * 60 * 1000; // 2 minutes | |
const NAMES_PER_THROTTLE = 3; | |
const PERMALOCK_CACHE_TIME = 30 * 24 * 60 * 60 * 1000; // 30 days | |
const DEFAULT_TRAINER_SPRITES = [1, 2, 101, 102, 169, 170, 265, 266]; | |
import { Utils, type ProcessManager } from '../lib'; | |
import { | |
Auth, GlobalAuth, PLAYER_SYMBOL, HOST_SYMBOL, type RoomPermission, type GlobalPermission, | |
} from './user-groups'; | |
const MINUTES = 60 * 1000; | |
const IDLE_TIMER = 60 * MINUTES; | |
const STAFF_IDLE_TIMER = 30 * MINUTES; | |
const CONNECTION_EXPIRY_TIME = 24 * 60 * MINUTES; | |
/********************************************************* | |
* Utility functions | |
*********************************************************/ | |
// Low-level functions for manipulating Users.users and Users.prevUsers | |
// Keeping them all here makes it easy to ensure they stay consistent | |
function move(user: User, newUserid: ID) { | |
if (user.id === newUserid) return true; | |
if (!user) return false; | |
// doing it this way mathematically ensures no cycles | |
prevUsers.delete(newUserid); | |
prevUsers.set(user.id, newUserid); | |
users.delete(user.id); | |
user.id = newUserid; | |
users.set(newUserid, user); | |
return true; | |
} | |
function add(user: User) { | |
if (user.id) throw new Error(`Adding a user that already exists`); | |
numUsers++; | |
user.guestNum = numUsers; | |
user.name = `Guest ${numUsers}`; | |
user.id = toID(user.name); | |
if (users.has(user.id)) throw new Error(`userid taken: ${user.id}`); | |
users.set(user.id, user); | |
} | |
function deleteUser(user: User) { | |
prevUsers.delete(`guest${user.guestNum}` as ID); | |
users.delete(user.id); | |
} | |
function merge(toRemain: User, toDestroy: User) { | |
prevUsers.delete(toRemain.id); | |
prevUsers.set(toDestroy.id, toRemain.id); | |
} | |
/** | |
* Get a user. | |
* | |
* Usage: | |
* Users.get(userid or username) | |
* | |
* Returns the corresponding User object, or null 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: string | User | null, exactName = false) { | |
if (!name || name === '!') return null; | |
if ((name as User).id) return name as User; | |
let userid = toID(name); | |
let i = 0; | |
if (!exactName) { | |
while (userid && !users.has(userid) && i < 1000) { | |
userid = prevUsers.get(userid)!; | |
i++; | |
} | |
} | |
return users.get(userid) || null; | |
} | |
/** | |
* Get a user by their exact username. | |
* | |
* Usage: | |
* Users.getExact(userid or username) | |
* | |
* Like Users.get, but won't track across username changes. | |
* | |
* Users.get(userid or username, true) is equivalent to | |
* Users.getExact(userid or username). | |
* The former is not recommended because it's less readable. | |
*/ | |
function getExactUser(name: string | User) { | |
return getUser(name, true); | |
} | |
/** | |
* Get a list of all users matching a list of userids and ips. | |
* | |
* Usage: | |
* Users.findUsers([userids], [ips]) | |
*/ | |
function findUsers(userids: ID[], ips: string[], options: { forPunishment?: boolean, includeTrusted?: boolean } = {}) { | |
const matches: User[] = []; | |
if (options.forPunishment) ips = ips.filter(ip => !Punishments.isSharedIp(ip)); | |
const ipMatcher = IPTools.checker(ips); | |
for (const user of users.values()) { | |
if (!options.forPunishment && !user.named && !user.connected) continue; | |
if (!options.includeTrusted && user.trusted) continue; | |
if (userids.includes(user.id)) { | |
matches.push(user); | |
continue; | |
} | |
if (user.ips.some(ipMatcher)) { | |
matches.push(user); | |
} | |
} | |
return matches; | |
} | |
/********************************************************* | |
* User groups | |
*********************************************************/ | |
const globalAuth = new GlobalAuth(); | |
function isUsernameKnown(name: string) { | |
const userid = toID(name); | |
if (Users.get(userid)) return true; | |
if (globalAuth.has(userid)) return true; | |
for (const room of Rooms.global.chatRooms) { | |
if (room.auth.has(userid)) return true; | |
} | |
return false; | |
} | |
function isUsername(name: string) { | |
return /[A-Za-z0-9]/.test(name.charAt(0)) && /[A-Za-z]/.test(name) && !name.includes(','); | |
} | |
function isTrusted(userid: ID) { | |
if (globalAuth.has(userid)) return userid; | |
for (const room of Rooms.global.chatRooms) { | |
if (room.persist && !room.settings.isPrivate && room.auth.isStaff(userid)) { | |
return userid; | |
} | |
} | |
const staffRoom = Rooms.get('staff'); | |
const staffAuth = staffRoom && !!(staffRoom.auth.has(userid) || staffRoom.users[userid]); | |
return staffAuth ? userid : false; | |
} | |
function isPublicBot(userid: ID) { | |
if (globalAuth.get(userid) === '*') return true; | |
for (const room of Rooms.global.chatRooms) { | |
if (room.persist && !room.settings.isPrivate && room.auth.get(userid) === '*') { | |
return true; | |
} | |
} | |
return false; | |
} | |
/********************************************************* | |
* User and Connection classes | |
*********************************************************/ | |
const connections = new Map<string, Connection>(); | |
export class Connection { | |
/** | |
* Connection IDs are mostly meaningless, beyond being known to be | |
* unique among connections. They set in `socketConnect` to | |
* `workerid-socketid`, so for instance `2-523` would be the 523th | |
* connection to the 2nd socket worker process. | |
*/ | |
readonly id: string; | |
readonly socketid: string; | |
readonly worker: ProcessManager.StreamWorker; | |
readonly inRooms: Set<RoomID>; | |
readonly ip: string; | |
readonly protocol: string; | |
readonly connectedAt: number; | |
/** | |
* This can be null during initialization and after disconnecting, | |
* but we're asserting it non-null for ease of use. The main risk | |
* is async code, where you need to re-check that it's not null | |
* before using it. | |
*/ | |
user: User; | |
challenge: string; | |
autojoins: string; | |
/** The last bot html page this connection requested, formatted as `${bot.id}-${pageid}` */ | |
lastRequestedPage: string | null; | |
lastActiveTime: number; | |
openPages: null | Set<string>; | |
/** | |
* Used to distinguish Connection from User. | |
* | |
* Makes it easy to do something like | |
* `for (const conn of (userOrConn.connections || [userOrConn]))` | |
*/ | |
readonly connections = null; | |
constructor( | |
id: string, | |
worker: ProcessManager.StreamWorker, | |
socketid: string, | |
user: User | null, | |
ip: string | null, | |
protocol: string | null | |
) { | |
const now = Date.now(); | |
this.id = id; | |
this.socketid = socketid; | |
this.worker = worker; | |
this.inRooms = new Set(); | |
this.ip = ip || ''; | |
this.protocol = protocol || ''; | |
this.connectedAt = now; | |
this.user = user!; | |
this.challenge = ''; | |
this.autojoins = ''; | |
this.lastRequestedPage = null; | |
this.lastActiveTime = now; | |
this.openPages = null; | |
} | |
sendTo(roomid: RoomID | BasicRoom | null, data: string) { | |
if (roomid && typeof roomid !== 'string') roomid = roomid.roomid; | |
if (roomid && roomid !== 'lobby') data = `>${roomid}\n${data}`; | |
Sockets.socketSend(this.worker, this.socketid, data); | |
Monitor.countNetworkUse(data.length); | |
} | |
send(data: string) { | |
Sockets.socketSend(this.worker, this.socketid, data); | |
Monitor.countNetworkUse(data.length); | |
} | |
destroy() { | |
Sockets.socketDisconnect(this.worker, this.socketid); | |
this.onDisconnect(); | |
} | |
onDisconnect() { | |
connections.delete(this.id); | |
if (this.user) this.user.onDisconnect(this); | |
this.user = null!; | |
} | |
popup(message: string) { | |
this.send(`|popup|` + message.replace(/\n/g, '||')); | |
} | |
joinRoom(room: Room) { | |
if (this.inRooms.has(room.roomid)) return; | |
this.inRooms.add(room.roomid); | |
Sockets.roomAdd(this.worker, room.roomid, this.socketid); | |
} | |
leaveRoom(room: Room) { | |
if (this.inRooms.has(room.roomid)) { | |
this.inRooms.delete(room.roomid); | |
Sockets.roomRemove(this.worker, room.roomid, this.socketid); | |
} | |
} | |
toString() { | |
let buf = this.user ? `${this.user.id}[${this.user.connections.indexOf(this)}]` : `[disconnected]`; | |
buf += `:${this.ip}`; | |
if (this.protocol !== 'websocket') buf += `:${this.protocol}`; | |
return buf; | |
} | |
} | |
type ChatQueueEntry = [string, RoomID, Connection]; | |
export interface UserSettings { | |
blockChallenges: boolean | AuthLevel | 'friends'; | |
blockPMs: boolean | AuthLevel | 'friends'; | |
ignoreTickets: boolean; | |
hideBattlesFromTrainerCard: boolean; | |
blockInvites: AuthLevel | boolean; | |
doNotDisturb: boolean; | |
blockFriendRequests: boolean; | |
allowFriendNotifications: boolean; | |
displayBattlesToFriends: boolean; | |
hideLogins: boolean; | |
} | |
// User | |
export class User extends Chat.MessageContext { | |
/** In addition to needing it to implement MessageContext, this is also nice for compatibility with Connection. */ | |
readonly user: User; | |
/** | |
* Not a source of truth - should always be in sync with | |
* `[...Rooms.rooms.values()].filter(room => this.id in room.users)` | |
*/ | |
readonly inRooms: Set<RoomID>; | |
/** | |
* Not a source of truth - should always in sync with | |
* `[...Rooms.rooms.values()].filter(` | |
* ` room => room.game && this.id in room.game.playerTable && !room.game.ended` | |
* `)` | |
*/ | |
readonly games: Set<RoomID>; | |
mmrCache: { [format: string]: number }; | |
guestNum: number; | |
name: string; | |
named: boolean; | |
registered: boolean; | |
id: ID; | |
tempGroup: GroupSymbol; | |
avatar: string | number; | |
language: ID | null; | |
connected: boolean; | |
connections: Connection[]; | |
latestHost: string; | |
latestHostType: string; | |
ips: string[]; | |
latestIp: string; | |
locked: ID | PunishType | null; | |
semilocked: ID | PunishType | null; | |
namelocked: ID | PunishType | null; | |
permalocked: ID | PunishType | null; | |
punishmentTimer: NodeJS.Timeout | null; | |
previousIDs: ID[]; | |
lastChallenge: number; | |
lastPM: string; | |
lastMatch: ID; | |
settings: UserSettings; | |
battleSettings: { | |
team: string, | |
hidden: boolean, | |
inviteOnly: boolean, | |
special?: string, | |
}; | |
isSysop: boolean; | |
isStaff: boolean; | |
isPublicBot: boolean; | |
lastDisconnected: number; | |
lastConnected: number; | |
foodfight?: { generatedTeam: string[], dish: string, ingredients: string[], timestamp: number }; | |
friends?: Set<string>; | |
chatQueue: ChatQueueEntry[] | null; | |
chatQueueTimeout: NodeJS.Timeout | null; | |
lastChatMessage: number; | |
lastCommand: string; | |
notified: { | |
blockChallenges: boolean, | |
blockPMs: boolean, | |
blockInvites: boolean, | |
punishment: boolean, | |
lock: boolean, | |
}; | |
lastMessage: string; | |
lastMessageTime: number; | |
lastReportTime: number; | |
lastNewNameTime = 0; | |
newNames = 0; | |
s1: string; | |
s2: string; | |
s3: string; | |
autoconfirmed: ID; | |
trusted: ID; | |
trackRename: string; | |
statusType: StatusType; | |
userMessage: string; | |
lastWarnedAt: number; | |
constructor(connection: Connection) { | |
super(connection.user); | |
this.user = this; | |
this.inRooms = new Set(); | |
this.games = new Set(); | |
this.mmrCache = Object.create(null); | |
this.guestNum = -1; | |
this.name = ""; | |
this.named = false; | |
this.registered = false; | |
this.id = ''; | |
this.tempGroup = Auth.defaultSymbol(); | |
this.language = null; | |
this.avatar = DEFAULT_TRAINER_SPRITES[Math.floor(Math.random() * DEFAULT_TRAINER_SPRITES.length)]; | |
this.connected = true; | |
Users.onlineCount++; | |
if (connection.user) connection.user = this; | |
this.connections = [connection]; | |
this.latestHost = ''; | |
this.latestHostType = ''; | |
this.ips = [connection.ip]; | |
// Note: Using the user's latest IP for anything will usually be | |
// wrong. Most code should use all of the IPs contained in | |
// the `ips` array, not just the latest IP. | |
this.latestIp = connection.ip; | |
this.locked = null; | |
this.semilocked = null; | |
this.namelocked = null; | |
this.permalocked = null; | |
this.punishmentTimer = null; | |
this.previousIDs = []; | |
// misc state | |
this.lastChallenge = 0; | |
this.lastPM = ''; | |
this.lastMatch = ''; | |
// settings | |
this.settings = { | |
blockChallenges: false, | |
blockPMs: false, | |
ignoreTickets: false, | |
hideBattlesFromTrainerCard: false, | |
blockInvites: false, | |
doNotDisturb: false, | |
blockFriendRequests: false, | |
allowFriendNotifications: false, | |
displayBattlesToFriends: false, | |
hideLogins: false, | |
}; | |
this.battleSettings = { | |
team: '', | |
hidden: false, | |
inviteOnly: false, | |
}; | |
this.isSysop = false; | |
this.isStaff = false; | |
this.isPublicBot = false; | |
this.lastDisconnected = 0; | |
this.lastConnected = connection.connectedAt; | |
// chat queue | |
this.chatQueue = null; | |
this.chatQueueTimeout = null; | |
this.lastChatMessage = 0; | |
this.lastCommand = ''; | |
// for the anti-spamming mechanism | |
this.lastMessage = ``; | |
this.lastMessageTime = 0; | |
this.lastReportTime = 0; | |
this.s1 = ''; | |
this.s2 = ''; | |
this.s3 = ''; | |
this.notified = { | |
blockChallenges: false, | |
blockPMs: false, | |
blockInvites: false, | |
punishment: false, | |
lock: false, | |
}; | |
this.autoconfirmed = ''; | |
this.trusted = ''; | |
// Used in punishments | |
this.trackRename = ''; | |
this.statusType = 'online'; | |
this.userMessage = ''; | |
this.lastWarnedAt = 0; | |
// initialize | |
Users.add(this); | |
} | |
sendTo(roomid: RoomID | BasicRoom | null, data: string) { | |
if (roomid && typeof roomid !== 'string') roomid = roomid.roomid; | |
if (roomid && roomid !== 'lobby') data = `>${roomid}\n${data}`; | |
for (const connection of this.connections) { | |
if (roomid && !connection.inRooms.has(roomid)) continue; | |
connection.send(data); | |
Monitor.countNetworkUse(data.length); | |
} | |
} | |
send(data: string) { | |
for (const connection of this.connections) { | |
connection.send(data); | |
Monitor.countNetworkUse(data.length); | |
} | |
} | |
popup(message: string) { | |
this.send(`|popup|` + message.replace(/\n/g, '||')); | |
} | |
getIdentity(room: BasicRoom | null = null) { | |
const punishgroups = Config.punishgroups || { locked: null, muted: null }; | |
if (this.locked || this.namelocked) { | |
const lockedSymbol = (punishgroups.locked?.symbol || '\u203d'); | |
return lockedSymbol + this.name; | |
} | |
if (room) { | |
if (room.isMuted(this)) { | |
const mutedSymbol = (punishgroups.muted?.symbol || '!'); | |
return mutedSymbol + this.name; | |
} | |
return room.auth.get(this) + this.name; | |
} | |
if (this.semilocked) { | |
const mutedSymbol = (punishgroups.muted?.symbol || '!'); | |
return mutedSymbol + this.name; | |
} | |
return this.tempGroup + this.name; | |
} | |
getIdentityWithStatus(room: BasicRoom | null = null) { | |
const identity = this.getIdentity(room); | |
const status = this.statusType === 'online' ? '' : '@!'; | |
return `${identity}${status}`; | |
} | |
getStatus() { | |
const statusMessage = this.statusType === 'busy' ? '!(Busy) ' : this.statusType === 'idle' ? '!(Idle) ' : ''; | |
const status = statusMessage + (this.userMessage || ''); | |
return status; | |
} | |
can(permission: RoomPermission, target: User | null, room: BasicRoom, cmd?: string, cmdToken?: string): boolean; | |
can(permission: GlobalPermission, target?: User | null): boolean; | |
can( | |
permission: RoomPermission & GlobalPermission, | |
target: User | null, | |
room?: BasicRoom | null, | |
cmd?: string, | |
cmdToken?: string, | |
): boolean; | |
can( | |
permission: string, | |
target: User | null = null, | |
room: BasicRoom | null = null, | |
cmd?: string, | |
cmdToken?: string, | |
): boolean { | |
return Auth.hasPermission(this, permission, target, room, cmd, cmdToken); | |
} | |
/** | |
* Special permission check for system operators | |
*/ | |
hasSysopAccess() { | |
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. | |
*/ | |
hasConsoleAccess(connection: Connection) { | |
if (this.hasSysopAccess()) return true; | |
if (!this.can('console')) return false; // normal permission check | |
const whitelist = Config.consoleips || ['127.0.0.1']; | |
// on the IP whitelist OR the userid whitelist | |
return whitelist.includes(connection.ip) || whitelist.includes(this.id); | |
} | |
resetName(isForceRenamed = false) { | |
return this.forceRename(`Guest ${this.guestNum}`, false, isForceRenamed); | |
} | |
updateIdentity(roomid: RoomID | null = null) { | |
if (roomid) { | |
return Rooms.get(roomid)!.onUpdateIdentity(this); | |
} | |
for (const inRoomID of this.inRooms) { | |
Rooms.get(inRoomID)!.onUpdateIdentity(this); | |
} | |
} | |
async validateToken(token: string, name: string, userid: ID, connection: Connection) { | |
if (!token && Config.noguestsecurity) { | |
if (Users.isTrusted(userid)) { | |
this.send(`|nametaken|${name}|You need an authentication token to log in as a trusted user.`); | |
return null; | |
} | |
return '1'; | |
} | |
if (!token || token.startsWith(';')) { | |
this.send(`|nametaken|${name}|Your authentication token was invalid.`); | |
return null; | |
} | |
let challenge = ''; | |
if (connection) { | |
challenge = connection.challenge; | |
} | |
if (!challenge) { | |
Monitor.warn(`verification failed; no challenge`); | |
return null; | |
} | |
const [tokenData, tokenSig] = Utils.splitFirst(token, ';'); | |
const tokenDataSplit = tokenData.split(','); | |
const [signedChallenge, signedUserid, userType, signedDate, signedHostname] = tokenDataSplit; | |
if (signedHostname && Config.legalhosts && !Config.legalhosts.includes(signedHostname)) { | |
Monitor.warn(`forged assertion: ${tokenData}`); | |
this.send(`|nametaken|${name}|Your assertion is for the wrong server. This server is ${Config.legalhosts[0]}.`); | |
return null; | |
} | |
if (tokenDataSplit.length < 5) { | |
Monitor.warn(`outdated assertion format: ${tokenData}`); | |
this.send(`|nametaken|${name}|The assertion you sent us is corrupt or incorrect. Please send the exact assertion given by the login server's JSON response.`); | |
return null; | |
} | |
if (signedUserid !== userid) { | |
// userid mismatch | |
this.send(`|nametaken|${name}|Your verification signature doesn't match your new username.`); | |
return null; | |
} | |
if (signedChallenge !== challenge) { | |
// a user sent an invalid token | |
Monitor.debug(`verify token challenge mismatch: ${signedChallenge} <=> ${challenge}`); | |
this.send(`|nametaken|${name}|Your verification signature doesn't match your authentication token.`); | |
return null; | |
} | |
const expiry = Config.tokenexpiry || 25 * 60 * 60; | |
if (Math.abs(parseInt(signedDate) - Date.now() / 1000) > expiry) { | |
Monitor.warn(`stale assertion: ${tokenData}`); | |
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.`); | |
return null; | |
} | |
const success = await Verifier.verify(tokenData, tokenSig); | |
if (!success) { | |
Monitor.warn(`verify failed: ${token}`); | |
Monitor.warn(`challenge was: ${challenge}`); | |
this.send(`|nametaken|${name}|Your verification signature was invalid.`); | |
return null; | |
} | |
// future-proofing | |
this.s1 = tokenDataSplit[5]; | |
this.s2 = tokenDataSplit[6]; | |
this.s3 = tokenDataSplit[7]; | |
return userType; | |
} | |
/** | |
* Do a rename, passing and validating a login token. | |
* | |
* @param name The name you want | |
* @param token Signed assertion returned from login server | |
* @param newlyRegistered Make sure this account will identify as registered | |
* @param connection The connection asking for the rename | |
*/ | |
async rename(name: string, token: string, newlyRegistered: boolean, connection: Connection) { | |
let userid = toID(name); | |
if (userid !== this.id) { | |
for (const roomid of this.games) { | |
const room = Rooms.get(roomid); | |
if (!room?.game || room.game.ended) { | |
this.games.delete(roomid); | |
console.log(`desynced roomgame ${roomid} renaming ${this.id} -> ${userid}`); | |
continue; | |
} | |
if (room.game.allowRenames || !this.named) continue; | |
this.popup(`You can't change your name right now because you're in ${room.game.title}, which doesn't allow renaming.`); | |
return false; | |
} | |
} | |
if (!name) name = ''; | |
if (!/[a-zA-Z]/.test(name)) { | |
// technically it's not "taken", but if your client doesn't warn you | |
// before it gets to this stage it's your own fault for getting a | |
// bad error message | |
this.send(`|nametaken||Your name must contain at least one letter.`); | |
return false; | |
} | |
if (userid.length > 18) { | |
this.send(`|nametaken||Your name must be 18 characters or shorter.`); | |
return false; | |
} | |
name = Chat.namefilter(name, this); | |
if (userid !== toID(name)) { | |
if (name) { | |
name = userid; | |
} else { | |
userid = ''; | |
} | |
} | |
if (this.registered) newlyRegistered = false; | |
if (!userid) { | |
this.send(`|nametaken||Your name contains a banned word.`); | |
return false; | |
} else { | |
if (userid === this.id && !newlyRegistered) { | |
return this.forceRename(name, this.registered); | |
} | |
} | |
const userType = await this.validateToken(token, name, userid, connection); | |
if (userType === null) return; | |
if (userType === '1') newlyRegistered = false; | |
if (!this.trusted && userType === '1') { // userType '1' means unregistered | |
const elapsed = Date.now() - this.lastNewNameTime; | |
if (elapsed < NAMECHANGE_THROTTLE && !Config.nothrottle) { | |
if (this.newNames >= NAMES_PER_THROTTLE) { | |
this.send( | |
`|nametaken|${name}|You must wait ${Chat.toDurationString(NAMECHANGE_THROTTLE - elapsed)} more | |
seconds before using another unregistered name.` | |
); | |
return false; | |
} | |
this.newNames++; | |
} else { | |
this.lastNewNameTime = Date.now(); | |
this.newNames = 1; | |
} | |
} | |
this.handleRename(name, userid, newlyRegistered, userType); | |
} | |
handleRename(name: string, userid: ID, newlyRegistered: boolean, userType: string) { | |
const registered = (userType !== '1'); | |
const conflictUser = users.get(userid); | |
if (conflictUser) { | |
// unregistered users can only merge in limited situations | |
let canMerge = registered && conflictUser.registered; | |
if ( | |
!registered && !conflictUser.registered && conflictUser.latestIp === this.latestIp && | |
!conflictUser.connected | |
) { | |
canMerge = true; | |
} | |
if (!canMerge) { | |
if (registered && !conflictUser.registered) { | |
// user has just registered; don't merge just to be safe | |
if (conflictUser !== this) conflictUser.resetName(); | |
} else { | |
this.send(`|nametaken|${name}|Someone is already using the name "${conflictUser.name}".`); | |
return false; | |
} | |
} | |
} | |
// user types: | |
// 1: unregistered user | |
// 2: registered user | |
// 3: Pokemon Showdown system operator | |
// 4: autoconfirmed | |
// 5: permalocked | |
// 6: permabanned | |
if (registered) { | |
if (userType === '3') { | |
this.isSysop = true; | |
this.isStaff = true; | |
this.trusted = userid; | |
this.autoconfirmed = userid; | |
} else if (userType === '4') { | |
this.autoconfirmed = userid; | |
} else if (userType === '5') { | |
this.permalocked = userid; | |
void Punishments.lock(this, Date.now() + PERMALOCK_CACHE_TIME, userid, true, `Permalocked as ${name}`, true); | |
} else if (userType === '6') { | |
void Punishments.lock(this, Date.now() + PERMALOCK_CACHE_TIME, userid, true, `Permabanned as ${name}`, true); | |
this.disconnectAll(); | |
} | |
} | |
if (Users.isTrusted(userid)) { | |
this.trusted = userid; | |
this.autoconfirmed = userid; | |
} | |
if (this.trusted) { | |
this.locked = null; | |
this.namelocked = null; | |
this.permalocked = null; | |
this.semilocked = null; | |
this.destroyPunishmentTimer(); | |
} | |
this.isPublicBot = Users.isPublicBot(userid); | |
Chat.runHandlers('onRename', this, this.id, userid); | |
let user = users.get(userid); | |
const possibleUser = Users.get(userid); | |
if (possibleUser?.namelocked) { | |
// allows namelocked users to be merged | |
user = possibleUser; | |
} | |
if (user && user !== this) { | |
// This user already exists; let's merge | |
user.merge(this); | |
Users.merge(user, this); | |
for (const id of this.previousIDs) { | |
if (!user.previousIDs.includes(id)) user.previousIDs.push(id); | |
} | |
if (this.named && !user.previousIDs.includes(this.id)) user.previousIDs.push(this.id); | |
this.destroy(); | |
Punishments.checkName(user, userid, registered); | |
Rooms.global.checkAutojoin(user); | |
Rooms.global.rejoinGames(user); | |
Chat.loginfilter(user, this, userType); | |
return true; | |
} | |
Punishments.checkName(this, userid, registered); | |
if (this.namelocked) { | |
Chat.loginfilter(this, null, userType); | |
return false; | |
} | |
// rename success | |
if (!this.forceRename(name, registered)) { | |
return false; | |
} | |
Rooms.global.checkAutojoin(this); | |
Rooms.global.rejoinGames(this); | |
Chat.loginfilter(this, null, userType); | |
return true; | |
} | |
forceRename(name: string, registered: boolean, isForceRenamed = false) { | |
// skip the login server | |
const userid = toID(name); | |
if (users.has(userid) && users.get(userid) !== this) { | |
return false; | |
} | |
const oldname = this.name; | |
const oldid = this.id; | |
if (userid !== this.id) { | |
this.cancelReady(); | |
if (!Users.move(this, userid)) { | |
return false; | |
} | |
// MMR is different for each userid | |
this.mmrCache = {}; | |
this.updateGroup(registered); | |
} else if (registered) { | |
this.updateGroup(registered); | |
} | |
if (this.named && oldid !== userid && !this.previousIDs.includes(oldid)) this.previousIDs.push(oldid); | |
this.name = name; | |
const joining = !this.named; | |
this.named = !userid.startsWith('guest') || !!this.namelocked; | |
if (isForceRenamed) this.userMessage = ''; | |
for (const connection of this.connections) { | |
// console.log(`${name} renaming: socket ${i} of ${this.connections.length}`); | |
connection.send(this.getUpdateuserText()); | |
} | |
for (const roomid of this.games) { | |
const room = Rooms.get(roomid); | |
if (!room) { | |
Monitor.warn(`while renaming, room ${roomid} expired for user ${this.id} in rooms ${[...this.inRooms]} and games ${[...this.games]}`); | |
this.games.delete(roomid); | |
continue; | |
} | |
if (!room.game) { | |
Monitor.warn(`game desync for user ${this.id} in room ${room.roomid}`); | |
this.games.delete(roomid); | |
continue; | |
} | |
room.game.onRename(this, oldid, joining, isForceRenamed); | |
} | |
for (const roomid of this.inRooms) { | |
const room = Rooms.get(roomid)!; | |
room.onRename(this, oldid, joining); | |
if (room.game && !this.games.has(roomid)) { | |
if (room.game.playerTable[this.id]) { | |
this.games.add(roomid); | |
room.game.onRename(this, oldid, joining, isForceRenamed); | |
} | |
} | |
} | |
if (isForceRenamed) this.trackRename = oldname; | |
return true; | |
} | |
getUpdateuserText() { | |
const named = this.named ? 1 : 0; | |
const settings = { | |
...this.settings, | |
// Battle privacy state needs to be propagated in addition to regular settings so that the | |
// 'Ban spectators' checkbox on the client can be kept in sync (and disable privacy correctly) | |
hiddenNextBattle: this.battleSettings.hidden, | |
inviteOnlyNextBattle: this.battleSettings.inviteOnly, | |
language: this.language, | |
}; | |
return `|updateuser|${this.getIdentityWithStatus()}|${named}|${this.avatar}|${JSON.stringify(settings)}`; | |
} | |
update() { | |
this.send(this.getUpdateuserText()); | |
} | |
/** | |
* If Alice logs into Bob's account, and Bob is currently logged into PS, | |
* their connections will be merged, so that both `Connection`s are attached | |
* to the Alice `User`. | |
* | |
* In this function, `this` is Bob, and `oldUser` is Alice. | |
* | |
* This is a pretty routine thing: If Alice opens PS, then opens PS again in | |
* a new tab, PS will first create a Guest `User`, then automatically log in | |
* and merge that Guest `User` into the Alice `User` from the first tab. | |
*/ | |
merge(oldUser: User) { | |
oldUser.cancelReady(); | |
for (const roomid of oldUser.inRooms) { | |
Rooms.get(roomid)!.onLeave(oldUser); | |
} | |
const oldLocked = this.locked; | |
const oldSemilocked = this.semilocked; | |
if (!oldUser.semilocked) this.semilocked = null; | |
// If either user is unlocked and neither is locked by name, remove the lock. | |
// Otherwise, keep any locks that were by name. | |
if ( | |
(!oldUser.locked || !this.locked) && | |
oldUser.locked !== oldUser.id && | |
this.locked !== this.id && | |
// Only unlock if no previous names are locked | |
!oldUser.previousIDs.some(id => !!Punishments.hasPunishType(id, 'LOCK')) | |
) { | |
this.locked = null; | |
this.destroyPunishmentTimer(); | |
} else if (this.locked !== this.id) { | |
this.locked = oldUser.locked; | |
} | |
if (oldUser.autoconfirmed) this.autoconfirmed = oldUser.autoconfirmed; | |
this.updateGroup(this.registered, true); | |
if (oldLocked !== this.locked || oldSemilocked !== this.semilocked) this.updateIdentity(); | |
// We only propagate the 'busy' statusType through merging - merging is | |
// active enough that the user should no longer be in the 'idle' state. | |
// Doing this before merging connections ensures the updateuser message | |
// shows the correct idle state. | |
const isBusy = this.statusType === 'busy' || oldUser.statusType === 'busy'; | |
this.setStatusType(isBusy ? 'busy' : 'online'); | |
for (const connection of oldUser.connections) { | |
this.mergeConnection(connection); | |
} | |
oldUser.inRooms.clear(); | |
oldUser.connections = []; | |
if (oldUser.chatQueue) { | |
if (!this.chatQueue) this.chatQueue = []; | |
this.chatQueue.push(...oldUser.chatQueue); | |
oldUser.clearChatQueue(); | |
if (!this.chatQueueTimeout) this.startChatQueue(); | |
} | |
this.s1 = oldUser.s1; | |
this.s2 = oldUser.s2; | |
this.s3 = oldUser.s3; | |
// merge IPs | |
for (const ip of oldUser.ips) { | |
if (!this.ips.includes(ip)) this.ips.push(ip); | |
} | |
if (oldUser.isSysop) { | |
this.isSysop = true; | |
oldUser.isSysop = false; | |
} | |
oldUser.ips = []; | |
this.latestIp = oldUser.latestIp; | |
this.latestHost = oldUser.latestHost; | |
this.latestHostType = oldUser.latestHostType; | |
this.userMessage = oldUser.userMessage || this.userMessage || ''; | |
oldUser.markDisconnected(); | |
} | |
mergeConnection(connection: Connection) { | |
// the connection has changed name to this user's username, and so is | |
// being merged into this account | |
if (!this.connected) { | |
this.connected = true; | |
Users.onlineCount++; | |
} | |
if (connection.connectedAt > this.lastConnected) { | |
this.lastConnected = connection.connectedAt; | |
} | |
this.connections.push(connection); | |
// console.log(`${this.name} merging: connection ${connection.socket.id}`); | |
connection.send(this.getUpdateuserText()); | |
connection.user = this; | |
for (const roomid of connection.inRooms) { | |
const room = Rooms.get(roomid)!; | |
if (!this.inRooms.has(roomid)) { | |
if (Punishments.checkNameInRoom(this, room.roomid)) { | |
// the connection was in a room that this user is banned from | |
connection.sendTo(room.roomid, `|deinit`); | |
connection.leaveRoom(room); | |
continue; | |
} | |
room.onJoin(this, connection); | |
this.inRooms.add(roomid); | |
} | |
// Yes, this is intentionally supposed to call onConnect twice | |
// during a normal login. Override onUpdateConnection if you | |
// don't want this behavior. | |
room.game?.onUpdateConnection?.(this, connection); | |
} | |
this.updateReady(connection); | |
} | |
debugData() { | |
let str = `${this.tempGroup}${this.name} (${this.id})`; | |
for (const [i, connection] of this.connections.entries()) { | |
str += ` socket${i}[`; | |
str += [...connection.inRooms].join(`, `); | |
str += `]`; | |
} | |
if (!this.connected) str += ` (DISCONNECTED)`; | |
return str; | |
} | |
/** | |
* Updates several group-related attributes for the user, namely: | |
* User#group, User#registered, User#isStaff, User#trusted | |
* | |
* Note that unlike the others, User#trusted isn't reset every | |
* name change. | |
*/ | |
updateGroup(registered: boolean, isMerge?: boolean) { | |
if (!registered) { | |
this.registered = false; | |
this.tempGroup = Users.Auth.defaultSymbol(); | |
this.isStaff = false; | |
return; | |
} | |
this.registered = true; | |
if (!isMerge) this.tempGroup = globalAuth.get(this.id); | |
Users.Avatars?.handleLogin(this); | |
const groupInfo = Config.groups[this.tempGroup]; | |
this.isStaff = !!(groupInfo && (groupInfo.lock || groupInfo.root)); | |
if (!this.isStaff) { | |
const rank = Rooms.get('staff')?.auth.getDirect(this.id); | |
this.isStaff = !!(rank && rank !== '*' && rank !== Users.Auth.defaultSymbol()); | |
} | |
if (this.trusted) { | |
if (this.locked && this.permalocked) { | |
Monitor.log(`[CrisisMonitor] Trusted user '${this.id}' is ${this.permalocked !== this.id ? `an alt of permalocked user '${this.permalocked}'` : `a permalocked user`}, and was automatically demoted from ${this.distrust()}.`); | |
return; | |
} | |
this.locked = null; | |
this.namelocked = null; | |
this.destroyPunishmentTimer(); | |
} | |
if (this.autoconfirmed && this.semilocked) { | |
if (this.semilocked.startsWith('#sharedip')) { | |
this.semilocked = null; | |
} else if (this.semilocked === '#dnsbl') { | |
this.popup(`You are locked because someone using your IP has spammed/hacked other websites. This usually means either you're using a proxy, you're in a country where other people commonly hack, or you have a virus on your computer that's spamming websites.`); | |
this.semilocked = '#dnsbl.' as PunishType; | |
} | |
} | |
if (this.settings.blockPMs && this.can('lock') && !this.can('bypassall')) this.settings.blockPMs = false; | |
} | |
/** | |
* Set a user's group. Pass (' ', true) to force trusted | |
* status without giving the user a group. | |
*/ | |
setGroup(group: GroupSymbol, forceTrusted = false) { | |
if (!group) throw new Error(`Falsy value passed to setGroup`); | |
this.tempGroup = group; | |
const groupInfo = Config.groups[this.tempGroup]; | |
this.isStaff = !!(groupInfo && (groupInfo.lock || groupInfo.root)); | |
if (!this.isStaff) { | |
const rank = Rooms.get('staff')?.auth.getDirect(this.id); | |
this.isStaff = !!(rank && rank !== '*' && rank !== Users.Auth.defaultSymbol()); | |
} | |
Rooms.global.checkAutojoin(this); | |
if (this.registered) { | |
if (forceTrusted || this.tempGroup !== Users.Auth.defaultSymbol()) { | |
globalAuth.set(this.id, this.tempGroup); | |
this.trusted = this.id; | |
this.autoconfirmed = this.id; | |
} else { | |
globalAuth.delete(this.id); | |
this.trusted = ''; | |
} | |
} | |
} | |
/** | |
* Demotes a user from anything that grants trusted status. | |
* Returns an array describing what the user was demoted from. | |
*/ | |
distrust() { | |
if (!this.trusted) return; | |
const userid = this.trusted; | |
const removed = []; | |
const globalGroup = globalAuth.get(userid); | |
if (globalGroup && globalGroup !== ' ') { | |
removed.push(globalAuth.get(userid)); | |
} | |
for (const room of Rooms.global.chatRooms) { | |
if (!room.settings.isPrivate && room.auth.isStaff(userid)) { | |
let oldGroup = room.auth.getDirect(userid) as string; | |
if (oldGroup === ' ') { | |
oldGroup = 'whitelist in '; | |
} else { | |
room.auth.set(userid, '+'); | |
} | |
removed.push(`${oldGroup}${room.roomid}`); | |
} | |
} | |
this.trusted = ''; | |
globalAuth.set(userid, Users.Auth.defaultSymbol()); | |
return removed; | |
} | |
markDisconnected() { | |
if (!this.connected) return; | |
Chat.runHandlers('onDisconnect', this); | |
this.connected = false; | |
Users.onlineCount--; | |
this.lastDisconnected = Date.now(); | |
if (!this.registered) { | |
// for "safety" | |
this.tempGroup = Users.Auth.defaultSymbol(); | |
this.isSysop = false; // should never happen | |
this.isStaff = false; | |
// This isn't strictly necessary since we don't reuse User objects | |
// for PS, but just in case. | |
// We're not resetting .trusted/.autoconfirmed so those accounts | |
// can still be locked after logout. | |
} | |
// NOTE: can't do a this.update(...) at this point because we're no longer connected. | |
} | |
onDisconnect(connection: Connection) { | |
// slightly safer to do this here so that we can do this before Conn#user is nulled. | |
if (connection.openPages) { | |
for (const page of connection.openPages) { | |
Chat.handleRoomClose(page as RoomID, this, connection); | |
} | |
} | |
for (const [i, connected] of this.connections.entries()) { | |
if (connected === connection) { | |
this.connections.splice(i, 1); | |
// console.log('DISCONNECT: ' + this.id); | |
if (!this.connections.length) { | |
this.markDisconnected(); | |
} | |
for (const roomid of connection.inRooms) { | |
this.leaveRoom(Rooms.get(roomid)!, connection); | |
} | |
break; | |
} | |
} | |
if (!this.connections.length) { | |
for (const roomid of this.inRooms) { | |
// should never happen. | |
Monitor.debug(`!! room miscount: ${roomid} not left`); | |
Rooms.get(roomid)!.onLeave(this); | |
} | |
// cleanup | |
this.inRooms.clear(); | |
if (!this.named && !this.previousIDs.length) { | |
// 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(); | |
} else { | |
this.cancelReady(); | |
} | |
} | |
} | |
disconnectAll() { | |
// Disconnects a user from the server | |
this.clearChatQueue(); | |
let connection = null; | |
this.markDisconnected(); | |
for (let i = this.connections.length - 1; i >= 0; i--) { | |
// console.log('DESTROY: ' + this.id); | |
connection = this.connections[i]; | |
for (const roomid of connection.inRooms) { | |
this.leaveRoom(Rooms.get(roomid)!, connection); | |
} | |
connection.destroy(); | |
} | |
if (this.connections.length) { | |
// should never happen | |
throw new Error(`Failed to drop all connections for ${this.id}`); | |
} | |
for (const roomid of this.inRooms) { | |
// should never happen. | |
throw new Error(`Room miscount: ${roomid} not left for ${this.id}`); | |
} | |
this.inRooms.clear(); | |
} | |
/** | |
* If this user is included in the returned list of | |
* alts (i.e. when forPunishment is true), they will always be the first element of that list. | |
*/ | |
getAltUsers(includeTrusted = false, forPunishment = false) { | |
let alts = findUsers([this.getLastId()], this.ips, { includeTrusted, forPunishment }); | |
alts = alts.filter(user => user !== this); | |
if (forPunishment) alts.unshift(this); | |
return alts; | |
} | |
getLastName() { | |
if (this.named) return this.name; | |
const lastName = this.previousIDs.length ? this.previousIDs[this.previousIDs.length - 1] : this.name; | |
return `[${lastName}]`; | |
} | |
getLastId() { | |
if (this.named) return this.id; | |
return (this.previousIDs.length ? this.previousIDs[this.previousIDs.length - 1] : this.id); | |
} | |
async tryJoinRoom(roomid: RoomID | Room, connection: Connection) { | |
roomid = roomid && (roomid as Room).roomid ? (roomid as Room).roomid : roomid as RoomID; | |
const room = Rooms.search(roomid); | |
if (!room) { | |
if (roomid.startsWith('view-')) { | |
return Chat.resolvePage(roomid, this, connection); | |
} | |
connection.sendTo(roomid, `|noinit|nonexistent|The room "${roomid}" does not exist.`); | |
return false; | |
} | |
if (!room.checkModjoin(this)) { | |
if (!this.named) return Rooms.RETRY_AFTER_LOGIN; | |
connection.sendTo(roomid, `|noinit|joinfailed|The room "${roomid}" is invite-only, and you haven't been invited.`); | |
return false; | |
} | |
if ((room as GameRoom).tour) { | |
const errorMessage = (room as GameRoom).tour!.onBattleJoin(room as GameRoom, this); | |
if (errorMessage) { | |
connection.sendTo(roomid, `|noinit|joinfailed|${errorMessage}`); | |
return false; | |
} | |
} | |
if (room.settings.isPrivate) { | |
if (!this.named) { | |
return Rooms.RETRY_AFTER_LOGIN; | |
} | |
} | |
if (!this.can('bypassall') && Punishments.isRoomBanned(this, room.roomid)) { | |
connection.sendTo(roomid, `|noinit|joinfailed|You are banned from the room "${roomid}".`); | |
return false; | |
} | |
if (room.roomid.startsWith('groupchat-') && !room.parent) { | |
const groupchatbanned = Punishments.isGroupchatBanned(this); | |
if (groupchatbanned) { | |
const expireText = Punishments.checkPunishmentExpiration(groupchatbanned); | |
connection.sendTo(roomid, `|noinit|joinfailed|You are banned from using groupchats${expireText}.`); | |
return false; | |
} | |
Punishments.monitorGroupchatJoin(room, this); | |
} | |
if (Rooms.aliases.get(roomid) === room.roomid) { | |
connection.send(`>${roomid}\n|deinit`); | |
} | |
this.joinRoom(room, connection); | |
return true; | |
} | |
joinRoom(roomid: RoomID | Room, connection: Connection | null = null) { | |
const room = Rooms.get(roomid); | |
if (!room) throw new Error(`Room not found: ${roomid}`); | |
if (!connection) { | |
for (const curConnection of this.connections) { | |
this.joinRoom(room, curConnection); | |
} | |
return; | |
} | |
if (!connection.inRooms.has(room.roomid)) { | |
if (!this.inRooms.has(room.roomid)) { | |
room.onJoin(this, connection); | |
this.inRooms.add(room.roomid); | |
} | |
connection.joinRoom(room); | |
room.onConnect(this, connection); | |
} | |
} | |
leaveRoom(room: Room | string, connection: Connection | null = null) { | |
room = Rooms.get(room)!; | |
if (!this.inRooms.has(room.roomid)) { | |
return false; | |
} | |
for (const curConnection of this.connections) { | |
if (connection && curConnection !== connection) continue; | |
if (curConnection.inRooms.has(room.roomid)) { | |
curConnection.sendTo(room.roomid, `|deinit`); | |
curConnection.leaveRoom(room); | |
} | |
if (connection) break; | |
} | |
let stillInRoom = false; | |
if (connection) { | |
stillInRoom = this.connections.some(conn => conn.inRooms.has(room.roomid)); | |
} | |
if (!stillInRoom) { | |
room.onLeave(this); | |
this.inRooms.delete(room.roomid); | |
} | |
} | |
cancelReady() { | |
// setting variables because this can't be short-circuited | |
const searchesCancelled = Ladders.cancelSearches(this); | |
const challengesCancelled = Ladders.challenges.clearFor(this.id, 'they changed their username'); | |
if (searchesCancelled || challengesCancelled) { | |
this.popup(`Your searches and challenges have been cancelled because you changed your username.`); | |
} | |
// cancel tour challenges | |
// no need for a popup because users can't change their name while in a tournament anyway | |
for (const roomid of this.games) { | |
// @ts-expect-error Tournaments aren't TS'd yet | |
Rooms.get(roomid)?.game?.cancelChallenge?.(this); | |
} | |
} | |
updateReady(connection: Connection | null = null) { | |
Ladders.updateSearch(this, connection); | |
Ladders.challenges.updateFor(connection || this); | |
} | |
updateSearch(connection: Connection | null = null) { | |
Ladders.updateSearch(this, connection); | |
} | |
/** | |
* Moves the user's connections in a given room to another room. | |
* This function's main use case is for when a room is renamed. | |
*/ | |
moveConnections(oldRoomID: RoomID, newRoomID: RoomID) { | |
this.inRooms.delete(oldRoomID); | |
this.inRooms.add(newRoomID); | |
for (const connection of this.connections) { | |
connection.inRooms.delete(oldRoomID); | |
connection.inRooms.add(newRoomID); | |
Sockets.roomRemove(connection.worker, oldRoomID, connection.socketid); | |
Sockets.roomAdd(connection.worker, newRoomID, connection.socketid); | |
} | |
} | |
/** | |
* The user says message in room. | |
* Returns false if the rest of the user's messages should be discarded. | |
*/ | |
chat(message: string, room: Room | null, connection: Connection) { | |
const now = Date.now(); | |
const noThrottle = this.hasSysopAccess() || Config.nothrottle; | |
if (message.startsWith('/cmd userdetails') || message.startsWith('>> ') || noThrottle) { | |
// certain commands are exempt from the queue | |
Monitor.activeIp = connection.ip; | |
Chat.parse(message, room, this, connection); | |
Monitor.activeIp = null; | |
if (noThrottle) return; | |
return false; // but end the loop here | |
} | |
const throttleDelay = this.isPublicBot ? THROTTLE_DELAY_PUBLIC_BOT : this.trusted ? THROTTLE_DELAY_TRUSTED : | |
THROTTLE_DELAY; | |
if (this.chatQueueTimeout) { | |
if (!this.chatQueue) this.chatQueue = []; // this should never happen | |
if (this.chatQueue.length >= THROTTLE_BUFFER_LIMIT - 1) { | |
connection.sendTo( | |
room, | |
`|raw|<strong class="message-throttle-notice">Your message was not sent because you've been typing too quickly.</strong>` | |
); | |
return false; | |
} else { | |
this.chatQueue.push([message, room ? room.roomid : '', connection]); | |
} | |
} else if (now < this.lastChatMessage + throttleDelay) { | |
this.chatQueue = [[message, room ? room.roomid : '', connection]]; | |
this.startChatQueue(throttleDelay - (now - this.lastChatMessage)); | |
} else { | |
this.lastChatMessage = now; | |
Monitor.activeIp = connection.ip; | |
Chat.parse(message, room, this, connection); | |
Monitor.activeIp = null; | |
} | |
} | |
startChatQueue(delay: number | null = null) { | |
if (delay === null) { | |
delay = (this.isPublicBot ? THROTTLE_DELAY_PUBLIC_BOT : this.trusted ? THROTTLE_DELAY_TRUSTED : | |
THROTTLE_DELAY) - (Date.now() - this.lastChatMessage); | |
} | |
this.chatQueueTimeout = setTimeout( | |
() => this.processChatQueue(), | |
delay | |
); | |
} | |
clearChatQueue() { | |
this.chatQueue = null; | |
if (this.chatQueueTimeout) { | |
clearTimeout(this.chatQueueTimeout); | |
this.chatQueueTimeout = null; | |
} | |
} | |
processChatQueue(): void { | |
this.chatQueueTimeout = null; | |
if (!this.chatQueue) return; | |
const queueElement = this.chatQueue.shift(); | |
if (!queueElement) { | |
this.chatQueue = null; | |
return; | |
} | |
const [message, roomid, connection] = queueElement; | |
if (!connection.user) { | |
// connection disconnected, chat queue should not be big enough | |
// for recursion to be an issue, also didn't ES6 spec tail | |
// recursion at some point? | |
return this.processChatQueue(); | |
} | |
this.lastChatMessage = new Date().getTime(); | |
const room = Rooms.get(roomid); | |
if (room || !roomid) { | |
Monitor.activeIp = connection.ip; | |
Chat.parse(message, room, this, connection); | |
Monitor.activeIp = null; | |
} else { | |
// room no longer exists; do nothing | |
} | |
const throttleDelay = this.isPublicBot ? THROTTLE_DELAY_PUBLIC_BOT : this.trusted ? THROTTLE_DELAY_TRUSTED : | |
THROTTLE_DELAY; | |
if (this.chatQueue.length) { | |
this.chatQueueTimeout = setTimeout(() => this.processChatQueue(), throttleDelay); | |
} else { | |
this.chatQueue = null; | |
} | |
} | |
setStatusType(type: StatusType) { | |
if (type === this.statusType) return; | |
this.statusType = type; | |
this.updateIdentity(); | |
this.update(); | |
} | |
setUserMessage(message: string) { | |
if (message === this.userMessage) return; | |
this.userMessage = message; | |
this.updateIdentity(); | |
} | |
clearStatus(type: StatusType = this.statusType) { | |
this.statusType = type; | |
this.userMessage = ''; | |
this.updateIdentity(); | |
} | |
getAccountStatusString() { | |
return this.trusted === this.id ? `[trusted]` : | |
this.autoconfirmed === this.id ? `[ac]` : | |
this.registered ? `[registered]` : | |
``; | |
} | |
destroy() { | |
// deallocate user | |
for (const roomid of this.games) { | |
const game = Rooms.get(roomid)?.game; | |
if (!game) { | |
Monitor.warn(`while deallocating, room ${roomid} did not have a game for ${this.id} in rooms ${[...this.inRooms]} and games ${[...this.games]}`); | |
this.games.delete(roomid); | |
continue; | |
} | |
if (!game.ended) game.forfeit?.(this, " lost by being offline too long."); | |
} | |
this.clearChatQueue(); | |
this.destroyPunishmentTimer(); | |
Users.delete(this); | |
} | |
destroyPunishmentTimer() { | |
if (this.punishmentTimer) { | |
clearTimeout(this.punishmentTimer); | |
this.punishmentTimer = null; | |
} | |
} | |
toString() { | |
return this.id; | |
} | |
} | |
/********************************************************* | |
* Inactive user pruning | |
*********************************************************/ | |
function pruneInactive(threshold: number) { | |
const now = Date.now(); | |
for (const user of users.values()) { | |
if (user.statusType === 'online') { | |
// check if we should set status to idle | |
const awayTimer = user.can('lock') ? STAFF_IDLE_TIMER : IDLE_TIMER; | |
const bypass = !user.can('bypassall') && ( | |
user.can('bypassafktimer') || | |
Array.from(user.inRooms).some(room => user.can('bypassafktimer', null, Rooms.get(room)!)) | |
); | |
if (!bypass && !user.connections.some(connection => now - connection.lastActiveTime < awayTimer)) { | |
user.setStatusType('idle'); | |
} | |
} | |
if (!user.connected && (now - user.lastDisconnected) > threshold) { | |
user.destroy(); | |
} | |
if (!user.can('addhtml')) { | |
const suspicious = global.Config?.isSuspicious?.(user) || false; | |
for (const connection of user.connections) { | |
if ( | |
// conn's been inactive for 24h, just kill it | |
(now - connection.lastActiveTime > CONNECTION_EXPIRY_TIME) || | |
// they're connected and not named, but not namelocked. this is unusual behavior, ultimately just wasting resources. | |
// people have been spamming us with conns as of writing this, so it appears to be largely bots doing this. | |
// so we're just gonna go ahead and dc them. if they're a real user, they can rejoin and go back to... whatever. | |
suspicious && (now - connection.connectedAt) > threshold | |
) { | |
connection.destroy(); | |
} | |
} | |
} | |
} | |
} | |
function logGhostConnections(threshold: number): Promise<unknown> { | |
const buffer = []; | |
for (const connection of connections.values()) { | |
// If the connection's been around for at least a week and it doesn't | |
// use raw WebSockets (which doesn't have any kind of keepalive or | |
// timeouts on it), log it. | |
if (connection.protocol !== 'websocket-raw' && connection.connectedAt <= Date.now() - threshold) { | |
const timestamp = Chat.toTimestamp(new Date(connection.connectedAt)); | |
const now = Chat.toTimestamp(new Date()); | |
const log = `Connection ${connection.id} from ${connection.ip} with protocol "${connection.protocol}" has been around since ${timestamp} (currently ${now}).`; | |
buffer.push(log); | |
} | |
} | |
return buffer.length ? | |
Monitor.logPath(`ghosts-${process.pid}.log`).append(buffer.join('\r\n') + '\r\n') : | |
Promise.resolve(); | |
} | |
/********************************************************* | |
* Routing | |
*********************************************************/ | |
function socketConnect( | |
worker: ProcessManager.StreamWorker, | |
workerid: number, | |
socketid: string, | |
ip: string, | |
protocol: string | |
) { | |
const id = `${workerid}-${socketid}`; | |
const connection = new Connection(id, worker, socketid, null, ip, protocol); | |
connections.set(id, connection); | |
const banned = Punishments.checkIpBanned(connection); | |
if (banned) { | |
return connection.destroy(); | |
} | |
// Emergency mode connections logging | |
if (Config.emergency) { | |
void Monitor.logPath('cons.emergency.log').append('[' + ip + ']\n'); | |
} | |
const user = new User(connection); | |
connection.user = user; | |
void Punishments.checkIp(user, connection); | |
// Generate 1024-bit challenge string. | |
require('crypto').randomBytes(128, (err: Error | null, buffer: Buffer) => { | |
if (err) { | |
// It's not clear what sort of condition could cause this. | |
// For now, we'll basically assume it can't happen. | |
Monitor.crashlog(err, 'randomBytes'); | |
// 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 + ']'); | |
const keyid = Config.loginserverpublickeyid || 0; | |
connection.sendTo(null, `|challstr|${keyid}|${connection.challenge}`); | |
} | |
}); | |
Rooms.global.handleConnect(user, connection); | |
} | |
function socketDisconnect(worker: ProcessManager.StreamWorker, workerid: number, socketid: string) { | |
const id = `${workerid}-${socketid}`; | |
const connection = connections.get(id); | |
if (!connection) return; | |
connection.onDisconnect(); | |
} | |
function socketDisconnectAll(worker: ProcessManager.StreamWorker, workerid: number) { | |
for (const connection of connections.values()) { | |
if (connection.worker === worker) { | |
connection.onDisconnect(); | |
} | |
} | |
} | |
function socketReceive(worker: ProcessManager.StreamWorker, workerid: number, socketid: string, message: string) { | |
const id = `${workerid}-${socketid}`; | |
const connection = connections.get(id); | |
if (!connection) return; | |
connection.lastActiveTime = Date.now(); | |
// 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.startsWith('{')) return; | |
const pipeIndex = message.indexOf('|'); | |
if (pipeIndex < 0) { | |
// drop invalid messages without a pipe character | |
connection.popup(`Invalid message; messages should be in the format \`ROOMID|MESSAGE\`. See https://github.com/smogon/pokemon-showdown/blob/master/PROTOCOL.md`); | |
return; | |
} | |
const user = connection.user; | |
if (!user) return; | |
// LEGACY: In the past, an empty room ID would default to Lobby, | |
// but that is no longer supported | |
const roomId = message.slice(0, pipeIndex) || ''; | |
message = message.slice(pipeIndex + 1); | |
const room = Rooms.get(roomId) || null; | |
const multilineMessage = Chat.multiLinePattern.test(message); | |
if (multilineMessage) { | |
user.chat(multilineMessage, room, connection); | |
return; | |
} | |
const lines = message.split('\n'); | |
if (!lines[lines.length - 1]) lines.pop(); | |
const maxLineCount = ( | |
user.can('bypassall') ? THROTTLE_MULTILINE_WARN_ADMIN : | |
(user.isStaff || room?.auth.isStaff(user.id)) ? | |
THROTTLE_MULTILINE_WARN_STAFF : THROTTLE_MULTILINE_WARN | |
); | |
if (lines.length > maxLineCount && !Config.nothrottle) { | |
connection.popup(`You're sending too many lines at once. Try using a paste service like [[Pastebin]].`); | |
return; | |
} | |
// Emergency logging | |
if (Config.emergency) { | |
void Monitor.logPath('emergency.log').append(`[${user} (${connection.ip})] ${roomId}|${message}\n`); | |
} | |
for (const line of lines) { | |
if (user.chat(line, room, connection) === false) break; | |
} | |
} | |
const users = new Map<ID, User>(); | |
const prevUsers = new Map<ID, ID>(); | |
let numUsers = 0; | |
export const Users = { | |
delete: deleteUser, | |
move, | |
add, | |
merge, | |
users, | |
prevUsers, | |
onlineCount: 0, | |
get: getUser, | |
getExact: getExactUser, | |
findUsers, | |
Auth, | |
Avatars: null as typeof import('./chat-commands/avatars').Avatars | null, | |
globalAuth, | |
isUsernameKnown, | |
isUsername, | |
isTrusted, | |
isPublicBot, | |
PLAYER_SYMBOL, | |
HOST_SYMBOL, | |
connections, | |
User, | |
Connection, | |
socketDisconnect, | |
socketDisconnectAll, | |
socketReceive, | |
pruneInactive, | |
pruneInactiveTimer: setInterval(() => { | |
pruneInactive(Config.inactiveuserthreshold || 60 * MINUTES); | |
}, 30 * MINUTES), | |
logGhostConnections, | |
logGhostConnectionsTimer: setInterval(() => { | |
void logGhostConnections(7 * 24 * 60 * MINUTES); | |
}, 7 * 24 * 60 * MINUTES), | |
socketConnect, | |
}; | |