Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Support banning users from using groupchats #7558

Merged
merged 1 commit into from
Oct 28, 2020
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 @@ -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();
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 @@ -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);
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, I already scoped it to a fake room.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh wow, nice.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I should probably have finished reading before posting preliminary thoughts.

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 @@ -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 {
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 @@ -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`);
}
Expand Down