From 83197c3598f6367aa09096cec19cd01a3e57188a Mon Sep 17 00:00:00 2001 From: Annika Date: Sun, 27 Sep 2020 11:49:09 -0700 Subject: [PATCH] Support banning users from using groupchats --- server/chat-commands/info.ts | 19 ++-- server/chat-commands/moderation.ts | 77 ++++++++++++++ server/chat-commands/room-settings.ts | 7 ++ server/global-types.ts | 5 +- server/punishments.ts | 144 +++++++++++++++++++++++--- server/rooms.ts | 12 +++ server/users.ts | 10 ++ 7 files changed, 250 insertions(+), 24 deletions(-) diff --git a/server/chat-commands/info.ts b/server/chat-commands/info.ts index 888423d8f783..eeed502b51a1 100644 --- a/server/chat-commands/info.ts +++ b/server/chat-commands/info.ts @@ -152,20 +152,21 @@ export const commands: ChatCommands = { if (punishment[3]) buf += Utils.html` (reason: ${punishment[3]})`; } } + const battlebanned = Punishments.isBattleBanned(targetUser); if (battlebanned) { buf += `
BATTLEBANNED: ${battlebanned[1]}`; - const expiresIn = new Date(battlebanned[2]).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) buf += ` (expires ${expiresText})`; + buf += ` (expires ${Punishments.checkPunishmentExpiration(battlebanned)})`; if (battlebanned[3]) buf += Utils.html` (reason: ${battlebanned[3]})`; } + + const groupchatbanned = Punishments.isGroupchatBanned(targetUser); + if (groupchatbanned) { + buf += `
Banned from using groupchats${groupchatbanned[1] !== targetUser.id ? `: ${groupchatbanned[1]}` : ``}`; + buf += ` ${Punishments.checkPunishmentExpiration(groupchatbanned)}`; + if (groupchatbanned[3]) buf += Utils.html` (reason: ${groupchatbanned[3]})`; + } + if (targetUser.semilocked) { buf += `
Semilocked: ${targetUser.semilocked}`; } diff --git a/server/chat-commands/moderation.ts b/server/chat-commands/moderation.ts index 7a62a53ac049..2266f049312f 100644 --- a/server/chat-commands/moderation.ts +++ b/server/chat-commands/moderation.ts @@ -15,6 +15,7 @@ import {Utils} from '../../lib/utils'; const MAX_REASON_LENGTH = 300; const MUTE_LENGTH = 7 * 60 * 1000; const HOURMUTE_LENGTH = 60 * 60 * 1000; +const DAY = 24 * 60 * 60 * 1000; /** Require reasons for punishment commands */ const REQUIRE_REASONS = true; @@ -1842,6 +1843,82 @@ export const commands: ChatCommands = { }, unbattlebanhelp: [`/unbattleban [username] - [DEPRECATED] Allows a user to battle again. Requires: % @ &`], + monthgroupchatban: 'groupchatban', + monthgcban: 'groupchatban', + gcban: 'groupchatban', + groupchatban(target, room, user, connection, cmd) { + room = this.requireRoom(); + if (!target) return this.parse(`/help groupchatban`); + this.checkCan('lock'); + + const reason = this.splitTarget(target); + const targetUser = this.targetUser; + if (!targetUser) return this.errorReply(`User ${this.targetUsername} not found.`); + if (target.length > MAX_REASON_LENGTH) { + return this.errorReply(`The reason is too long. It cannot exceed ${MAX_REASON_LENGTH} characters.`); + } + + const isMonth = cmd.startsWith('month'); + + if (!isMonth && Punishments.isGroupchatBanned(targetUser)) { + return this.errorReply(`User '${targetUser.name}' is already banned from using groupchats.`); + } + + const reasonText = reason ? `: ${reason}` : ``; + this.privateGlobalModAction(`${targetUser.name} was banned from using groupchats for a ${isMonth ? 'month' : 'week'} by ${user.name}${reasonText}.`); + + if (targetUser.trusted) { + Monitor.log(`[CrisisMonitor] Trusted user ${targetUser.name} was banned from using groupchats by ${user.name}, and should probably be demoted.`); + } + + const createdGroupchats = Punishments.groupchatBan(targetUser, (isMonth ? Date.now() + 30 * DAY : null), null, reason); + targetUser.popup(`|modal|${user.name} has banned you from using groupchats for a ${isMonth ? 'month' : 'week'}${reasonText}`); + this.globalModlog("GROUPCHATBAN", targetUser, ` by ${user.id}${reasonText}`); + + for (const roomid of createdGroupchats) { + const targetRoom = Rooms.get(roomid); + if (!targetRoom) continue; + const participants = targetRoom.warnParticipants?.( + `This groupchat (${targetRoom.title}) has been deleted due to inappropriate conduct by its creator, ${targetUser.name}.` + + ` Do not attempt to recreate it, or you may be punished.${reason ? ` (reason: ${reason})` : ``}` + ); + + const modlogEntry = { + action: 'NOTE', + loggedBy: user.id, + note: `participants in ${roomid} (creator: ${targetUser.id}): ${participants.join(', ')}`, + }; + Rooms.global.modlog(modlogEntry, targetRoom.roomid); + targetRoom.modlog(modlogEntry); + targetRoom.destroy(); + } + }, + groupchatbanhelp: [ + `/groupchatban [user], [optional reason]`, + `/monthgroupchatban [user], [optional reason]`, + `Bans the user from joining or creating groupchats for a week (or month). Requires: % @ &`, + ], + + ungcban: 'ungroupchatban', + gcunban: 'ungroupchatban', + groucphatunban: 'ungroupchatban', + ungroupchatban(target, room, user) { + if (!target) return this.parse('/help ungroupchatban'); + this.checkCan('lock'); + + const targetUser = Users.get(target); + const unbanned = Punishments.groupchatUnban(targetUser || toID(target)); + + if (unbanned) { + this.addGlobalModAction(`${unbanned} was ungroupchatbanned by ${user.name}.`); + this.globalModlog("UNGROUPCHATBAN", toID(target), ` by ${user.id}`); + if (targetUser) targetUser.popup(`${user.name} has allowed you to use groupchats again.`); + } else { + this.errorReply(`User ${target} is not banned from using groupchats.`); + } + }, + ungroupchatbanhelp: [`/ungroupchatban [user] - Allows a groupchatbanned user to use groupchats again. Requires: % @ &`], + nameblacklist: 'blacklistname', blacklistname(target, room, user) { room = this.requireRoom(); diff --git a/server/chat-commands/room-settings.ts b/server/chat-commands/room-settings.ts index d7bc54c57e1b..a1d2cbaaab74 100644 --- a/server/chat-commands/room-settings.ts +++ b/server/chat-commands/room-settings.ts @@ -730,6 +730,13 @@ export const commands: ChatCommands = { if (!user.autoconfirmed) { return this.errorReply("You must be autoconfirmed to make a groupchat."); } + + const groupchatbanned = Punishments.isGroupchatBanned(user); + if (groupchatbanned) { + const expireText = Punishments.checkPunishmentExpiration(groupchatbanned); + return this.errorReply(`You are banned from using groupchats ${expireText}.`); + } + if (cmd === 'subroomgroupchat') { if (!user.can('mute', null, room)) { return this.errorReply("You can only create subroom groupchats for rooms you're staff in."); diff --git a/server/global-types.ts b/server/global-types.ts index f37dee2e268c..a2630879f4f5 100644 --- a/server/global-types.ts +++ b/server/global-types.ts @@ -44,7 +44,10 @@ type RoomGame = Rooms.RoomGame; type RoomBattle = Rooms.RoomBattle; type Roomlog = Rooms.Roomlog; type Room = Rooms.Room; -type RoomID = "" | "lobby" | "staff" | "upperstaff" | "development" | "battle" | string & {__isRoomID: true}; +type RoomID = ( + "" | "lobby" | "staff" | "upperstaff" | "development" | + "battle" | "groupchat" | string & {__isRoomID: true} +); namespace Rooms { export type GlobalRoomState = import('./rooms').GlobalRoomState; export type ChatRoom = import('./rooms').ChatRoom; diff --git a/server/punishments.ts b/server/punishments.ts index 88e9b4f406bc..ae1f5201cf4f 100644 --- a/server/punishments.ts +++ b/server/punishments.ts @@ -24,6 +24,7 @@ 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 @@ -37,6 +38,17 @@ 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 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 = 30 * 1000; // 30 seconds + /** * A punishment is an array: [punishType, userid | #punishmenttype, expireTime, reason] */ @@ -151,6 +163,13 @@ export const Punishments = new class { * Connection flood table. Separate table from IP bans. */ readonly cfloods = new Set(); + /** + * 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} = {}; + /** roomid:timestamp map */ + readonly lastGroupchatMonitorTime: {[k: string]: number} = {}; /** * punishType is an allcaps string, for global punishments they can be * anything in the punishmentTypes map. @@ -185,6 +204,7 @@ export const Punishments = new class { ['BLACKLIST', 'blacklisted'], ['BATTLEBAN', 'battlebanned'], ['MUTE', 'muted'], + ['GROUPCHATBAN', 'banned from using groupchats'], ]); constructor() { setImmediate(() => { @@ -923,6 +943,101 @@ export const Punishments = new class { } } + /** + * 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. + */ + groupchatBan(user: User | ID, expireTime: number | null, id: ID | null, reason: string | null) { + if (!expireTime) expireTime = Date.now() + GROUPCHATBAN_DURATION; + const punishment = ['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; + if (targetRoom.game && targetRoom.game.removeBannedUser) { + 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) as ID[] + ); + } + } + } + + Punishments.roomPunish("groupchat", user, punishment); + return groupchatsCreated; + } + + groupchatUnban(user: User | ID) { + let userid = (typeof user === 'object' ? (user as User).id : user); + + const punishment = Punishments.isGroupchatBanned(user); + if (punishment) userid = punishment[1] as ID; + + return Punishments.roomUnpunish("groupchat", userid, 'GROUPCHATBAN'); + } + + isGroupchatBanned(user: User | ID) { + const userid = toID(user); + const targetUser = Users.get(user); + + let punishment = Punishments.roomUserids.nestedGet("groupchat", userid); + if (punishment?.[0] === 'GROUPCHATBAN') return punishment; + + if (targetUser?.autoconfirmed) { + punishment = Punishments.roomUserids.nestedGet("groupchat", targetUser.autoconfirmed); + if (punishment?.[0] === 'GROUPCHATBAN') return punishment; + } + + if (targetUser && !targetUser.trusted) { + for (const ip of targetUser.ips) { + punishment = Punishments.roomIps.nestedGet("groupchat", ip); + if (punishment?.[0] === 'GROUPCHATBAN') { + if (Punishments.sharedIps.has(ip) && targetUser.autoconfirmed) return; + return punishment; + } + } + } + } + + /** + * 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 «${room.roomid}» `; + if (Config.modloglink) html += `(logs) `; + + html += `includes ${overlap} participants from forcibly deleted groupchat «${roomid}»`; + if (Config.modloglink) html += ` (logs)`; + html += `.`; + + Rooms.global.notifyRooms(['staff'], html); + Punishments.lastGroupchatMonitorTime[room.roomid] = Date.now(); + } + } + } + lockRange(range: string, reason: string, expireTime?: number | null) { if (!expireTime) expireTime = Date.now() + RANGELOCK_DURATION; const punishment = ['LOCK', '#rangelock', expireTime, reason] as Punishment; @@ -1360,22 +1475,23 @@ export const Punishments = new class { checkLockExpiration(userid: string | null) { if (!userid) return ``; const punishment = Punishments.userids.get(userid); + const user = Users.get(userid); + if (user?.permalocked) return ` (never expires; you are permalocked)`; - if (punishment) { - const user = Users.get(userid); - if (user?.permalocked) return ` (never expires; you are permalocked)`; - const expiresIn = new Date(punishment[2]).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})`; - } + return Punishments.checkPunishmentExpiration(punishment); + } - return ``; + checkPunishmentExpiration(punishment: Punishment | undefined) { + if (!punishment) return ``; + const expiresIn = new Date(punishment[2]).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 { diff --git a/server/rooms.ts b/server/rooms.ts index f02738cf51ac..786ba5831832 100644 --- a/server/rooms.ts +++ b/server/rooms.ts @@ -702,6 +702,18 @@ export abstract class BasicRoom { return {id: this.roomid.slice(7, lastHyphen), password: this.roomid.slice(lastHyphen, end)}; } + /** + * Displays a warning popup to all users in the room. + * Returns a list of all the user IDs that were warned. + */ + warnParticipants(message: string) { + const warned = Object.values(this.users); + for (const user of warned) { + user.popup(`|modal|${message}`); + } + return warned; + } + /** * @param newID Add this param if the roomid is different from `toID(newTitle)` * @param noAlias Set this param to true to not redirect aliases and the room's old name to its new name. diff --git a/server/users.ts b/server/users.ts index 74f5bf91ffcc..cf899e8c5ea2 100644 --- a/server/users.ts +++ b/server/users.ts @@ -1231,6 +1231,16 @@ export class User extends Chat.MessageContext { 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`); }