Pokemon_server / server /punishments.ts
Jofthomas's picture
Jofthomas HF staff
Upload 4781 files
5c2ed06 verified
/**
* Punishments
* Pokemon Showdown - http://pokemonshowdown.com/
*
* Handles the punishing of users on PS.
*
* There are four types of global punishments on PS. Locks, bans, namelocks and rangelocks.
* This file contains the lists of users that have been punished (both IPs and usernames),
* as well as the functions that handle the execution of said punishments.
*
* @license MIT license
*/
import { FS, Utils } from '../lib';
import type { AddressRange } from './ip-tools';
const PUNISHMENT_FILE = 'config/punishments.tsv';
const ROOM_PUNISHMENT_FILE = 'config/room-punishments.tsv';
const SHAREDIPS_FILE = 'config/sharedips.tsv';
const SHAREDIPS_BLACKLIST_FILE = 'config/sharedips-blacklist.tsv';
const WHITELISTED_NAMES_FILE = 'config/name-whitelist.tsv';
const RANGELOCK_DURATION = 60 * 60 * 1000; // 1 hour
const LOCK_DURATION = 48 * 60 * 60 * 1000; // 48 hours
const GLOBALBAN_DURATION = 7 * 24 * 60 * 60 * 1000; // 1 week
const BATTLEBAN_DURATION = 48 * 60 * 60 * 1000; // 48 hours
const GROUPCHATBAN_DURATION = 7 * 24 * 60 * 60 * 1000; // 1 week
const MOBILE_PUNISHMENT_DURATIION = 6 * 60 * 60 * 1000; // 6 hours
const ROOMBAN_DURATION = 48 * 60 * 60 * 1000; // 48 hours
const BLACKLIST_DURATION = 365 * 24 * 60 * 60 * 1000; // 1 year
const USERID_REGEX = /^[a-z0-9]+$/;
const PUNISH_TRUSTED = false;
const PUNISHMENT_POINT_VALUES: { [k: string]: number } = { MUTE: 2, BLACKLIST: 3, ROOMBAN: 4 };
const AUTOLOCK_POINT_THRESHOLD = 8;
const AUTOWEEKLOCK_THRESHOLD = 5; // number of global punishments to upgrade autolocks to weeklocks
const AUTOWEEKLOCK_DAYS_TO_SEARCH = 60;
/** The longest amount of time any individual timeout will be set for. */
const MAX_PUNISHMENT_TIMER_LENGTH = 24 * 60 * 60 * 1000; // 24 hours
/**
* The number of users from a groupchat whose creator was banned from using groupchats
* who may join a new groupchat before the GroupchatMonitor activates.
*/
const GROUPCHAT_PARTICIPANT_OVERLAP_THRESHOLD = 5;
/**
* The minimum amount of time that must pass between activations of the GroupchatMonitor.
*/
const GROUPCHAT_MONITOR_INTERVAL = 10 * 60 * 1000; // 10 minutes
export interface Punishment {
type: string;
id: ID | PunishType;
expireTime: number;
reason: string;
rest?: any[];
}
/**
* Info on punishment types. Can be used for either room punishment types or global punishments
* extended by plugins. The `desc` is the /alts display, and the `callback` is what to do if the user
* _does_ have the punishment (can be used to add extra effects in lieu of something like a loginfilter.)
* Rooms will be specified for room punishments, otherwise it's null for global punishments
*/
export interface PunishInfo {
desc: string;
onActivate?: (user: User, punishment: Punishment, room: Room | null, isExactMatch: boolean) => void;
/** For room punishments - should they count for punishmentmonitor? default to no. */
activatePunishMonitor?: boolean;
}
interface PunishmentEntry {
ips: string[];
userids: ID[];
punishType: string;
expireTime: number;
reason: string;
rest: any[];
}
class PunishmentMap extends Map<string, Punishment[]> {
roomid?: RoomID;
constructor(roomid?: RoomID) {
super();
this.roomid = roomid;
}
removeExpiring(punishments: Punishment[]) {
for (const [i, punishment] of punishments.entries()) {
if (Date.now() > punishment.expireTime) {
punishments.splice(i, 1);
}
}
}
get(k: string) {
const punishments = super.get(k);
if (punishments) {
this.removeExpiring(punishments);
if (punishments.length) return punishments;
this.delete(k);
}
return undefined;
}
has(k: string) {
return !!this.get(k);
}
getByType(k: string, type: string) {
// safely 0 since a user only ever has one punishment of each type
return this.get(k)?.filter(p => p.type === type)?.[0];
}
each(callback: (punishment: Punishment, id: string, map: PunishmentMap) => void) {
for (const [k, punishments] of super.entries()) {
this.removeExpiring(punishments);
if (punishments.length) {
for (const punishment of punishments) {
callback(punishment, k, this);
}
} else {
this.delete(k);
}
}
}
deleteOne(k: string, punishment: Punishment) {
const list = this.get(k);
if (!list) return;
for (let i = list.length - 1; i >= 0; i--) {
const cur = list[i];
if (punishment.type === cur.type && cur.id === punishment.id) {
list.splice(i, 1);
}
}
if (!list.length) {
this.delete(k);
}
return true;
}
add(k: string, punishment: Punishment) {
let list = this.get(k);
if (!list) {
list = [];
this.set(k, list);
}
for (let i = list.length - 1; i >= 0; i--) {
const curPunishment = list[i];
if (punishment.type === curPunishment.type) {
if (curPunishment.expireTime >= punishment.expireTime) {
curPunishment.reason = punishment.reason;
// if we already have a punishment of the same type with a higher expiration date
// we want to just update the reason and ignore it
return this;
}
list.splice(i, 1);
}
}
list.push(punishment);
return this;
}
}
class NestedPunishmentMap extends Map<RoomID, PunishmentMap> {
nestedSet(k1: RoomID, k2: string, value: Punishment) {
if (!this.get(k1)) {
this.set(k1, new PunishmentMap(k1));
}
// guaranteed above
this.get(k1)!.add(k2, value);
}
nestedGet(k1: RoomID, k2: string) {
const subMap = this.get(k1);
if (!subMap) return subMap;
const punishments = subMap.get(k2);
if (punishments?.length) {
return punishments;
}
return undefined;
}
nestedGetByType(k1: RoomID, k2: string, type: string) {
return this.nestedGet(k1, k2)?.find(p => p.type === type);
}
nestedHas(k1: RoomID, k2: string) {
return !!this.nestedGet(k1, k2);
}
nestedDelete(k1: RoomID, k2: string) {
const subMap = this.get(k1);
if (!subMap) return;
subMap.delete(k2);
if (!subMap.size) this.delete(k1);
}
nestedEach(callback: (punishment: Punishment, roomid: RoomID, userid: string) => void) {
for (const [k1, subMap] of this.entries()) {
for (const [k2, punishments] of subMap.entries()) {
subMap.removeExpiring(punishments);
if (punishments.length) {
for (const punishment of punishments) {
callback(punishment, k1, k2);
}
} else {
this.nestedDelete(k1, k2);
}
}
}
}
}
/*********************************************************
* Persistence
*********************************************************/
export const Punishments = new class {
/**
* ips is an ip:punishment Map
*/
readonly ips = new PunishmentMap();
/**
* userids is a userid:punishment Map
*/
readonly userids = new PunishmentMap();
/**
* roomUserids is a roomid:userid:punishment nested Map
*/
readonly roomUserids = new NestedPunishmentMap();
/**
* roomIps is a roomid:ip:punishment Map
*/
readonly roomIps = new NestedPunishmentMap();
/**
* sharedIps is an ip:note Map
*/
readonly sharedIps = new Map<string, string>();
/**
* AddressRange:note map. In a separate map so we iterate a massive map a lot less.
* (AddressRange is a bit of a premature optimization, but it saves us a conversion call on some trafficked spots)
*/
sharedRanges = new Map<AddressRange, string>();
/**
* sharedIpBlacklist is an ip:note Map
*/
readonly sharedIpBlacklist = new Map<string, string>();
/**
* namefilterwhitelist is a whitelistedname:whitelister Map
*/
readonly namefilterwhitelist = new Map<string, string>();
/**
* Connection flood table. Separate table from IP bans.
*/
readonly cfloods = new Set<string>();
/**
* Participants in groupchats whose creators were banned from using groupchats.
* Object keys are roomids of groupchats; values are Sets of user IDs.
*/
readonly bannedGroupchatParticipants: { [k: string]: Set<ID> } = {};
/** roomid:timestamp map */
readonly lastGroupchatMonitorTime: { [k: string]: number } = {};
/**
* Map<userid that has been warned, reason they were warned for>
*/
readonly offlineWarns = new Map<ID, string>();
/**
* punishType is an allcaps string, for global punishments they can be
* anything in the punishmentTypes map.
*
* This map can be extended with custom punishments by chat plugins.
*
* Keys in the map correspond to PunishInfo */
readonly punishmentTypes: Map<string, PunishInfo> = new Map<string, PunishInfo>([
...(global.Punishments?.punishmentTypes || []),
['LOCK', { desc: 'locked' }],
['BAN', { desc: 'globally banned' }],
['NAMELOCK', { desc: 'namelocked' }],
['GROUPCHATBAN', { desc: 'banned from using groupchats' }],
['BATTLEBAN', { desc: 'banned from battling' }],
]);
/**
* For room punishments, they can be anything in the roomPunishmentTypes map.
*
* This map can be extended with custom punishments by chat plugins.
*
* Keys in the map correspond to punishTypes, values signify the way they
* should be displayed in /alt.
* By default, this includes:
* - 'ROOMBAN'
* - 'BLACKLIST'
* - 'MUTE' (used by getRoomPunishments)
*
*/
readonly roomPunishmentTypes: Map<string, PunishInfo> = new Map<string, PunishInfo>([
// references to global.Punishments? are here because if you hotpatch punishments without hotpatching chat,
// old punishment types won't be loaded into here, which might cause issues. This guards against that.
...(global.Punishments?.roomPunishmentTypes || []),
['ROOMBAN', { desc: 'banned', activatePunishMonitor: true }],
['BLACKLIST', { desc: 'blacklisted', activatePunishMonitor: true }],
['MUTE', { desc: 'muted', activatePunishMonitor: true }],
]);
constructor() {
setImmediate(() => {
void Punishments.loadPunishments();
void Punishments.loadRoomPunishments();
void Punishments.loadBanlist();
void Punishments.loadSharedIps();
void Punishments.loadSharedIpBlacklist();
void Punishments.loadWhitelistedNames();
});
}
// punishments.tsv is in the format:
// punishType, userid, ips/usernames, expiration time, reason
// room-punishments.tsv is in the format:
// punishType, roomid:userid, ips/usernames, expiration time, reason
async loadPunishments() {
const data = await FS(PUNISHMENT_FILE).readIfExists();
if (!data) return;
for (const row of data.split("\n")) {
if (!row || row === '\r') continue;
const [type, id, altKeys, expireTimeStr, ...reason] = row.trim().split("\t");
const expireTime = Number(expireTimeStr);
if (type === "Punishment") continue;
const keys = altKeys.split(',').concat(id);
const punishment = { type, id, expireTime, reason: reason.join('\t') } as Punishment;
if (Date.now() >= expireTime) {
continue;
}
for (const key of keys) {
if (!key.trim()) continue; // ignore empty ips / userids
if (!USERID_REGEX.test(key)) {
Punishments.ips.add(key, punishment);
} else {
Punishments.userids.add(key, punishment);
}
}
}
}
async loadRoomPunishments() {
const data = await FS(ROOM_PUNISHMENT_FILE).readIfExists();
if (!data) return;
for (const row of data.split("\n")) {
if (!row || row === '\r') continue;
const [type, id, altKeys, expireTimeStr, ...reason] = row.trim().split("\t");
const expireTime = Number(expireTimeStr);
if (type === "Punishment") continue;
const [roomid, userid] = id.split(':');
if (!userid) continue; // invalid format
const keys = altKeys.split(',').concat(userid);
const punishment = { type, id: userid, expireTime, reason: reason.join('\t') } as Punishment;
if (Date.now() >= expireTime) {
continue;
}
for (const key of keys) {
if (!USERID_REGEX.test(key)) {
Punishments.roomIps.nestedSet(roomid as RoomID, key, punishment);
} else {
Punishments.roomUserids.nestedSet(roomid as RoomID, key, punishment);
}
}
}
}
savePunishments() {
FS(PUNISHMENT_FILE).writeUpdate(() => {
const saveTable = Punishments.getPunishments();
let buf = 'Punishment\tUser ID\tIPs and alts\tExpires\tReason\r\n';
for (const [id, entry] of saveTable) {
buf += Punishments.renderEntry(entry, id);
}
return buf;
}, { throttle: 5000 });
}
saveRoomPunishments() {
FS(ROOM_PUNISHMENT_FILE).writeUpdate(() => {
const saveTable: [string, PunishmentEntry][] = [];
for (const roomid of Punishments.roomIps.keys()) {
for (const [userid, punishment] of Punishments.getPunishments(roomid, true)) {
saveTable.push([`${roomid}:${userid}`, punishment]);
}
}
let buf = 'Punishment\tRoom ID:User ID\tIPs and alts\tExpires\tReason\r\n';
for (const [id, entry] of saveTable) {
buf += Punishments.renderEntry(entry, id);
}
return buf;
}, { throttle: 5000 });
}
getEntry(entryId: string) {
let entry: PunishmentEntry | null = null;
Punishments.ips.each((punishment, ip) => {
const { type, id, expireTime, reason, rest } = punishment;
if (id !== entryId) return;
if (entry) {
entry.ips.push(ip);
return;
}
entry = {
userids: [],
ips: [ip],
punishType: type,
expireTime,
reason,
rest: rest || [],
};
});
Punishments.userids.each((punishment, userid) => {
const { type, id, expireTime, reason, rest } = punishment;
if (id !== entryId) return;
if (!entry) {
entry = {
userids: [],
ips: [],
punishType: type,
expireTime,
reason,
rest: rest || [],
};
}
if (userid !== id) entry.userids.push(toID(userid));
});
return entry;
}
appendPunishment(entry: PunishmentEntry, id: string, filename: string, allowNonUserIDs?: boolean) {
if (!allowNonUserIDs && id.startsWith('#')) return;
const buf = Punishments.renderEntry(entry, id);
return FS(filename).append(buf);
}
renderEntry(entry: PunishmentEntry, id: string) {
const keys = entry.ips.concat(entry.userids).join(',');
const row = [entry.punishType, id, keys, entry.expireTime, entry.reason, ...entry.rest];
return row.join('\t') + '\r\n';
}
async loadBanlist() {
const data = await FS('config/ipbans.txt').readIfExists();
if (!data) return;
const rangebans = [];
for (const row of data.split("\n")) {
const ip = row.split('#')[0].trim();
if (!ip) continue;
if (ip.includes('/')) {
rangebans.push(ip);
} else if (!Punishments.ips.has(ip)) {
Punishments.ips.add(ip, { type: 'LOCK', id: '#ipban', expireTime: Infinity, reason: '' });
}
}
Punishments.checkRangeBanned = IPTools.checker(rangebans);
}
/**
* sharedips.tsv is in the format:
* IP, type (in this case always SHARED), note
*/
async loadSharedIps() {
const data = await FS(SHAREDIPS_FILE).readIfExists();
if (!data) return;
let needsSave = false; // do we need to re-save to fix malformed data?
for (const row of data.replace('\r', '').split("\n")) {
if (!row) continue;
const [ip, type, note] = row.trim().split("\t");
if (ip === 'IP') {
continue; // first row
}
if (IPTools.ipRegex.test(note)) {
// this is handling a bug where data accidentally got reversed
// (into note,shared,ip format instead of ip,shared,note format)
Punishments.sharedIps.set(note, ip);
needsSave = true;
continue;
}
if (!IPTools.ipRegex.test(ip)) {
const pattern = IPTools.stringToRange(ip);
if (pattern) {
Punishments.sharedRanges.set(pattern, note);
} else {
Monitor.adminlog(`Invalid range data in '${SHAREDIPS_FILE}': "${row}".`);
}
continue;
}
if (type !== 'SHARED') continue;
Punishments.sharedIps.set(ip, note);
}
if (needsSave) {
void Punishments.saveSharedIps();
}
}
appendSharedIp(ip: string, note: string) {
const pattern = IPTools.stringToRange(ip);
let ipString = ip;
if (pattern && pattern.minIP !== pattern.maxIP) {
ipString = IPTools.rangeToString(pattern);
}
const buf = `${ipString}\tSHARED\t${note}\r\n`;
return FS(SHAREDIPS_FILE).append(buf);
}
saveSharedIps() {
let buf = 'IP\tType\tNote\r\n';
for (const [ip, note] of Punishments.sharedIps) {
buf += `${ip}\tSHARED\t${note}\r\n`;
}
for (const [range, note] of Punishments.sharedRanges) {
buf += `${IPTools.rangeToString(range)}\tSHARED\t${note}\r\n`;
}
return FS(SHAREDIPS_FILE).write(buf);
}
/**
* sharedips.tsv is in the format:
* IP, type (in this case always SHARED), note
*/
async loadSharedIpBlacklist() {
const data = await FS(SHAREDIPS_BLACKLIST_FILE).readIfExists();
if (!data) return;
for (const row of data.replace('\r', '').split("\n")) {
if (!row) continue;
const [ip, reason] = row.trim().split("\t");
// it can be an ip or a range
if (!IPTools.ipRangeRegex.test(ip)) continue;
if (!reason) continue;
Punishments.sharedIpBlacklist.set(ip, reason);
}
}
appendSharedIpBlacklist(ip: string, reason: string) {
const buf = `${ip}\t${reason}\r\n`;
return FS(SHAREDIPS_BLACKLIST_FILE).append(buf);
}
saveSharedIpBlacklist() {
let buf = `IP\tReason\r\n`;
Punishments.sharedIpBlacklist.forEach((reason, ip) => {
buf += `${ip}\t${reason}\r\n`;
});
return FS(SHAREDIPS_BLACKLIST_FILE).write(buf);
}
async loadWhitelistedNames() {
const data = await FS(WHITELISTED_NAMES_FILE).readIfExists();
if (!data) return;
const lines = data.split('\n');
lines.shift();
for (const line of lines) {
const [userid, whitelister] = line.split('\t');
this.namefilterwhitelist.set(toID(userid), toID(whitelister));
}
}
appendWhitelistedName(name: string, whitelister: string) {
return FS(WHITELISTED_NAMES_FILE).append(`${toID(name)}\t${toID(whitelister)}\r\n`);
}
saveNameWhitelist() {
let buf = `Userid\tWhitelister\t\r\n`;
Punishments.namefilterwhitelist.forEach((userid, whitelister) => {
buf += `${userid}\t${whitelister}\r\n`;
});
return FS(WHITELISTED_NAMES_FILE).write(buf);
}
/*********************************************************
* Adding and removing
*********************************************************/
async punish(user: User | ID, punishment: Punishment, ignoreAlts: boolean, bypassPunishmentfilter = false) {
user = Users.get(user) || user;
if (typeof user === 'string') {
return Punishments.punishName(user, punishment);
}
Punishments.checkInteractions(user.getLastId(), punishment);
if (!punishment.id) punishment.id = user.getLastId();
const userids = new Set<ID>();
const ips = new Set<string>();
const mobileIps = new Set<string>();
const affected = ignoreAlts ? [user] : user.getAltUsers(PUNISH_TRUSTED, true);
for (const alt of affected) {
await this.punishInner(alt, punishment, userids, ips, mobileIps);
}
const { type, id, expireTime, reason, rest } = punishment;
userids.delete(id as ID);
void Punishments.appendPunishment({
userids: [...userids],
ips: [...ips],
punishType: type,
expireTime,
reason,
rest: rest || [],
}, id, PUNISHMENT_FILE);
if (mobileIps.size) {
const mobileExpireTime = Date.now() + MOBILE_PUNISHMENT_DURATIION;
const mobilePunishment = { type, id, expireTime: mobileExpireTime, reason, rest } as Punishment;
for (const mobileIp of mobileIps) {
Punishments.ips.add(mobileIp, mobilePunishment);
}
}
if (!bypassPunishmentfilter) Chat.punishmentfilter(user, punishment);
return affected;
}
async punishInner(user: User, punishment: Punishment, userids: Set<ID>, ips: Set<string>, mobileIps: Set<string>) {
const existingPunishment = Punishments.userids.getByType(user.locked || toID(user.name), punishment.type);
if (existingPunishment) {
// don't reduce the duration of an existing punishment
if (existingPunishment.expireTime > punishment.expireTime) {
punishment.expireTime = existingPunishment.expireTime;
}
// don't override stronger punishment types
const types = ['LOCK', 'NAMELOCK', 'BAN'];
if (types.indexOf(existingPunishment.type) > types.indexOf(punishment.type)) {
punishment.type = existingPunishment.type;
}
}
for (const ip of user.ips) {
const { hostType } = await IPTools.lookup(ip);
if (hostType !== 'mobile') {
Punishments.ips.add(ip, punishment);
ips.add(ip);
} else {
mobileIps.add(ip);
}
}
const lastUserId = user.getLastId();
if (!lastUserId.startsWith('guest')) {
Punishments.userids.add(lastUserId, punishment);
}
if (user.locked && !user.locked.startsWith('#')) {
Punishments.userids.add(user.locked, punishment);
userids.add(user.locked as ID);
}
if (user.autoconfirmed) {
Punishments.userids.add(user.autoconfirmed, punishment);
userids.add(user.autoconfirmed);
}
if (user.trusted) {
Punishments.userids.add(user.trusted, punishment);
userids.add(user.trusted);
}
}
punishName(userid: ID, punishment: Punishment) {
if (!punishment.id) punishment.id = userid;
const foundKeys = Punishments.search(userid).map(([key]) => key);
const userids = new Set<ID>([userid]);
const ips = new Set<string>();
Punishments.checkInteractions(userid, punishment);
for (const key of foundKeys) {
if (key.includes('.')) {
ips.add(key);
} else {
userids.add(key as ID);
}
}
for (const id of userids) {
Punishments.userids.add(id, punishment);
}
for (const ip of ips) {
Punishments.ips.add(ip, punishment);
}
const { type, id, expireTime, reason, rest } = punishment;
const affected = Users.findUsers([...userids], [...ips], { includeTrusted: PUNISH_TRUSTED, forPunishment: true });
userids.delete(id as ID);
void Punishments.appendPunishment({
userids: [...userids],
ips: [...ips],
punishType: type,
expireTime,
reason,
rest: rest || [],
}, id, PUNISHMENT_FILE);
Chat.punishmentfilter(userid, punishment);
return affected;
}
unpunish(id: string, punishType: string) {
id = toID(id);
const punishment = Punishments.userids.getByType(id, punishType);
if (punishment) {
id = punishment.id;
}
// in theory we can stop here if punishment doesn't exist, but
// in case of inconsistent state, we'll try anyway
let success: false | string = false;
Punishments.ips.each((cur, key) => {
const { type: curPunishmentType, id: curId } = cur;
if (curId === id && curPunishmentType === punishType) {
Punishments.ips.deleteOne(key, cur);
success = id;
}
});
Punishments.userids.each((cur, key) => {
const { type: curPunishmentType, id: curId } = cur;
if (curId === id && curPunishmentType === punishType) {
Punishments.userids.deleteOne(key, cur);
success = id;
}
});
if (success) {
Punishments.savePunishments();
}
return success;
}
roomPunish(room: Room | RoomID, user: User | ID, punishment: Punishment) {
if (typeof user === 'string') {
return Punishments.roomPunishName(room, user, punishment);
}
if (!punishment.id) punishment.id = user.getLastId();
Punishments.checkInteractions(punishment.id as ID, punishment, toID(room) as RoomID);
const roomid = typeof room !== 'string' ? (room as Room).roomid : room;
const userids = new Set<ID>();
const ips = new Set<string>();
const affected = user.getAltUsers(PUNISH_TRUSTED, true);
for (const curUser of affected) {
this.roomPunishInner(roomid, curUser, punishment, userids, ips);
}
const { type, id, expireTime, reason, rest } = punishment;
userids.delete(id as ID);
void Punishments.appendPunishment({
userids: [...userids],
ips: [...ips],
punishType: type,
expireTime,
reason,
rest: rest || [],
}, roomid + ':' + id, ROOM_PUNISHMENT_FILE);
if (typeof room !== 'string') {
room = room as Room;
if (!(room.settings.isPrivate === true || room.settings.isPersonal)) {
void Punishments.monitorRoomPunishments(user);
}
}
return affected;
}
roomPunishInner(roomid: RoomID, user: User, punishment: Punishment, userids: Set<string>, ips: Set<string>) {
for (const ip of user.ips) {
Punishments.roomIps.nestedSet(roomid, ip, punishment);
ips.add(ip);
}
if (!user.id.startsWith('guest')) {
Punishments.roomUserids.nestedSet(roomid, user.id, punishment);
}
if (user.autoconfirmed) {
Punishments.roomUserids.nestedSet(roomid, user.autoconfirmed, punishment);
userids.add(user.autoconfirmed);
}
if (user.trusted) {
Punishments.roomUserids.nestedSet(roomid, user.trusted, punishment);
userids.add(user.trusted);
}
}
checkInteractions(userid: ID, punishment: Punishment, room?: RoomID) {
const punishments = Punishments.search(userid);
const results = [];
const info = Punishments[room ? 'roomInteractions' : 'interactions'][punishment.type];
if (!info) return;
for (const [k, curRoom, curPunishment] of punishments) {
if (k !== userid || (room && curRoom !== room)) continue;
if (info.overrides.includes(curPunishment.type)) {
results.push(curPunishment);
if (room) {
Punishments.roomUnpunish(room, userid, curPunishment.type);
} else {
Punishments.unpunish(userid, curPunishment.type);
}
}
}
return results;
}
roomPunishName(room: Room | RoomID, userid: ID, punishment: Punishment) {
if (!punishment.id) punishment.id = userid;
const roomid = typeof room !== 'string' ? (room as Room).roomid : room;
const foundKeys = Punishments.search(userid).map(([key]) => key);
Punishments.checkInteractions(userid, punishment, roomid);
const userids = new Set<ID>([userid]);
const ips = new Set<string>();
for (const key of foundKeys) {
if (key.includes('.')) {
ips.add(key);
} else {
userids.add(key as ID);
}
}
for (const id of userids) {
Punishments.roomUserids.nestedSet(roomid, id, punishment);
}
for (const ip of ips) {
Punishments.roomIps.nestedSet(roomid, ip, punishment);
}
const { type, id, expireTime, reason, rest } = punishment;
const affected = Users.findUsers([...userids], [...ips], { includeTrusted: PUNISH_TRUSTED, forPunishment: true });
userids.delete(id as ID);
void Punishments.appendPunishment({
userids: [...userids],
ips: [...ips],
punishType: type,
expireTime,
reason,
rest: rest || [],
}, roomid + ':' + id, ROOM_PUNISHMENT_FILE);
if (typeof room !== 'string') {
room = room as Room;
if (!(room.settings.isPrivate === true || room.settings.isPersonal)) {
void Punishments.monitorRoomPunishments(userid);
}
}
return affected;
}
/**
* @param ignoreWrite skip persistent storage
*/
roomUnpunish(room: Room | RoomID, id: string, punishType: string, ignoreWrite = false) {
const roomid = typeof room !== 'string' ? (room as Room).roomid : room;
id = toID(id);
const punishment = Punishments.roomUserids.nestedGetByType(roomid, id, punishType);
if (punishment) {
id = punishment.id;
}
// in theory we can stop here if punishment doesn't exist, but
// in case of inconsistent state, we'll try anyway
let success;
const ipSubMap = Punishments.roomIps.get(roomid);
if (ipSubMap) {
for (const [key, punishmentList] of ipSubMap) {
for (const [i, cur] of punishmentList.entries()) {
if (cur.id === id && cur.type === punishType) {
punishmentList.splice(i, 1);
success = id;
}
}
if (!punishmentList.length) {
ipSubMap.delete(key);
}
}
}
const useridSubMap = Punishments.roomUserids.get(roomid);
if (useridSubMap) {
for (const [key, punishmentList] of useridSubMap) {
for (const [i, cur] of punishmentList.entries()) {
if (cur.id === id && cur.type === punishType) {
punishmentList.splice(i, 1);
success = id;
}
if (!punishmentList.length) {
useridSubMap.delete(key);
}
}
}
}
if (success && !ignoreWrite) {
Punishments.saveRoomPunishments();
}
return success;
}
addRoomPunishmentType(
opts: PunishInfo & { type: string } | string,
// backwards compat - todo make only PunishInfo & {type: string}
desc?: string,
callback?: PunishInfo['onActivate']
) {
if (typeof opts === 'string') {
if (!desc) throw new Error('Desc argument must be provided if type is string');
opts = { onActivate: callback, desc, type: opts };
}
this.roomPunishmentTypes.set(opts.type, opts);
if (!this.sortedRoomTypes.includes(opts.type)) this.sortedRoomTypes.unshift(opts.type);
}
addPunishmentType(
opts: PunishInfo & { type: string } | string,
// backwards compat - todo make only PunishInfo & {type: string}
desc?: string,
callback?: PunishInfo['onActivate']
) {
if (typeof opts === 'string') {
if (!desc) throw new Error('Desc argument must be provided if type is string');
opts = { onActivate: callback, desc, type: opts };
}
this.punishmentTypes.set(opts.type, opts);
if (!this.sortedTypes.includes(opts.type)) this.sortedTypes.unshift(opts.type);
}
/*********************************************************
* Specific punishments
*********************************************************/
async ban(
user: User | ID, expireTime: number | null, id: ID | PunishType | null, ignoreAlts: boolean, ...reason: string[]
) {
if (!expireTime) expireTime = Date.now() + GLOBALBAN_DURATION;
const punishment = { type: 'BAN', id, expireTime, reason: reason.join(' ') } as Punishment;
const affected = await Punishments.punish(user, punishment, ignoreAlts);
for (const curUser of affected) {
curUser.locked = punishment.id;
curUser.disconnectAll();
}
return affected;
}
unban(name: string) {
return Punishments.unpunish(name, 'BAN');
}
async lock(
user: User | ID,
expireTime: number | null,
id: ID | PunishType | null,
ignoreAlts: boolean,
reason: string,
bypassPunishmentfilter = false,
rest?: any[]
) {
if (!expireTime) expireTime = Date.now() + LOCK_DURATION;
const punishment = { type: 'LOCK', id, expireTime, reason, rest } as Punishment;
const userObject = Users.get(user);
// This makes it easier for unit tests to tell if a user was locked
if (userObject) userObject.locked = punishment.id;
const affected = await Punishments.punish(user, punishment, ignoreAlts, bypassPunishmentfilter);
for (const curUser of affected) {
Punishments.checkPunishmentTime(curUser, punishment);
curUser.locked = punishment.id;
curUser.updateIdentity();
}
return affected;
}
async autolock(
user: User | ID,
room: Room | RoomID,
source: string,
reason: string,
message: string | null,
week = false,
namelock?: string
) {
if (!message) message = reason;
let punishment = `LOCK`;
let expires = null;
if (week) {
expires = Date.now() + 7 * 24 * 60 * 60 * 1000;
punishment = `WEEKLOCK`;
}
const userid = toID(user);
if (Users.get(user)?.locked) return false;
const name = typeof user === 'string' ? user : user.name;
if (namelock) {
punishment = `NAME${punishment}`;
await Punishments.namelock(user, expires, toID(namelock), false, `Autonamelock: ${name}: ${reason}`);
} else {
await Punishments.lock(user, expires, userid, false, `Autolock: ${name}: ${reason}`);
}
Monitor.log(`[${source}] ${punishment}ED: ${message}`);
const logEntry = {
action: `AUTO${punishment}`,
visualRoomID: typeof room !== 'string' ? (room as Room).roomid : room,
ip: typeof user !== 'string' ? user.latestIp : null,
userid,
note: reason,
isGlobal: true,
};
if (typeof user !== 'string') logEntry.ip = user.latestIp;
const roomObject = Rooms.get(room);
const userObject = Users.get(user);
if (roomObject) {
roomObject.modlog(logEntry);
} else {
Rooms.global.modlog(logEntry);
}
if (roomObject?.battle && userObject?.connections[0]) {
Chat.parse('/savereplay forpunishment', roomObject, userObject, userObject.connections[0]);
}
const roomauth = Rooms.global.destroyPersonalRooms(userid);
if (roomauth.length) {
Monitor.log(`[CrisisMonitor] Autolocked user ${name} has public roomauth (${roomauth.join(', ')}), and should probably be demoted.`);
}
}
unlock(name: string) {
const user = Users.get(name);
let id: string = toID(name);
const success: string[] = [];
if (user?.locked && !user.namelocked) {
id = user.locked;
user.locked = null;
user.namelocked = null;
user.destroyPunishmentTimer();
user.updateIdentity();
success.push(user.getLastName());
}
if (!id.startsWith('#')) {
for (const curUser of Users.users.values()) {
if (curUser.locked === id) {
curUser.locked = null;
curUser.namelocked = null;
curUser.destroyPunishmentTimer();
curUser.updateIdentity();
success.push(curUser.getLastName());
}
}
}
if (['LOCK', 'YEARLOCK'].some(type => Punishments.unpunish(name, type))) {
if (!success.length) success.push(name);
}
if (!success.length) return undefined;
if (!success.some(v => toID(v) === id)) {
success.push(id);
}
return success;
}
/**
* Sets the punishment timer for a user,
* to either MAX_PUNISHMENT_TIMER_LENGTH or the amount of time left on the punishment.
* It also expires a punishment if the time is up.
*/
checkPunishmentTime(user: User, punishment: Punishment) {
if (user.punishmentTimer) {
clearTimeout(user.punishmentTimer);
user.punishmentTimer = null;
}
// Don't unlock users who have non-time-based locks such as #hostfilter
// Optional chaining doesn't seem to work properly in callbacks of setTimeout
if (user.locked && user.locked.startsWith('#')) return;
const { id, expireTime } = punishment;
const timeLeft = expireTime - Date.now();
if (timeLeft <= 1) {
if (user.locked === id) Punishments.unlock(user.id);
return;
}
const waitTime = Math.min(timeLeft, MAX_PUNISHMENT_TIMER_LENGTH);
user.punishmentTimer = setTimeout(() => {
// make sure we're not referencing a pre-hotpatch Punishments instance
global.Punishments.checkPunishmentTime(user, punishment);
}, waitTime);
}
async namelock(
user: User | ID, expireTime: number | null, id: ID | PunishType | null, ignoreAlts: boolean, ...reason: string[]
) {
if (!expireTime) expireTime = Date.now() + LOCK_DURATION;
const punishment = { type: 'NAMELOCK', id, expireTime, reason: reason.join(' ') } as Punishment;
const affected = await Punishments.punish(user, punishment, ignoreAlts);
for (const curUser of affected) {
Punishments.checkPunishmentTime(curUser, punishment);
curUser.locked = punishment.id;
curUser.namelocked = punishment.id;
curUser.resetName(true);
curUser.updateIdentity();
}
return affected;
}
unnamelock(name: string) {
const user = Users.get(name);
let id: string = toID(name);
const success: string[] = [];
if (user?.namelocked) name = user.namelocked;
const unpunished = Punishments.unpunish(name, 'NAMELOCK');
if (user?.locked) {
id = user.locked;
user.locked = null;
user.namelocked = null;
user.destroyPunishmentTimer();
user.resetName();
success.push(user.getLastName());
}
if (!id.startsWith('#')) {
for (const curUser of Users.users.values()) {
if (curUser.locked === id) {
curUser.locked = null;
curUser.namelocked = null;
curUser.destroyPunishmentTimer();
curUser.resetName();
success.push(curUser.getLastName());
}
}
}
if (unpunished && !success.length) success.push(name);
if (!success.length) return false;
if (!success.some(v => toID(v) === id)) {
success.push(id);
}
return success;
}
battleban(user: User, expireTime: number | null, id: ID | null, ...reason: string[]) {
if (!expireTime) expireTime = Date.now() + BATTLEBAN_DURATION;
const punishment = { type: 'BATTLEBAN', id, expireTime, reason: reason.join(' ') } as Punishment;
// Handle tournaments the user was in before being battle banned
for (const games of user.games.keys()) {
const game = Rooms.get(games)!.getGame(Tournaments.Tournament);
if (!game) continue; // this should never happen
if (game.isTournamentStarted) {
game.disqualifyUser(user.id, null, null);
} else if (!game.isTournamentStarted) {
game.removeUser(user.id);
}
}
return Punishments.punish(user, punishment, false);
}
unbattleban(userid: string) {
const user = Users.get(userid);
if (user) {
const punishment = Punishments.isBattleBanned(user);
if (punishment) userid = punishment.id;
}
return Punishments.unpunish(userid, 'BATTLEBAN');
}
isBattleBanned(user: User) {
if (!user) throw new Error(`Trying to check if a non-existent user is battlebanned.`);
let punishment = Punishments.userids.getByType(user.id, 'BATTLEBAN');
if (punishment) return punishment;
if (user.autoconfirmed) {
punishment = Punishments.userids.getByType(user.autoconfirmed, 'BATTLEBAN');
if (punishment) return punishment;
}
for (const ip of user.ips) {
punishment = Punishments.ips.getByType(ip, 'BATTLEBAN');
if (punishment) {
if (Punishments.isSharedIp(ip) && user.autoconfirmed) return;
return punishment;
}
}
}
/**
* Bans a user from using groupchats. Returns an array of roomids of the groupchat they created, if any.
* We don't necessarily want to delete these, since we still need to warn the participants,
* and make a modnote of the participant names, which doesn't seem appropriate for a Punishments method.
*/
async groupchatBan(user: User | ID, expireTime: number | null, id: ID | null, reason: string | null) {
if (!expireTime) expireTime = Date.now() + GROUPCHATBAN_DURATION;
const punishment = { type: 'GROUPCHATBAN', id, expireTime, reason } as Punishment;
const groupchatsCreated = [];
const targetUser = Users.get(user);
if (targetUser) {
for (const roomid of targetUser.inRooms || []) {
const targetRoom = Rooms.get(roomid);
if (!targetRoom?.roomid.startsWith('groupchat-')) continue;
targetRoom.game?.removeBannedUser?.(targetUser);
targetUser.leaveRoom(targetRoom.roomid);
// Handle groupchats that the user created
if (targetRoom.auth.get(targetUser) === Users.HOST_SYMBOL) {
groupchatsCreated.push(targetRoom.roomid);
Punishments.bannedGroupchatParticipants[targetRoom.roomid] = new Set(
// Room#users is a UserTable where the keys are IDs,
// but typed as strings so that they can be used as object keys.
Object.keys(targetRoom.users).filter(u => !targetRoom.users[u].can('lock')) as ID[]
);
}
}
}
await Punishments.punish(user, punishment, false);
return groupchatsCreated;
}
groupchatUnban(user: User | ID) {
let userid = (typeof user === 'object' ? user.id : user);
const punishment = Punishments.isGroupchatBanned(user);
if (punishment) userid = punishment.id as ID;
return Punishments.unpunish(userid, 'GROUPCHATBAN');
}
isGroupchatBanned(user: User | ID) {
const userid = toID(user);
const targetUser = Users.get(user);
let punishment = Punishments.userids.getByType(userid, 'GROUPCHATBAN');
if (punishment) return punishment;
if (targetUser?.autoconfirmed) {
punishment = Punishments.userids.getByType(targetUser.autoconfirmed, 'GROUPCHATBAN');
if (punishment) return punishment;
}
if (targetUser && !targetUser.trusted) {
for (const ip of targetUser.ips) {
punishment = Punishments.ips.getByType(ip, 'GROUPCHATBAN');
if (punishment) {
if (Punishments.isSharedIp(ip) && targetUser.autoconfirmed) return;
return punishment;
}
}
}
}
isTicketBanned(user: User | ID) {
const ips = [];
if (typeof user === 'object') {
ips.push(...user.ips);
ips.unshift(user.latestIp);
user = user.id;
}
const punishment = Punishments.userids.getByType(user, 'TICKETBAN');
if (punishment) return punishment;
// skip if the user is autoconfirmed and on a shared ip
// [0] is forced to be the latestIp
if (ips.some(ip => Punishments.isSharedIp(ip))) return false;
for (const ip of ips) {
const curPunishment = Punishments.ips.getByType(ip, 'TICKETBAN');
if (curPunishment) return curPunishment;
}
return false;
}
/**
* Monitors a groupchat, watching in case too many users who had participated in
* a groupchat that was deleted because its owner was groupchatbanned join.
*/
monitorGroupchatJoin(room: BasicRoom, newUser: User | ID) {
if (Punishments.lastGroupchatMonitorTime[room.roomid] > (Date.now() - GROUPCHAT_MONITOR_INTERVAL)) return;
const newUserID = toID(newUser);
for (const [roomid, participants] of Object.entries(Punishments.bannedGroupchatParticipants)) {
if (!participants.has(newUserID)) continue;
let overlap = 0;
for (const participant of participants) {
if (participant in room.users || room.auth.has(participant)) overlap++;
}
if (overlap > GROUPCHAT_PARTICIPANT_OVERLAP_THRESHOLD) {
let html = `|html|[GroupchatMonitor] The groupchat «<a href="/${room.roomid}">${room.roomid}</a>» `;
if (Config.modloglink) html += `(<a href="${Config.modloglink(new Date(), room.roomid)}">logs</a>) `;
html += `includes ${overlap} participants from forcibly deleted groupchat «<a href="/${roomid}">${roomid}</a>»`;
if (Config.modloglink) html += ` (<a href="${Config.modloglink(new Date(), roomid)}">logs</a>)`;
html += `.`;
Rooms.global.notifyRooms(['staff'], html);
Punishments.lastGroupchatMonitorTime[room.roomid] = Date.now();
}
}
}
punishRange(
range: string,
reason: string,
expireTime?: number | null,
punishType?: string
) {
if (!expireTime) expireTime = Date.now() + RANGELOCK_DURATION;
if (!punishType) punishType = 'LOCK';
const punishment = { type: punishType, id: '#rangelock', expireTime, reason } as Punishment;
Punishments.ips.add(range, punishment);
const ips = [];
const parsedRange = IPTools.stringToRange(range);
if (!parsedRange) throw new Error(`Invalid IP range: ${range}`);
const { minIP, maxIP } = parsedRange;
for (let ipNumber = minIP; ipNumber <= maxIP; ipNumber++) {
ips.push(IPTools.numberToIP(ipNumber)!); // range is already validated by stringToRange
}
void Punishments.appendPunishment({
userids: [],
ips,
punishType,
expireTime,
reason,
rest: [],
}, '#rangelock', PUNISHMENT_FILE, true);
}
banRange(range: string, reason: string, expireTime?: number | null) {
if (!expireTime) expireTime = Date.now() + RANGELOCK_DURATION;
const punishment = { type: 'BAN', id: '#rangelock', expireTime, reason } as Punishment;
Punishments.ips.add(range, punishment);
}
roomBan(room: Room, user: User, expireTime: number | null, id: string | null, ...reason: string[]) {
if (!expireTime) expireTime = Date.now() + ROOMBAN_DURATION;
const punishment = { type: 'ROOMBAN', id, expireTime, reason: reason.join(' ') } as Punishment;
const affected = Punishments.roomPunish(room, user, punishment);
for (const curUser of affected) {
room.game?.removeBannedUser?.(curUser);
curUser.leaveRoom(room.roomid);
}
if (room.subRooms) {
for (const subRoom of room.subRooms.values()) {
for (const curUser of affected) {
subRoom.game?.removeBannedUser?.(curUser);
curUser.leaveRoom(subRoom.roomid);
}
}
}
return affected;
}
roomBlacklist(room: Room, user: User | ID, expireTime: number | null, id: ID | null, ...reason: string[]) {
if (!expireTime) expireTime = Date.now() + BLACKLIST_DURATION;
const punishment = { type: 'BLACKLIST', id, expireTime, reason: reason.join(' ') } as Punishment;
const affected = Punishments.roomPunish(room, user, punishment);
for (const curUser of affected) {
// ensure there aren't roombans so nothing gets mixed up
Punishments.roomUnban(room, (curUser as any).id || curUser);
room.game?.removeBannedUser?.(curUser);
curUser.leaveRoom(room.roomid);
}
if (room.subRooms) {
for (const subRoom of room.subRooms.values()) {
for (const curUser of affected) {
subRoom.game?.removeBannedUser?.(curUser);
curUser.leaveRoom(subRoom.roomid);
}
}
}
return affected;
}
roomUnban(room: Room, userid: string) {
const user = Users.get(userid);
if (user) {
const punishment = Punishments.isRoomBanned(user, room.roomid);
if (punishment) userid = punishment.id;
}
return Punishments.roomUnpunish(room, userid, 'ROOMBAN');
}
/**
* @param ignoreWrite Flag to skip persistent storage.
*/
roomUnblacklist(room: Room, userid: string, ignoreWrite?: boolean) {
const user = Users.get(userid);
if (user) {
const punishment = Punishments.isRoomBanned(user, room.roomid);
if (punishment) userid = punishment.id;
}
return Punishments.roomUnpunish(room, userid, 'BLACKLIST', ignoreWrite);
}
roomUnblacklistAll(room: Room) {
const roombans = Punishments.roomUserids.get(room.roomid);
if (!roombans) return false;
const unblacklisted: string[] = [];
roombans.each(({ type }, userid) => {
if (type === 'BLACKLIST') {
Punishments.roomUnblacklist(room, userid, true);
unblacklisted.push(userid);
}
});
if (unblacklisted.length === 0) return false;
Punishments.saveRoomPunishments();
return unblacklisted;
}
addSharedIp(ip: string, note: string) {
const pattern = IPTools.stringToRange(ip);
const isRange = pattern && pattern.minIP !== pattern.maxIP;
if (isRange) {
Punishments.sharedRanges.set(pattern, note);
} else {
Punishments.sharedIps.set(ip, note);
}
void Punishments.appendSharedIp(ip, note);
for (const user of Users.users.values()) {
const sharedIp = user.ips.some(
curIP => (isRange ? IPTools.checkPattern([pattern], IPTools.ipToNumber(curIP)) : curIP === ip)
);
if (user.locked && user.locked !== user.id && sharedIp) {
if (!user.autoconfirmed) {
user.semilocked = `#sharedip ${user.locked}` as PunishType;
}
user.locked = null;
user.namelocked = null;
user.destroyPunishmentTimer();
user.updateIdentity();
}
}
}
isSharedIp(ip: string) {
if (this.sharedIps.has(ip)) return true;
const num = IPTools.ipToNumber(ip);
for (const range of this.sharedRanges.keys()) {
if (IPTools.checkPattern([range], num)) {
return true;
}
}
return false;
}
removeSharedIp(ip: string) {
const pattern = IPTools.stringToRange(ip);
if (pattern && pattern.minIP !== pattern.maxIP) {
// i don't _like_ this, but map.delete on an object doesn't work.
const isMatch = (range: AddressRange) => (
range.minIP === pattern.minIP && range.maxIP === pattern.maxIP
);
Punishments.sharedRanges = new Map([...Punishments.sharedRanges].filter(([range]) => !isMatch(range)));
} else {
Punishments.sharedIps.delete(ip);
}
void Punishments.saveSharedIps();
}
addBlacklistedSharedIp(ip: string, reason: string) {
void Punishments.appendSharedIpBlacklist(ip, reason);
Punishments.sharedIpBlacklist.set(ip, reason);
}
removeBlacklistedSharedIp(ip: string) {
Punishments.sharedIpBlacklist.delete(ip);
void Punishments.saveSharedIpBlacklist();
}
whitelistName(name: string, whitelister: string) {
if (this.namefilterwhitelist.has(name)) return false;
name = toID(name);
whitelister = toID(whitelister);
this.namefilterwhitelist.set(name, whitelister);
void this.appendWhitelistedName(name, whitelister);
return true;
}
unwhitelistName(name: string) {
name = toID(name);
if (!this.namefilterwhitelist.has(name)) return false;
this.namefilterwhitelist.delete(name);
void this.saveNameWhitelist();
return true;
}
/*********************************************************
* Checking
*********************************************************/
/**
* Returns an array of [key, roomid, punishment] pairs.
*
* @param searchId userid or IP
*/
search(searchId: string) {
/** [key, roomid, punishment][] */
const results: [string, RoomID, Punishment][] = [];
Punishments.ips.each((punishment, ip) => {
const { id } = punishment;
if (searchId === id || searchId === ip) {
results.push([ip, '', punishment]);
}
});
Punishments.userids.each((punishment, userid) => {
const { id } = punishment;
if (searchId === id || searchId === userid) {
results.push([userid, '', punishment]);
}
});
Punishments.roomIps.nestedEach((punishment, roomid, ip) => {
const { id: punishUserid } = punishment;
if (searchId === punishUserid || searchId === ip) {
results.push([ip, roomid, punishment]);
}
});
Punishments.roomUserids.nestedEach((punishment, roomid, userid) => {
const { id: punishUserid } = punishment;
if (searchId === punishUserid || searchId === userid) {
results.push([userid, roomid, punishment]);
}
});
return results;
}
getPunishType(name: string) {
let punishment = Punishments.userids.get(toID(name));
if (punishment) return punishment[0].type;
const user = Users.get(name);
if (!user) return;
punishment = Punishments.ipSearch(user.latestIp);
if (punishment) return punishment[0].type;
return '';
}
hasPunishType(name: string, types: string | string[], ip?: string) {
if (typeof types === 'string') types = [types];
const byName = Punishments.userids.get(name)?.some(p => types.includes(p.type));
if (!ip) return byName;
return byName || Punishments.ipSearch(ip)?.some(p => types.includes(p.type));
}
hasRoomPunishType(room: Room | RoomID, name: string, types: string | string[]) {
if (typeof types === 'string') types = [types];
if (typeof (room as Room).roomid === 'string') room = (room as Room).roomid;
return Punishments.roomUserids.nestedGet(room as RoomID, name)?.some(p => types.includes(p.type));
}
sortedTypes = ['TICKETBAN', 'LOCK', 'NAMELOCK', 'BAN'];
sortedRoomTypes: string[] = [...(global.Punishments?.sortedRoomTypes || []), 'ROOMBAN', 'BLACKLIST'];
byWeight(punishments?: Punishment[], room = false) {
if (!punishments) return [];
return Utils.sortBy(
punishments,
p => -(room ? this.sortedRoomTypes : this.sortedTypes).indexOf(p.type)
);
}
interactions: { [k: string]: { overrides: string[] } } = {
NAMELOCK: { overrides: ['LOCK'] },
};
roomInteractions: { [k: string]: { overrides: string[] } } = {
BLACKLIST: { overrides: ['ROOMBAN'] },
};
/**
* Searches for IP in Punishments.ips
*
* For instance, if IP is '1.2.3.4', will return the value corresponding
* to any of the keys in table match '1.2.3.4', '1.2.3.*', '1.2.*', or '1.*'
*
*/
ipSearch(ip: string, type?: undefined): Punishment[] | undefined;
ipSearch(ip: string, type: string): Punishment | undefined;
ipSearch(ip: string, type?: string): Punishment | Punishment[] | undefined {
const allPunishments: Punishment[] = [];
let punishment = Punishments.ips.get(ip);
if (punishment) {
if (type) return punishment.find(p => p.type === type);
allPunishments.push(...punishment);
}
let dotIndex = ip.lastIndexOf('.');
for (let i = 0; i < 4 && dotIndex > 0; i++) {
ip = ip.substr(0, dotIndex);
punishment = Punishments.ips.get(ip + '.*');
if (punishment) {
if (type) return punishment.find(p => p.type === type);
allPunishments.push(...punishment);
}
dotIndex = ip.lastIndexOf('.');
}
return allPunishments.length ? allPunishments : undefined;
}
/** Defined in Punishments.loadBanlist */
checkRangeBanned(ip: string) {
return false;
}
checkName(user: User, userid: string, registered: boolean) {
if (userid.startsWith('guest')) return;
for (const roomid of user.inRooms) {
Punishments.checkNewNameInRoom(user, userid, roomid);
}
let punishments: Punishment[] = [];
const idPunishments = Punishments.userids.get(userid);
if (idPunishments) {
punishments = idPunishments;
}
const battleban = Punishments.isBattleBanned(user);
if (battleban) punishments.push(battleban);
if (user.namelocked) {
let punishment = Punishments.userids.get(user.namelocked)?.[0];
if (!punishment) punishment = { type: 'NAMELOCK', id: user.namelocked, expireTime: 0, reason: '' };
punishments.push(punishment);
}
if (user.locked) {
let punishment = Punishments.userids.get(user.locked)?.[0];
if (!punishment) punishment = { type: 'LOCK', id: user.locked, expireTime: 0, reason: '' };
punishments.push(punishment);
}
const ticket = Chat.pages?.help ?
`<a href="view-help-request--appeal"><button class="button"><strong>Appeal your punishment</strong></button></a>` : '';
if (!punishments.length) return;
Punishments.byWeight(punishments);
for (const punishment of punishments) {
const id = punishment.type;
const punishmentInfo = this.punishmentTypes.get(id);
const punishUserid = punishment.id;
const reason = punishment.reason ? Utils.html`\n\nReason: ${punishment.reason}` : '';
let appeal = ``;
if (user.permalocked && Config.appealurl) {
appeal += `\n\nPermanent punishments can be appealed: <a href="${Config.appealurl}">${Config.appealurl}</a>`;
} else if (ticket) {
appeal += `\n\nIf you feel you were unfairly punished or wish to otherwise appeal, you can ${ticket}.`;
} else if (Config.appealurl) {
appeal += `\n\nIf you wish to appeal your punishment, please use: <a href="${Config.appealurl}">${Config.appealurl}</a>`;
}
const bannedUnder = punishUserid !== userid ? ` because you have the same IP as banned user: ${punishUserid}` : '';
if (id === 'BATTLEBAN') {
if (punishUserid !== user.id && Punishments.isSharedIp(user.latestIp) && user.autoconfirmed) {
Punishments.unpunish(userid, 'BATTLEBAN');
} else {
void Punishments.punish(user, punishment, false);
user.cancelReady();
if (!Punishments.userids.getByType(userid, 'BATTLEBAN')) {
const appealLink = ticket || (Config.appealurl ? `appeal at: ${Config.appealurl}` : ``);
// Prioritize popups for other global punishments
user.send(
`|popup||html|You are banned from battling` +
`${punishment.id !== userid ? ` because you have the same IP as banned user: ${punishUserid}` : ''}. ` +
`Your battle ban will expire in a few days.` +
`${punishment.reason ? Utils.html`\n\nReason: ${punishment.reason}` : ``}` +
`${appealLink ? `\n\nOr you can ${appealLink}.` : ``}`
);
user.notified.punishment = true;
continue;
}
}
}
if ((id === 'LOCK' || id === 'NAMELOCK') && punishUserid !== userid && Punishments.isSharedIp(user.latestIp)) {
if (!user.autoconfirmed) {
user.semilocked = `#sharedip ${user.locked}` as PunishType;
}
user.locked = null;
user.namelocked = null;
user.destroyPunishmentTimer();
user.updateIdentity();
return;
}
if (id === 'BAN') {
const appealUrl = Config.banappealurl || Config.appealurl;
user.popup(
`Your username (${user.name}) is banned${bannedUnder}. Your ban will expire in a few days.${reason}` +
`${appealUrl ? `||||Or you can appeal at: ${appealUrl}` : ``}`
);
user.notified.punishment = true;
if (registered) void Punishments.punish(user, punishment, false);
user.disconnectAll();
return; // end the loop here
}
if (id === 'NAMELOCK' || user.namelocked) {
user.send(`|popup||html|You are namelocked and can't have a username${bannedUnder}. Your namelock will expire in a few days.${reason}${appeal}`);
user.locked = punishUserid;
user.namelocked = punishUserid;
user.resetName();
user.updateIdentity();
} else if (id === 'LOCK') {
if (punishUserid === '#hostfilter' || punishUserid === '#ipban') {
user.send(`|popup||html|Your IP (${user.latestIp}) is currently locked due to being a proxy. We automatically lock these connections since they are used to spam, hack, or otherwise attack our server. Disable any proxies you are using to connect to PS.\n\n<a href="view-help-request--appeal"><button class="button">Help me with a lock from a proxy</button></a>`);
} else if (user.latestHostType === 'proxy' && user.locked !== user.id) {
user.send(`|popup||html|You are locked${bannedUnder} on the IP (${user.latestIp}), which is a proxy. We automatically lock these connections since they are used to spam, hack, or otherwise attack our server. Disable any proxies you are using to connect to PS.\n\n<a href="view-help-request--appeal"><button class="button">Help me with a lock from a proxy</button></a>`);
} else if (!user.notified.lock) {
user.send(`|popup||html|You are locked${bannedUnder}. ${user.permalocked ? `This lock is permanent.` : `Your lock will expire in a few days.`}${reason}${appeal}`);
}
user.notified.lock = true;
user.locked = punishUserid;
user.updateIdentity();
} else if (punishmentInfo?.onActivate) {
punishmentInfo.onActivate.call(this, user, punishment, null, punishment.id === user.id);
}
Punishments.checkPunishmentTime(user, punishment);
}
}
checkIp(user: User, connection: Connection) {
const ip = connection.ip;
let punishments = Punishments.ipSearch(ip);
if (!punishments && Punishments.checkRangeBanned(ip)) {
punishments = [{ type: 'LOCK', id: '#ipban', expireTime: Infinity, reason: '' }];
}
if (punishments) {
const isSharedIP = Punishments.isSharedIp(ip);
let sharedAndHasPunishment = false;
for (const punishment of punishments) {
if (isSharedIP) {
if (!user.locked && !user.autoconfirmed) {
user.semilocked = `#sharedip ${punishment.id}` as PunishType;
}
sharedAndHasPunishment = true;
} else {
if (['BAN', 'LOCK', 'NAMELOCK'].includes(punishment.type)) {
user.locked = punishment.id;
if (punishment.type === 'NAMELOCK') {
user.namelocked = punishment.id;
user.resetName(true);
}
} else {
const info = Punishments.punishmentTypes.get(punishment.type);
info?.onActivate?.call(this, user, punishment, null, punishment.id === user.id);
}
}
}
if (!sharedAndHasPunishment) Punishments.checkPunishmentTime(user, Punishments.byWeight(punishments)[0]);
}
return IPTools.lookup(ip).then(({ dnsbl, host, hostType }) => {
user = connection.user || user;
if (hostType === 'proxy' && !user.trusted && !user.locked) {
user.locked = '#hostfilter';
} else if (dnsbl && !user.autoconfirmed) {
user.semilocked = '#dnsbl';
}
if (host) {
user.latestHost = host;
user.latestHostType = hostType;
}
Chat.hostfilter(host || '', user, connection, hostType);
});
}
/**
* IP bans need to be checked separately since we don't even want to
* make a User object if an IP is banned.
*/
checkIpBanned(connection: Connection) {
const ip = connection.ip;
if (Punishments.cfloods.has(ip) || (Monitor.countConnection(ip) && Punishments.cfloods.add(ip))) {
connection.send(`|popup||modal|PS is under heavy load and cannot accommodate your connection right now.`);
return '#cflood';
}
if (Punishments.isSharedIp(ip)) return false;
let banned: false | string = false;
const punishment = Punishments.ipSearch(ip, 'BAN');
if (punishment) {
banned = punishment.id;
}
if (!banned) return false;
const appealUrl = Config.banappealurl || Config.appealurl;
connection.send(
`|popup||modal|You are banned because you have the same IP (${ip}) as banned user '${banned}'. ` +
`Your ban will expire in a few days.` +
`${appealUrl ? `||||Or you can appeal at: ${appealUrl}` : ``}`
);
Monitor.notice(`CONNECT BLOCKED - IP BANNED: ${ip} (${banned})`);
return banned;
}
checkNameInRoom(user: User, roomid: RoomID): boolean {
let punishment = Punishments.roomUserids.nestedGet(roomid, user.id);
if (!punishment && user.autoconfirmed) {
punishment = Punishments.roomUserids.nestedGet(roomid, user.autoconfirmed);
}
if (punishment?.some(p => p.type === 'ROOMBAN' || p.type === 'BLACKLIST')) {
return true;
}
const room = Rooms.get(roomid)!;
if (room.parent) {
return Punishments.checkNameInRoom(user, room.parent.roomid);
}
return false;
}
/**
* @param userid The name into which the user is renamed.
*/
checkNewNameInRoom(user: User, userid: string, roomid: RoomID): Punishment[] | null {
let punishments: Punishment[] | null = Punishments.roomUserids.nestedGet(roomid, userid) || null;
if (!punishments) {
const room = Rooms.get(roomid)!;
if (room.parent) {
punishments = Punishments.roomUserids.nestedGet(room.parent.roomid, userid) || null;
}
}
if (punishments) {
for (const punishment of punishments) {
const info = this.roomPunishmentTypes.get(punishment.type);
if (info?.onActivate) {
info.onActivate.call(this, user, punishment, Rooms.get(roomid)!, punishment.id === user.id);
continue;
}
if (punishment.type !== 'ROOMBAN' && punishment.type !== 'BLACKLIST') return null;
const room = Rooms.get(roomid)!;
room.game?.removeBannedUser?.(user);
user.leaveRoom(room.roomid);
}
return punishments;
}
return null;
}
/**
* @return Descriptive text for the remaining time until the punishment expires, if any.
*/
checkLockExpiration(userid: string | null) {
if (!userid) return ``;
const punishment = Punishments.userids.getByType(userid, 'LOCK');
const user = Users.get(userid);
if (user?.permalocked) return ` (never expires; you are permalocked)`;
return Punishments.checkPunishmentExpiration(punishment);
}
checkPunishmentExpiration(punishment: Punishment | undefined) {
if (!punishment) return ``;
const expiresIn = new Date(punishment.expireTime).getTime() - Date.now();
const expiresDays = Math.round(expiresIn / 1000 / 60 / 60 / 24);
let expiresText = '';
if (expiresDays >= 1) {
expiresText = `in around ${Chat.count(expiresDays, "days")}`;
} else {
expiresText = `soon`;
}
if (expiresIn > 1) return ` (expires ${expiresText})`;
}
isRoomBanned(user: User, roomid: RoomID): Punishment | undefined {
if (!user) throw new Error(`Trying to check if a non-existent user is room banned.`);
let punishments = Punishments.roomUserids.nestedGet(roomid, user.id);
for (const p of punishments || []) {
if (p.type === 'ROOMBAN' || p.type === 'BLACKLIST') return p;
}
if (user.autoconfirmed) {
punishments = Punishments.roomUserids.nestedGet(roomid, user.autoconfirmed);
for (const p of punishments || []) {
if (p.type === 'ROOMBAN' || p.type === 'BLACKLIST') return p;
}
}
if (!user.trusted) {
for (const ip of user.ips) {
punishments = Punishments.roomIps.nestedGet(roomid, ip);
if (punishments) {
for (const punishment of punishments) {
if (punishment.type === 'ROOMBAN') {
return punishment;
} else if (punishment.type === 'BLACKLIST') {
if (Punishments.isSharedIp(ip) && user.autoconfirmed) continue;
return punishment;
}
}
}
}
}
for (const id of user.previousIDs) {
punishments = Punishments.roomUserids.nestedGet(roomid, id);
for (const p of punishments || []) {
if (['ROOMBAN', 'BLACKLIST'].includes(p.type)) return p;
}
}
const room = Rooms.get(roomid);
if (!room) throw new Error(`Trying to ban a user from a nonexistent room: ${roomid}`);
if (room.parent) return Punishments.isRoomBanned(user, room.parent.roomid);
}
isGlobalBanned(user: User): Punishment | undefined {
if (!user) throw new Error(`Trying to check if a non-existent user is global banned.`);
const punishment = Punishments.userids.getByType(user.id, "BAN") || Punishments.userids.getByType(user.id, "FORCEBAN");
if (punishment) return punishment;
}
isBlacklistedSharedIp(ip: string) {
const pattern = IPTools.stringToRange(ip);
if (!pattern) {
throw new Error(`Invalid IP address: '${ip}'`);
}
for (const [blacklisted, reason] of this.sharedIpBlacklist) {
const range = IPTools.stringToRange(blacklisted);
if (!range) throw new Error("Falsy range in sharedIpBlacklist");
if (IPTools.rangeIntersects(range, pattern)) return reason;
}
return false;
}
/**
* Returns an array of all room punishments associated with a user.
*
* options.publicOnly will make this only return public room punishments.
* options.checkIps will also check the IP of the user for IP-based punishments.
*/
getRoomPunishments(user: User | string, options: Partial<{ checkIps: any, publicOnly: any }> = {}) {
if (!user) return [];
const userid = toID(user);
const punishments: [Room, Punishment][] = [];
for (const curRoom of Rooms.global.chatRooms) {
if (
!curRoom || curRoom.settings.isPrivate === true ||
(options.publicOnly && curRoom.settings.isPersonal)
) continue;
let punishment = Punishments.roomUserids.nestedGet(curRoom.roomid, userid);
if (punishment) {
for (const p of punishment) {
punishments.push([curRoom, p]);
}
continue;
} else if (options?.checkIps) {
if (typeof user !== 'string') {
let longestIPPunishment;
for (const ip of user.ips) {
punishment = Punishments.roomIps.nestedGet(curRoom.roomid, ip);
if (punishment && (!longestIPPunishment || punishment[2] > longestIPPunishment[2])) {
longestIPPunishment = punishment;
}
}
if (longestIPPunishment) {
for (const p of longestIPPunishment) {
punishments.push([curRoom, p]);
}
continue;
}
}
}
if (typeof user !== 'string' && curRoom.muteQueue) {
// check mutes
for (const entry of curRoom.muteQueue) {
if (userid === entry.userid ||
user.guestNum === entry.guestNum ||
(user.autoconfirmed && user.autoconfirmed === entry.autoconfirmed)) {
punishments.push([curRoom, { type: 'MUTE', id: entry.userid, expireTime: entry.time, reason: '' } as Punishment]);
}
}
}
}
return punishments;
}
getPunishments(roomid?: RoomID, ignoreMutes?: boolean) {
const punishmentTable: [string, PunishmentEntry][] = [];
if (roomid && (!Punishments.roomIps.has(roomid) || !Punishments.roomUserids.has(roomid))) return punishmentTable;
// `Punishments.roomIps.get(roomid)` guaranteed to exist above
(roomid ? Punishments.roomIps.get(roomid)! : Punishments.ips).each((punishment, ip) => {
const { type, id, expireTime, reason, rest } = punishment;
if (id !== '#rangelock' && id.startsWith('#')) return;
let entry = punishmentTable.find(e => e[0] === id && e[1].punishType === type)?.[1];
if (entry) {
entry.ips.push(ip);
return;
}
entry = {
userids: [],
ips: [ip],
punishType: type,
expireTime,
reason,
rest: rest || [],
};
punishmentTable.push([id, entry]);
});
// `Punishments.roomIps.get(roomid)` guaranteed to exist above
(roomid ? Punishments.roomUserids.get(roomid)! : Punishments.userids).each((punishment, userid) => {
const { type, id, expireTime, reason, rest } = punishment;
if (id.startsWith('#')) return;
let entry = punishmentTable.find(([curId, cur]) => id === curId && cur.punishType === type)?.[1];
if (!entry) {
entry = {
userids: [],
ips: [],
punishType: type,
expireTime,
reason,
rest: rest || [],
};
punishmentTable.push([id, entry]);
}
if (userid !== id) entry.userids.push(userid as ID); // guaranteed as per above check
});
if (roomid && ignoreMutes !== false) {
const room = Rooms.get(roomid);
if (room?.muteQueue) {
for (const mute of room.muteQueue) {
punishmentTable.push([mute.userid, {
userids: [], ips: [], punishType: "MUTE", expireTime: mute.time, reason: "", rest: [],
}]);
}
}
}
return punishmentTable;
}
visualizePunishments(punishments: Map<string, PunishmentEntry>, user: User) {
let buf = "";
buf += `<div class="ladder pad"><h2>List of active punishments:</h2>`;
buf += `<table">`;
buf += `<tr>`;
buf += `<th>Username</th>`;
buf += `<th>Punishment type</th>`;
buf += `<th>Expire time</th>`;
buf += `<th>Reason</th>`;
buf += `<th>Alts</th>`;
if (user.can('ip')) buf += `<th>IPs</th>`;
buf += `</tr>`;
for (const [userid, punishment] of punishments) {
const expiresIn = new Date(punishment.expireTime).getTime() - Date.now();
if (expiresIn < 1000) continue;
const expireString = Chat.toDurationString(expiresIn, { precision: 1 });
buf += `<tr>`;
buf += `<td>${userid}</td>`;
buf += `<td>${punishment.punishType}</td>`;
buf += `<td>${expireString}</td>`;
buf += `<td>${punishment.reason || ' - '}</td>`;
buf += `<td>${punishment.userids.join(", ") || ' - '}</td>`;
if (user.can('ip')) buf += `<td>${punishment.ips.join(", ") || ' - '}</td>`;
buf += `</tr>`;
}
buf += `</table>`;
buf += `</div>`;
return buf;
}
/**
* Notifies staff if a user has three or more room punishments.
*/
async monitorRoomPunishments(user: User | ID) {
if ((user as User).locked) return;
const userid = toID(user);
/** Default to 3 if the Config option is not defined or valid */
const minPunishments = (typeof Config.monitorminpunishments === 'number' ? Config.monitorminpunishments : 3);
if (!minPunishments) return;
let punishments = Punishments.getRoomPunishments(user, { checkIps: true, publicOnly: true });
punishments = punishments.filter(([room, punishment]) => (
Punishments.roomPunishmentTypes.get(punishment.type)?.activatePunishMonitor
));
if (punishments.length >= minPunishments) {
let points = 0;
const punishmentText = punishments.map(([room, punishment]) => {
const { type: punishType, id: punishUserid, reason } = punishment;
if (punishType in PUNISHMENT_POINT_VALUES) points += PUNISHMENT_POINT_VALUES[punishType];
let punishDesc = Punishments.roomPunishmentTypes.get(punishType)?.desc;
if (!punishDesc) punishDesc = `punished`;
if (punishUserid !== userid) punishDesc += ` as ${punishUserid}`;
// Backwards compatibility for current punishments
const trimmedReason = reason?.trim();
if (trimmedReason && !trimmedReason.startsWith('(PROOF:')) punishDesc += `: ${trimmedReason}`;
return `<<${room}>> (${punishDesc})`;
}).join(', ');
if (Config.punishmentautolock && points >= AUTOLOCK_POINT_THRESHOLD) {
const rooms = punishments.map(([room]) => room).join(', ');
const reason = `Autolocked for having punishments in ${punishments.length} rooms: ${rooms}`;
const message = `${(user as User).name || userid} was locked for having punishments in ${punishments.length} rooms: ${punishmentText}`;
const globalPunishments = await Rooms.Modlog.getGlobalPunishments(userid, AUTOWEEKLOCK_DAYS_TO_SEARCH);
// null check in case SQLite is disabled
const isWeek = globalPunishments !== null && globalPunishments >= AUTOWEEKLOCK_THRESHOLD;
void Punishments.autolock(user, 'staff', 'PunishmentMonitor', reason, message, isWeek);
if (typeof user !== 'string') {
user.popup(
`|modal|You've been locked for breaking the rules in multiple chatrooms.\n\n` +
`If you feel that your lock was unjustified, you can still PM staff members (%, @, ~) to discuss it${
Config.appealurl ? ` or you can appeal:\n${Config.appealurl}` : "."
}\n\n` +
`Your lock will expire in a few days.`
);
}
} else {
Monitor.log(`[PunishmentMonitor] ${(user as User).name || userid} currently has punishments in ${punishments.length} rooms: ${punishmentText}`);
}
}
}
renameRoom(oldID: RoomID, newID: RoomID) {
for (const table of [Punishments.roomUserids, Punishments.roomIps]) {
const entry = table.get(oldID);
if (entry) {
table.set(newID, entry);
table.delete(oldID);
}
}
Punishments.saveRoomPunishments();
}
PunishmentMap = PunishmentMap;
NestedPunishmentMap = NestedPunishmentMap;
}();