Skip to content

Commit

Permalink
Support banning users from using groupchats (#7558)
Browse files Browse the repository at this point in the history
  • Loading branch information
AnnikaCodes committed Oct 28, 2020
1 parent a4a3e38 commit 0164af5
Show file tree
Hide file tree
Showing 7 changed files with 250 additions and 24 deletions.
19 changes: 10 additions & 9 deletions server/chat-commands/info.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 += `<br />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 += `<br />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 += `<br />Semilocked: ${targetUser.semilocked}`;
}
Expand Down
77 changes: 77 additions & 0 deletions server/chat-commands/moderation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -1846,6 +1847,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();
Expand Down
7 changes: 7 additions & 0 deletions server/chat-commands/room-settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.");
Expand Down
5 changes: 4 additions & 1 deletion server/global-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
144 changes: 130 additions & 14 deletions server/punishments.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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]
*/
Expand Down Expand Up @@ -151,6 +163,13 @@ export const Punishments = new class {
* 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} = {};
/**
* punishType is an allcaps string, for global punishments they can be
* anything in the punishmentTypes map.
Expand Down Expand Up @@ -185,6 +204,7 @@ export const Punishments = new class {
['BLACKLIST', 'blacklisted'],
['BATTLEBAN', 'battlebanned'],
['MUTE', 'muted'],
['GROUPCHATBAN', 'banned from using groupchats'],
]);
constructor() {
setImmediate(() => {
Expand Down Expand Up @@ -924,6 +944,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 «<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();
}
}
}

lockRange(range: string, reason: string, expireTime?: number | null) {
if (!expireTime) expireTime = Date.now() + RANGELOCK_DURATION;
const punishment = ['LOCK', '#rangelock', expireTime, reason] as Punishment;
Expand Down Expand Up @@ -1379,22 +1494,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 {
Expand Down
12 changes: 12 additions & 0 deletions server/rooms.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
10 changes: 10 additions & 0 deletions server/users.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1233,6 +1233,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`);
}
Expand Down

0 comments on commit 0164af5

Please sign in to comment.