diff --git a/src/ErrorCache.ts b/src/ErrorCache.ts index 0c00387..7395120 100644 --- a/src/ErrorCache.ts +++ b/src/ErrorCache.ts @@ -22,35 +22,54 @@ const TRIGGER_INTERVALS: { [key: string]: number } = { [ERROR_KIND_FATAL]: 15 * 60 * 1000, // 15 minutes }; +/** + * The ErrorCache is used to suppress the same error messages for the same error state. + * An example would be when mjolnir has been told to protect a room but is missing some permission such as the ability to send `m.room.server_acl`. + * Each time `Mjolnir` synchronizes policies to protected rooms Mjolnir will try to log to the management room that Mjolnir doesn't have permission to send `m.room.server_acl`. + * The ErrorCache is an attempt to make sure the error is reported only once. + */ export default class ErrorCache { - private static roomsToErrors: { [roomId: string]: { [kind: string]: number } } = {}; + private roomsToErrors: Map> = new Map(); - private constructor() { + constructor() { } - public static resetError(roomId: string, kind: string) { - if (!ErrorCache.roomsToErrors[roomId]) { - ErrorCache.roomsToErrors[roomId] = {}; + /** + * Reset the error cache for a room/kind in the situation where circumstances have changed e.g. if Mjolnir has been informed via sync of a `m.room.power_levels` event in the room, we would want to clear `ERROR_KIND_PERMISSION` + * so that a user can see if their changes worked. + * @param roomId The room to reset the error cache for. + * @param kind The kind of error we are resetting. + */ + public resetError(roomId: string, kind: string) { + if (!this.roomsToErrors.has(roomId)) { + this.roomsToErrors.set(roomId, new Map()); } - ErrorCache.roomsToErrors[roomId][kind] = 0; + this.roomsToErrors.get(roomId)?.set(kind, 0); } - public static triggerError(roomId: string, kind: string): boolean { - if (!ErrorCache.roomsToErrors[roomId]) { - ErrorCache.roomsToErrors[roomId] = {}; + /** + * Register the error with the cache. + * @param roomId The room where the error is occuring or related to. + * @param kind What kind of error, either `ERROR_KIND_PERMISSION` or `ERROR_KIND_FATAL`. + * @returns True if the error kind has been triggered in that room, + * meaning it has been longer than the time specified in `TRIGGER_INTERVALS` since the last trigger (or the first trigger). Otherwise false. + */ + public triggerError(roomId: string, kind: string): boolean { + if (!this.roomsToErrors.get(roomId)) { + this.roomsToErrors.set(roomId, new Map()); } - const triggers = ErrorCache.roomsToErrors[roomId]; - if (!triggers[kind]) { - triggers[kind] = 0; + const triggers = this.roomsToErrors.get(roomId)!; + if (!triggers.has(kind)) { + triggers?.set(kind, 0); } - const lastTriggerTime = triggers[kind]; + const lastTriggerTime = triggers.get(kind)!; const now = new Date().getTime(); const interval = TRIGGER_INTERVALS[kind]; if ((now - lastTriggerTime) >= interval) { - triggers[kind] = now; + triggers.set(kind, now); return true; } else { return false; diff --git a/src/ManagementRoomOutput.ts b/src/ManagementRoomOutput.ts new file mode 100644 index 0000000..e47ba61 --- /dev/null +++ b/src/ManagementRoomOutput.ts @@ -0,0 +1,123 @@ +/* +Copyright 2019, 2022 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import { extractRequestError, LogLevel, LogService, MatrixClient, MessageType, Permalinks, TextualMessageEventContent, UserID } from "matrix-bot-sdk"; +import { IConfig } from "./config"; +import { htmlEscape } from "./utils"; + +const levelToFn = { + [LogLevel.DEBUG.toString()]: LogService.debug, + [LogLevel.INFO.toString()]: LogService.info, + [LogLevel.WARN.toString()]: LogService.warn, + [LogLevel.ERROR.toString()]: LogService.error, +}; + +/** + * Allows the different componenets of mjolnir to send messages back to the management room without introducing a dependency on the entirity of a `Mjolnir` instance. + */ +export default class ManagementRoomOutput { + + constructor( + private readonly managementRoomId: string, + private readonly client: MatrixClient, + private readonly config: IConfig, + ) { + + } + + /** + * Take an arbitrary string and a set of room IDs, and return a + * TextualMessageEventContent whose plaintext component replaces those room + * IDs with their canonical aliases, and whose html component replaces those + * room IDs with their matrix.to room pills. + * + * @param client The matrix client on which to query for room aliases + * @param text An arbitrary string to rewrite with room aliases and pills + * @param roomIds A set of room IDs to find and replace in `text` + * @param msgtype The desired message type of the returned TextualMessageEventContent + * @returns A TextualMessageEventContent with replaced room IDs + */ + private async replaceRoomIdsWithPills(text: string, roomIds: Set, msgtype: MessageType = "m.text"): Promise { + const content: TextualMessageEventContent = { + body: text, + formatted_body: htmlEscape(text), + msgtype: msgtype, + format: "org.matrix.custom.html", + }; + + // Though spec doesn't say so, room ids that have slashes in them are accepted by Synapse and Dendrite unfortunately for us. + const escapeRegex = (v: string): string => { + return v.replace(/[-\/\\^$*+?.()|[\]{}]/g, '\\$&'); + }; + + const viaServers = [(new UserID(await this.client.getUserId())).domain]; + for (const roomId of roomIds) { + let alias = roomId; + try { + alias = (await this.client.getPublishedAlias(roomId)) || roomId; + } catch (e) { + // This is a recursive call, so tell the function not to try and call us + await this.logMessage(LogLevel.WARN, "utils", `Failed to resolve room alias for ${roomId} - see console for details`, null, true); + LogService.warn("utils", extractRequestError(e)); + } + const regexRoomId = new RegExp(escapeRegex(roomId), "g"); + content.body = content.body.replace(regexRoomId, alias); + if (content.formatted_body) { + const permalink = Permalinks.forRoom(alias, alias !== roomId ? [] : viaServers); + content.formatted_body = content.formatted_body.replace(regexRoomId, `${htmlEscape(alias)}`); + } + } + + return content; + } + + /** + * Log a message to the management room and the console, replaces any room ids in additionalRoomIds with pills. + * + * @param level Used to determine whether to hide the message or not depending on `config.verboseLogging`. + * @param module Used to help find where in the source the message is coming from (when logging to the console). + * @param message The message we want to log. + * @param additionalRoomIds The roomIds in the message that we want to be replaced by room pills. + * @param isRecursive Whether logMessage is being called from logMessage. + */ + public async logMessage(level: LogLevel, module: string, message: string | any, additionalRoomIds: string[] | string | null = null, isRecursive = false): Promise { + if (!additionalRoomIds) additionalRoomIds = []; + if (!Array.isArray(additionalRoomIds)) additionalRoomIds = [additionalRoomIds]; + + if (this.config.verboseLogging || LogLevel.INFO.includes(level)) { + let clientMessage = message; + if (level === LogLevel.WARN) clientMessage = `⚠ | ${message}`; + if (level === LogLevel.ERROR) clientMessage = `‼ | ${message}`; + + const client = this.client; + const roomIds = [this.managementRoomId, ...additionalRoomIds]; + + let evContent: TextualMessageEventContent = { + body: message, + formatted_body: htmlEscape(message), + msgtype: "m.notice", + format: "org.matrix.custom.html", + }; + if (!isRecursive) { + evContent = await this.replaceRoomIdsWithPills(clientMessage, new Set(roomIds), "m.notice"); + } + + await client.sendMessage(this.managementRoomId, evContent); + } + + levelToFn[level.toString()](module, message); + } +} diff --git a/src/Mjolnir.ts b/src/Mjolnir.ts index 0193229..0400a72 100644 --- a/src/Mjolnir.ts +++ b/src/Mjolnir.ts @@ -20,54 +20,35 @@ import { LogLevel, LogService, MatrixClient, - MatrixGlob, MembershipEvent, Permalinks, - UserID, - TextualMessageEventContent } from "matrix-bot-sdk"; -import { ALL_RULE_TYPES as ALL_BAN_LIST_RULE_TYPES, RULE_ROOM, RULE_SERVER, RULE_USER } from "./models/ListRule"; -import { applyServerAcls } from "./actions/ApplyAcl"; -import { RoomUpdateError } from "./models/RoomUpdateError"; +import { ALL_RULE_TYPES as ALL_BAN_LIST_RULE_TYPES } from "./models/ListRule"; import { COMMAND_PREFIX, handleCommand } from "./commands/CommandHandler"; -import { applyUserBans } from "./actions/ApplyBan"; -import ErrorCache, { ERROR_KIND_FATAL, ERROR_KIND_PERMISSION } from "./ErrorCache"; -import { Protection } from "./protections/IProtection"; -import { PROTECTIONS } from "./protections/protections"; -import { Consequence } from "./protections/consequence"; -import { ProtectionSettingValidationError } from "./protections/ProtectionSettings"; import { UnlistedUserRedactionQueue } from "./queues/UnlistedUserRedactionQueue"; -import { EventRedactionQueue, RedactUserInRoom } from "./queues/EventRedactionQueue"; import { htmlEscape } from "./utils"; import { ReportManager } from "./report/ReportManager"; import { ReportPoller } from "./report/ReportPoller"; import { WebAPIs } from "./webapis/WebAPIs"; -import { replaceRoomIdsWithPills } from "./utils"; import RuleServer from "./models/RuleServer"; -import { RoomMemberManager } from "./RoomMembers"; -import { ProtectedRoomActivityTracker } from "./queues/ProtectedRoomActivityTracker"; import { ThrottlingQueue } from "./queues/ThrottlingQueue"; import { IConfig } from "./config"; -import PolicyList, { ListRuleChange } from "./models/PolicyList"; - -const levelToFn = { - [LogLevel.DEBUG.toString()]: LogService.debug, - [LogLevel.INFO.toString()]: LogService.info, - [LogLevel.WARN.toString()]: LogService.warn, - [LogLevel.ERROR.toString()]: LogService.error, -}; +import PolicyList from "./models/PolicyList"; +import { ProtectedRooms } from "./ProtectedRooms"; +import ManagementRoomOutput from "./ManagementRoomOutput"; +import { ProtectionManager } from "./protections/ProtectionManager"; +import { RoomMemberManager } from "./RoomMembers"; export const STATE_NOT_STARTED = "not_started"; export const STATE_CHECKING_PERMISSIONS = "checking_permissions"; export const STATE_SYNCING = "syncing"; export const STATE_RUNNING = "running"; -const WATCHED_LISTS_EVENT_TYPE = "org.matrix.mjolnir.watched_lists"; -const ENABLED_PROTECTIONS_EVENT_TYPE = "org.matrix.mjolnir.enabled_protections"; const PROTECTED_ROOMS_EVENT_TYPE = "org.matrix.mjolnir.protected_rooms"; +const WATCHED_LISTS_EVENT_TYPE = "org.matrix.mjolnir.watched_lists"; const WARN_UNPROTECTED_ROOM_EVENT_PREFIX = "org.matrix.mjolnir.unprotected_room_warning.for."; -const CONSEQUENCE_EVENT_DATA = "org.matrix.mjolnir.consequence"; + /** * Synapse will tell us where we last got to on polling reports, so we need * to store that for pagination on further polls @@ -79,18 +60,11 @@ export class Mjolnir { private localpart: string; private currentState: string = STATE_NOT_STARTED; public readonly roomJoins: RoomMemberManager; - public protections = new Map(); /** * This is for users who are not listed on a watchlist, * but have been flagged by the automatic spam detection as suispicous */ private unlistedUserRedactionQueue = new UnlistedUserRedactionQueue(); - /** - * This is a queue for redactions to process after mjolnir - * has finished applying ACL and bans when syncing. - */ - private eventRedactionQueue = new EventRedactionQueue(); - private automaticRedactionReasons: MatrixGlob[] = []; /** * Every room that we are joined to except the management room. Used to implement `config.protectAllJoinedRooms`. */ @@ -99,23 +73,31 @@ export class Mjolnir { * These are rooms that were explicitly said to be protected either in the config, or by what is present in the account data for `org.matrix.mjolnir.protected_rooms`. */ private explicitlyProtectedRoomIds: string[] = []; + /** + * These are rooms that we have joined to watch the list, but don't have permission to protect. + * These are eventually are exluded from `protectedRooms` in `applyUnprotectedRooms` via `resyncJoinedRooms`. + */ private unprotectedWatchedListRooms: string[] = []; + public readonly protectedRoomsTracker: ProtectedRooms; private webapis: WebAPIs; - private protectedRoomActivityTracker: ProtectedRoomActivityTracker; public taskQueue: ThrottlingQueue; /** - * Used to provide mutual exclusion when synchronizing rooms with the state of a policy list. - * This is because requests operating with rules from an older version of the list that are slow - * could race & give the room an inconsistent state. An example is if we add multiple m.policy.rule.server rules, - * which would cause several requests to a room to send a new m.room.server_acl event. - * These requests could finish in any order, which has left rooms with an inconsistent server_acl event - * until Mjolnir synchronises the room with its policy lists again, which can be in the region of hours. + * Reporting back to the management room. */ - public aclChain: Promise = Promise.resolve(); + public readonly managementRoomOutput: ManagementRoomOutput; /* * Config-enabled polling of reports in Synapse, so Mjolnir can react to reports */ private reportPoller?: ReportPoller; + /** + * Store the protections being used by Mjolnir. + */ + public readonly protectionManager: ProtectionManager; + /** + * Handle user reports from the homeserver. + */ + public readonly reportManager: ReportManager; + /** * Adds a listener to the client that will automatically accept invitations. * @param {MatrixClient} client @@ -151,7 +133,7 @@ export class Mjolnir { const spaceUserIds = await client.getJoinedRoomMembers(spaceId) .catch(async e => { if (e.body?.errcode === "M_FORBIDDEN") { - await mjolnir.logMessage(LogLevel.ERROR, 'Mjolnir', `Mjolnir is not in the space configured for acceptInvitesFromSpace, did you invite it?`); + await mjolnir.managementRoomOutput.logMessage(LogLevel.ERROR, 'Mjolnir', `Mjolnir is not in the space configured for acceptInvitesFromSpace, did you invite it?`); await client.joinRoom(spaceId); return await client.getJoinedRoomMembers(spaceId); } else { @@ -196,14 +178,15 @@ export class Mjolnir { } const ruleServer = config.web.ruleServer ? new RuleServer() : null; - const mjolnir = new Mjolnir(client, managementRoomId, config, protectedRooms, policyLists, ruleServer); - await mjolnir.logMessage(LogLevel.INFO, "index", "Mjolnir is starting up. Use !mjolnir to query status."); + const mjolnir = new Mjolnir(client, await client.getUserId(), managementRoomId, config, protectedRooms, policyLists, ruleServer); + await mjolnir.managementRoomOutput.logMessage(LogLevel.INFO, "index", "Mjolnir is starting up. Use !mjolnir to query status."); Mjolnir.addJoinOnInviteListener(mjolnir, client, config); return mjolnir; } constructor( public readonly client: MatrixClient, + private readonly clientUserId: string, public readonly managementRoomId: string, public readonly config: IConfig, /* @@ -217,10 +200,6 @@ export class Mjolnir { ) { this.explicitlyProtectedRoomIds = Object.keys(this.protectedRooms); - for (const reason of this.config.automaticallyRedactForReasons) { - this.automaticRedactionReasons.push(new MatrixGlob(reason.toLowerCase())); - } - // Setup bot. client.on("room.event", this.handleEvent.bind(this)); @@ -278,20 +257,22 @@ export class Mjolnir { } }); - // Setup room activity watcher - this.protectedRoomActivityTracker = new ProtectedRoomActivityTracker(client); - // Setup Web APIs console.log("Creating Web APIs"); - const reportManager = new ReportManager(this); - reportManager.on("report.new", this.handleReport.bind(this)); - this.webapis = new WebAPIs(reportManager, this.config, this.ruleServer); + this.reportManager = new ReportManager(this); + this.webapis = new WebAPIs(this.reportManager, this.config, this.ruleServer); if (config.pollReports) { - this.reportPoller = new ReportPoller(this, reportManager); + this.reportPoller = new ReportPoller(this, this.reportManager); } // Setup join/leave listener this.roomJoins = new RoomMemberManager(this.client); this.taskQueue = new ThrottlingQueue(this, config.backgroundDelayMS); + + this.protectionManager = new ProtectionManager(this); + + this.managementRoomOutput = new ManagementRoomOutput(managementRoomId, client, config); + const protections = new ProtectionManager(this); + this.protectedRoomsTracker = new ProtectedRooms(client, clientUserId, managementRoomId, this.managementRoomOutput, protections, config); } public get lists(): PolicyList[] { @@ -302,10 +283,6 @@ export class Mjolnir { return this.currentState; } - public get enabledProtections(): Protection[] { - return [...this.protections.values()].filter(p => p.enabled); - } - /** * Returns the handler to flag a user for redaction, removing any future messages that they send. * Typically this is used by the flooding or image protection on users that have not been banned from a list yet. @@ -315,10 +292,6 @@ export class Mjolnir { return this.unlistedUserRedactionQueue; } - public get automaticRedactGlobs(): MatrixGlob[] { - return this.automaticRedactionReasons; - } - /** * Start Mjölnir. */ @@ -339,7 +312,7 @@ export class Mjolnir { if (err.body?.errcode !== "M_NOT_FOUND") { throw err; } else { - this.logMessage(LogLevel.INFO, "Mjolnir@startup", "report poll setting does not exist yet"); + this.managementRoomOutput.logMessage(LogLevel.INFO, "Mjolnir@startup", "report poll setting does not exist yet"); } } this.reportPoller.start(reportPollSetting.from); @@ -348,7 +321,7 @@ export class Mjolnir { // Load the state. this.currentState = STATE_CHECKING_PERMISSIONS; - await this.logMessage(LogLevel.DEBUG, "Mjolnir@startup", "Loading protected rooms..."); + await this.managementRoomOutput.logMessage(LogLevel.DEBUG, "Mjolnir@startup", "Loading protected rooms..."); await this.resyncJoinedRooms(false); try { const data: { rooms?: string[] } | null = await this.client.getAccountData(PROTECTED_ROOMS_EVENT_TYPE); @@ -356,7 +329,6 @@ export class Mjolnir { for (const roomId of data['rooms']) { this.protectedRooms[roomId] = Permalinks.forRoom(roomId); this.explicitlyProtectedRoomIds.push(roomId); - this.protectedRoomActivityTracker.addProtectedRoom(roomId); } } } catch (e) { @@ -364,27 +336,27 @@ export class Mjolnir { } await this.buildWatchedPolicyLists(); this.applyUnprotectedRooms(); + await this.protectionManager.start(); if (this.config.verifyPermissionsOnStartup) { - await this.logMessage(LogLevel.INFO, "Mjolnir@startup", "Checking permissions..."); - await this.verifyPermissions(this.config.verboseLogging); + await this.managementRoomOutput.logMessage(LogLevel.INFO, "Mjolnir@startup", "Checking permissions..."); + await this.protectedRoomsTracker.verifyPermissions(this.config.verboseLogging); } this.currentState = STATE_SYNCING; if (this.config.syncOnStartup) { - await this.logMessage(LogLevel.INFO, "Mjolnir@startup", "Syncing lists..."); - await this.syncLists(this.config.verboseLogging); - await this.registerProtections(); + await this.managementRoomOutput.logMessage(LogLevel.INFO, "Mjolnir@startup", "Syncing lists..."); + await this.protectedRoomsTracker.syncLists(this.config.verboseLogging); } this.currentState = STATE_RUNNING; - await this.logMessage(LogLevel.INFO, "Mjolnir@startup", "Startup complete. Now monitoring rooms."); + await this.managementRoomOutput.logMessage(LogLevel.INFO, "Mjolnir@startup", "Startup complete. Now monitoring rooms."); } catch (err) { try { LogService.error("Mjolnir", "Error during startup:"); LogService.error("Mjolnir", extractRequestError(err)); this.stop(); - await this.logMessage(LogLevel.ERROR, "Mjolnir@startup", "Startup failed due to error - see console"); + await this.managementRoomOutput.logMessage(LogLevel.ERROR, "Mjolnir@startup", "Startup failed due to error - see console"); throw err; } catch (e) { LogService.error("Mjolnir", `Failed to report startup error to the management room: ${e}`); @@ -403,39 +375,10 @@ export class Mjolnir { this.reportPoller?.stop(); } - public async logMessage(level: LogLevel, module: string, message: string | any, additionalRoomIds: string[] | string | null = null, isRecursive = false): Promise { - if (!additionalRoomIds) additionalRoomIds = []; - if (!Array.isArray(additionalRoomIds)) additionalRoomIds = [additionalRoomIds]; - - if (this.config.verboseLogging || LogLevel.INFO.includes(level)) { - let clientMessage = message; - if (level === LogLevel.WARN) clientMessage = `⚠ | ${message}`; - if (level === LogLevel.ERROR) clientMessage = `‼ | ${message}`; - - const client = this.client; - const roomIds = [this.managementRoomId, ...additionalRoomIds]; - - let evContent: TextualMessageEventContent = { - body: message, - formatted_body: htmlEscape(message), - msgtype: "m.notice", - format: "org.matrix.custom.html", - }; - if (!isRecursive) { - evContent = await replaceRoomIdsWithPills(this, clientMessage, new Set(roomIds), "m.notice"); - } - - await client.sendMessage(this.managementRoomId, evContent); - } - - levelToFn[level.toString()](module, message); - } - - public async addProtectedRoom(roomId: string) { this.protectedRooms[roomId] = Permalinks.forRoom(roomId); this.roomJoins.addRoom(roomId); - this.protectedRoomActivityTracker.addProtectedRoom(roomId); + this.protectedRoomsTracker.addProtectedRoom(roomId); const unprotectedIdx = this.unprotectedWatchedListRooms.indexOf(roomId); if (unprotectedIdx >= 0) this.unprotectedWatchedListRooms.splice(unprotectedIdx, 1); @@ -450,13 +393,12 @@ export class Mjolnir { const rooms = (additionalProtectedRooms?.rooms ?? []); rooms.push(roomId); await this.client.setAccountData(PROTECTED_ROOMS_EVENT_TYPE, { rooms: rooms }); - await this.syncLists(this.config.verboseLogging); } public async removeProtectedRoom(roomId: string) { delete this.protectedRooms[roomId]; this.roomJoins.removeRoom(roomId); - this.protectedRoomActivityTracker.removeProtectedRoom(roomId); + this.protectedRoomsTracker.removeProtectedRoom(roomId); const idx = this.explicitlyProtectedRoomIds.indexOf(roomId); if (idx >= 0) this.explicitlyProtectedRoomIds.splice(idx, 1); @@ -471,194 +413,40 @@ export class Mjolnir { await this.client.setAccountData(PROTECTED_ROOMS_EVENT_TYPE, additionalProtectedRooms); } + // See https://github.com/matrix-org/mjolnir/issues/370. private async resyncJoinedRooms(withSync = true) { if (!this.config.protectAllJoinedRooms) return; - const joinedRoomIds = (await this.client.getJoinedRooms()).filter(r => r !== this.managementRoomId); + const joinedRoomIds = (await this.client.getJoinedRooms()) + .filter(r => r !== this.managementRoomId && !this.unprotectedWatchedListRooms.includes(r)); const oldRoomIdsSet = new Set(this.protectedJoinedRoomIds); const joinedRoomIdsSet = new Set(joinedRoomIds); - // Remove every room id that we have joined from `this.protectedRooms`. - for (const roomId of this.protectedJoinedRoomIds) { - delete this.protectedRooms[roomId]; - this.protectedRoomActivityTracker.removeProtectedRoom(roomId); + // find every room that we have left (since last time) + for (const roomId of oldRoomIdsSet.keys()) { if (!joinedRoomIdsSet.has(roomId)) { + // Then we have left this room. + delete this.protectedRooms[roomId]; this.roomJoins.removeRoom(roomId); } } - this.protectedJoinedRoomIds = joinedRoomIds; - // Add all joined rooms back to the permalink object - for (const roomId of joinedRoomIds) { - this.protectedRooms[roomId] = Permalinks.forRoom(roomId); - this.protectedRoomActivityTracker.addProtectedRoom(roomId); + // find every room that we have joined (since last time). + for (const roomId of joinedRoomIdsSet.keys()) { if (!oldRoomIdsSet.has(roomId)) { + // Then we have joined this room this.roomJoins.addRoom(roomId); + this.protectedRooms[roomId] = Permalinks.forRoom(roomId); } } + // update our internal representation of joined rooms. + this.protectedJoinedRoomIds = joinedRoomIds; this.applyUnprotectedRooms(); if (withSync) { - await this.syncLists(this.config.verboseLogging); + await this.protectedRoomsTracker.syncLists(this.config.verboseLogging); } } - /* - * Take all the builtin protections, register them to set their enabled (or not) state and - * update their settings with any saved non-default values - */ - private async registerProtections() { - for (const protection of PROTECTIONS) { - try { - await this.registerProtection(protection); - } catch (e) { - LogService.warn("Mjolnir", extractRequestError(e)); - } - } - } - - /* - * Make a list of the names of enabled protections and save them in a state event - */ - private async saveEnabledProtections() { - const protections = this.enabledProtections.map(p => p.name); - await this.client.setAccountData(ENABLED_PROTECTIONS_EVENT_TYPE, { enabled: protections }); - } - /* - * Enable a protection by name and persist its enable state in to a state event - * - * @param name The name of the protection whose settings we're enabling - */ - public async enableProtection(name: string) { - const protection = this.protections.get(name); - if (protection !== undefined) { - protection.enabled = true; - await this.saveEnabledProtections(); - } - } - /* - * Disable a protection by name and remove it from the persistent list of enabled protections - * - * @param name The name of the protection whose settings we're disabling - */ - public async disableProtection(name: string) { - const protection = this.protections.get(name); - if (protection !== undefined) { - protection.enabled = false; - await this.saveEnabledProtections(); - } - } - - /* - * Read org.matrix.mjolnir.setting state event, find any saved settings for - * the requested protectionName, then iterate and validate against their parser - * counterparts in Protection.settings and return those which validate - * - * @param protectionName The name of the protection whose settings we're reading - * @returns Every saved setting for this protectionName that has a valid value - */ - public async getProtectionSettings(protectionName: string): Promise<{ [setting: string]: any }> { - let savedSettings: { [setting: string]: any } = {} - try { - savedSettings = await this.client.getRoomStateEvent( - this.managementRoomId, 'org.matrix.mjolnir.setting', protectionName - ); - } catch { - // setting does not exist, return empty object - return {}; - } - - const settingDefinitions = this.protections.get(protectionName)?.settings ?? {}; - const validatedSettings: { [setting: string]: any } = {} - for (let [key, value] of Object.entries(savedSettings)) { - if ( - // is this a setting name with a known parser? - key in settingDefinitions - // is the datatype of this setting's value what we expect? - && typeof (settingDefinitions[key].value) === typeof (value) - // is this setting's value valid for the setting? - && settingDefinitions[key].validate(value) - ) { - validatedSettings[key] = value; - } else { - await this.logMessage( - LogLevel.WARN, - "getProtectionSetting", - `Tried to read ${protectionName}.${key} and got invalid value ${value}` - ); - } - } - return validatedSettings; - } - - /* - * Takes an object of settings we want to change and what their values should be, - * check that their values are valid, combine them with current saved settings, - * then save the amalgamation to a state event - * - * @param protectionName Which protection these settings belong to - * @param changedSettings The settings to change and their values - */ - public async setProtectionSettings(protectionName: string, changedSettings: { [setting: string]: any }): Promise { - const protection = this.protections.get(protectionName); - if (protection === undefined) { - return; - } - - const validatedSettings: { [setting: string]: any } = await this.getProtectionSettings(protectionName); - - for (let [key, value] of Object.entries(changedSettings)) { - if (!(key in protection.settings)) { - throw new ProtectionSettingValidationError(`Failed to find protection setting by name: ${key}`); - } - if (typeof (protection.settings[key].value) !== typeof (value)) { - throw new ProtectionSettingValidationError(`Invalid type for protection setting: ${key} (${typeof (value)})`); - } - if (!protection.settings[key].validate(value)) { - throw new ProtectionSettingValidationError(`Invalid value for protection setting: ${key} (${value})`); - } - validatedSettings[key] = value; - } - - await this.client.sendStateEvent( - this.managementRoomId, 'org.matrix.mjolnir.setting', protectionName, validatedSettings - ); - } - - /* - * Given a protection object; add it to our list of protections, set whether it is enabled - * and update its settings with any saved non-default values. - * - * @param protection The protection object we want to register - */ - public async registerProtection(protection: Protection) { - this.protections.set(protection.name, protection) - - let enabledProtections: { enabled: string[] } | null = null; - try { - enabledProtections = await this.client.getAccountData(ENABLED_PROTECTIONS_EVENT_TYPE); - } catch { - // this setting either doesn't exist, or we failed to read it (bad network?) - // TODO: retry on certain failures? - } - protection.enabled = enabledProtections?.enabled.includes(protection.name) ?? false; - - const savedSettings = await this.getProtectionSettings(protection.name); - for (let [key, value] of Object.entries(savedSettings)) { - // this.getProtectionSettings() validates this data for us, so we don't need to - protection.settings[key].setValue(value); - } - } - /* - * Given a protection object; remove it from our list of protections. - * - * @param protection The protection object we want to unregister - */ - public unregisterProtection(protectionName: string) { - if (!(protectionName in this.protections)) { - throw new Error("Failed to find protection by name: " + protectionName); - } - this.protections.delete(protectionName); - } /** * Helper for constructing `PolicyList`s and making sure they have the right listeners set up. @@ -668,22 +456,13 @@ export class Mjolnir { private async addPolicyList(roomId: string, roomRef: string): Promise { const list = new PolicyList(roomId, roomRef, this.client); this.ruleServer?.watch(list); - list.on('PolicyList.batch', this.syncWithPolicyList.bind(this)); + list.on('PolicyList.batch', (...args) => this.protectedRoomsTracker.syncWithPolicyList(...args)); await list.updateList(); this.policyLists.push(list); + this.protectedRoomsTracker.watchList(list); return list; } - /** - * Get a protection by name. - * - * @return If there is a protection with this name *and* it is enabled, - * return the protection. - */ - public getProtection(protectionName: string): Protection | null { - return this.protections.get(protectionName) ?? null; - } - public async watchList(roomRef: string): Promise { const joinedRooms = await this.client.getJoinedRooms(); const permalink = Permalinks.parseUrl(roomRef); @@ -716,6 +495,7 @@ export class Mjolnir { if (list) { this.policyLists.splice(this.policyLists.indexOf(list), 1); this.ruleServer?.unwatch(list); + this.protectedRoomsTracker.unwatchList(list); } await this.client.setAccountData(WATCHED_LISTS_EVENT_TYPE, { @@ -741,14 +521,18 @@ export class Mjolnir { // Ignore - probably haven't warned about it yet } - await this.logMessage(LogLevel.WARN, "Mjolnir", `Not protecting ${roomId} - it is a ban list that this bot did not create. Add the room as protected if it is supposed to be protected. This warning will not appear again.`, roomId); + await this.managementRoomOutput.logMessage(LogLevel.WARN, "Mjolnir", `Not protecting ${roomId} - it is a ban list that this bot did not create. Add the room as protected if it is supposed to be protected. This warning will not appear again.`, roomId); await this.client.setAccountData(WARN_UNPROTECTED_ROOM_EVENT_PREFIX + roomId, { warned: true }); } + /** + * So this is called to retroactively remove protected rooms from Mjolnir's internal model of joined rooms. + * This is really shit and needs to be changed asap. Unacceptable even. + */ private applyUnprotectedRooms() { for (const roomId of this.unprotectedWatchedListRooms) { delete this.protectedRooms[roomId]; - this.protectedRoomActivityTracker.removeProtectedRoom(roomId); + this.protectedRoomsTracker.removeProtectedRoom(roomId); } } @@ -777,221 +561,6 @@ export class Mjolnir { } } - public async verifyPermissions(verbose = true, printRegardless = false) { - const errors: RoomUpdateError[] = []; - for (const roomId of Object.keys(this.protectedRooms)) { - errors.push(...(await this.verifyPermissionsIn(roomId))); - } - - const hadErrors = await this.printActionResult(errors, "Permission errors in protected rooms:", printRegardless); - if (!hadErrors && verbose) { - const html = `All permissions look OK.`; - const text = "All permissions look OK."; - await this.client.sendMessage(this.managementRoomId, { - msgtype: "m.notice", - body: text, - format: "org.matrix.custom.html", - formatted_body: html, - }); - } - } - - private async verifyPermissionsIn(roomId: string): Promise { - const errors: RoomUpdateError[] = []; - const additionalPermissions = this.requiredProtectionPermissions(); - - try { - const ownUserId = await this.client.getUserId(); - - const powerLevels = await this.client.getRoomStateEvent(roomId, "m.room.power_levels", ""); - if (!powerLevels) { - // noinspection ExceptionCaughtLocallyJS - throw new Error("Missing power levels state event"); - } - - function plDefault(val: number | undefined | null, def: number): number { - if (!val && val !== 0) return def; - return val; - } - - const users = powerLevels['users'] || {}; - const events = powerLevels['events'] || {}; - const usersDefault = plDefault(powerLevels['users_default'], 0); - const stateDefault = plDefault(powerLevels['state_default'], 50); - const ban = plDefault(powerLevels['ban'], 50); - const kick = plDefault(powerLevels['kick'], 50); - const redact = plDefault(powerLevels['redact'], 50); - - const userLevel = plDefault(users[ownUserId], usersDefault); - const aclLevel = plDefault(events["m.room.server_acl"], stateDefault); - - // Wants: ban, kick, redact, m.room.server_acl - - if (userLevel < ban) { - errors.push({ - roomId, - errorMessage: `Missing power level for bans: ${userLevel} < ${ban}`, - errorKind: ERROR_KIND_PERMISSION, - }); - } - if (userLevel < kick) { - errors.push({ - roomId, - errorMessage: `Missing power level for kicks: ${userLevel} < ${kick}`, - errorKind: ERROR_KIND_PERMISSION, - }); - } - if (userLevel < redact) { - errors.push({ - roomId, - errorMessage: `Missing power level for redactions: ${userLevel} < ${redact}`, - errorKind: ERROR_KIND_PERMISSION, - }); - } - if (userLevel < aclLevel) { - errors.push({ - roomId, - errorMessage: `Missing power level for server ACLs: ${userLevel} < ${aclLevel}`, - errorKind: ERROR_KIND_PERMISSION, - }); - } - - // Wants: Additional permissions - - for (const additionalPermission of additionalPermissions) { - const permLevel = plDefault(events[additionalPermission], stateDefault); - - if (userLevel < permLevel) { - errors.push({ - roomId, - errorMessage: `Missing power level for "${additionalPermission}" state events: ${userLevel} < ${permLevel}`, - errorKind: ERROR_KIND_PERMISSION, - }); - } - } - - // Otherwise OK - } catch (e) { - LogService.error("Mjolnir", extractRequestError(e)); - errors.push({ - roomId, - errorMessage: e.message || (e.body ? e.body.error : ''), - errorKind: ERROR_KIND_FATAL, - }); - } - - return errors; - } - - private requiredProtectionPermissions(): Set { - return new Set(this.enabledProtections.map((p) => p.requiredStatePermissions).flat()) - } - - /** - * @returns The protected rooms ordered by the most recently active first. - */ - public protectedRoomsByActivity(): string[] { - return this.protectedRoomActivityTracker.protectedRoomsByActivity(); - } - - /** - * Sync all the rooms with all the watched lists, banning and applying any changed ACLS. - * @param verbose Whether to report any errors to the management room. - */ - public async syncLists(verbose = true) { - for (const list of this.policyLists) { - const changes = await list.updateList(); - await this.printBanlistChanges(changes, list, true); - } - - let hadErrors = false; - const [aclErrors, banErrors] = await Promise.all([ - applyServerAcls(this.policyLists, this.protectedRoomsByActivity(), this), - applyUserBans(this.policyLists, this.protectedRoomsByActivity(), this) - ]); - const redactionErrors = await this.processRedactionQueue(); - hadErrors = hadErrors || await this.printActionResult(aclErrors, "Errors updating server ACLs:"); - hadErrors = hadErrors || await this.printActionResult(banErrors, "Errors updating member bans:"); - hadErrors = hadErrors || await this.printActionResult(redactionErrors, "Error updating redactions:"); - - if (!hadErrors && verbose) { - const html = `Done updating rooms - no errors`; - const text = "Done updating rooms - no errors"; - await this.client.sendMessage(this.managementRoomId, { - msgtype: "m.notice", - body: text, - format: "org.matrix.custom.html", - formatted_body: html, - }); - } - } - - /** - * Pulls any changes to the rules that are in a policy room and updates all protected rooms - * with those changes. Does not fail if there are errors updating the room, these are reported to the management room. - * @param policyList The `PolicyList` which we will check for changes and apply them to all protected rooms. - * @returns When all of the protected rooms have been updated. - */ - private async syncWithPolicyList(policyList: PolicyList): Promise { - const changes = await policyList.updateList(); - - let hadErrors = false; - const [aclErrors, banErrors] = await Promise.all([ - applyServerAcls(this.policyLists, this.protectedRoomsByActivity(), this), - applyUserBans(this.policyLists, this.protectedRoomsByActivity(), this) - ]); - const redactionErrors = await this.processRedactionQueue(); - hadErrors = hadErrors || await this.printActionResult(aclErrors, "Errors updating server ACLs:"); - hadErrors = hadErrors || await this.printActionResult(banErrors, "Errors updating member bans:"); - hadErrors = hadErrors || await this.printActionResult(redactionErrors, "Error updating redactions:"); - - if (!hadErrors) { - const html = `Done updating rooms - no errors`; - const text = "Done updating rooms - no errors"; - await this.client.sendMessage(this.managementRoomId, { - msgtype: "m.notice", - body: text, - format: "org.matrix.custom.html", - formatted_body: html, - }); - } - // This can fail if the change is very large and it is much less important than applying bans, so do it last. - await this.printBanlistChanges(changes, policyList, true); - } - - private async handleConsequences(protection: Protection, roomId: string, eventId: string, sender: string, consequences: Consequence[]) { - for (const consequence of consequences) { - try { - if (consequence.name === "alert") { - /* take no additional action, just print the below message to management room */ - } else if (consequence.name === "ban") { - await this.client.banUser(sender, roomId, "abuse detected"); - } else if (consequence.name === "redact") { - await this.client.redactEvent(roomId, eventId, "abuse detected"); - } else { - throw new Error(`unknown consequence ${consequence.name}`); - } - - let message = `protection ${protection.name} enacting` - + ` ${consequence.name}` - + ` against ${htmlEscape(sender)}` - + ` in ${htmlEscape(roomId)}` - + ` (reason: ${htmlEscape(consequence.reason)})`; - await this.client.sendMessage(this.managementRoomId, { - msgtype: "m.notice", - body: message, - [CONSEQUENCE_EVENT_DATA]: { - who: sender, - room: roomId, - types: [consequence.name], - } - }); - } catch (e) { - await this.logMessage(LogLevel.ERROR, "handleConsequences", `Failed to enact ${consequence.name} consequence: ${e}`); - } - } - } - private async handleEvent(roomId: string, event: any) { // Check for UISI errors if (roomId === this.managementRoomId) { @@ -1014,139 +583,11 @@ export class Mjolnir { } } - if (roomId in this.protectedRooms) { - if (event['sender'] === await this.client.getUserId()) return; // Ignore ourselves - - // Iterate all the enabled protections - for (const protection of this.enabledProtections) { - let consequences: Consequence[] | undefined = undefined; - try { - consequences = await protection.handleEvent(this, roomId, event); - } catch (e) { - const eventPermalink = Permalinks.forEvent(roomId, event['event_id']); - LogService.error("Mjolnir", "Error handling protection: " + protection.name); - LogService.error("Mjolnir", "Failed event: " + eventPermalink); - LogService.error("Mjolnir", extractRequestError(e)); - await this.client.sendNotice(this.managementRoomId, "There was an error processing an event through a protection - see log for details. Event: " + eventPermalink); - continue; - } - - if (consequences !== undefined) { - await this.handleConsequences(protection, roomId, event["event_id"], event["sender"], consequences); - } - } - - // Run the event handlers - we always run this after protections so that the protections - // can flag the event for redaction. - await this.unlistedUserRedactionHandler.handleEvent(roomId, event, this); - - if (event['type'] === 'm.room.power_levels' && event['state_key'] === '') { - // power levels were updated - recheck permissions - ErrorCache.resetError(roomId, ERROR_KIND_PERMISSION); - await this.logMessage(LogLevel.DEBUG, "Mjolnir", `Power levels changed in ${roomId} - checking permissions...`, roomId); - const errors = await this.verifyPermissionsIn(roomId); - const hadErrors = await this.printActionResult(errors); - if (!hadErrors) { - await this.logMessage(LogLevel.DEBUG, "Mjolnir", `All permissions look OK.`); - } - return; - } else if (event['type'] === "m.room.member") { - // The reason we have to apply bans on each member change is because - // we cannot eagerly ban users (that is to ban them when they have never been a member) - // as they can be force joined to a room they might not have known existed. - // Only apply bans and then redactions in the room we are currently looking at. - const banErrors = await applyUserBans(this.policyLists, [roomId], this); - const redactionErrors = await this.processRedactionQueue(roomId); - await this.printActionResult(banErrors); - await this.printActionResult(redactionErrors); - } + if (event.sender !== this.clientUserId) { + this.protectedRoomsTracker.handleEvent(roomId, event); } } - /** - * Print the changes to a banlist to the management room. - * @param changes A list of changes that have been made to a particular ban list. - * @param ignoreSelf Whether to exclude changes that have been made by Mjolnir. - * @returns true if the message was sent, false if it wasn't (because there there were no changes to report). - */ - private async printBanlistChanges(changes: ListRuleChange[], list: PolicyList, ignoreSelf = false): Promise { - if (ignoreSelf) { - const sender = await this.client.getUserId(); - changes = changes.filter(change => change.sender !== sender); - } - if (changes.length <= 0) return false; - - let html = ""; - let text = ""; - - const changesInfo = `updated with ${changes.length} ` + (changes.length === 1 ? 'change:' : 'changes:'); - const shortcodeInfo = list.listShortcode ? ` (shortcode: ${htmlEscape(list.listShortcode)})` : ''; - - html += `${htmlEscape(list.roomId)}${shortcodeInfo} ${changesInfo}
    `; - text += `${list.roomRef}${shortcodeInfo} ${changesInfo}:\n`; - - for (const change of changes) { - const rule = change.rule; - let ruleKind: string = rule.kind; - if (ruleKind === RULE_USER) { - ruleKind = 'user'; - } else if (ruleKind === RULE_SERVER) { - ruleKind = 'server'; - } else if (ruleKind === RULE_ROOM) { - ruleKind = 'room'; - } - html += `
  • ${change.changeType} ${htmlEscape(ruleKind)} (${htmlEscape(rule.recommendation ?? "")}): ${htmlEscape(rule.entity)} (${htmlEscape(rule.reason)})
  • `; - text += `* ${change.changeType} ${ruleKind} (${rule.recommendation}): ${rule.entity} (${rule.reason})\n`; - } - - const message = { - msgtype: "m.notice", - body: text, - format: "org.matrix.custom.html", - formatted_body: html, - }; - await this.client.sendMessage(this.managementRoomId, message); - return true; - } - - private async printActionResult(errors: RoomUpdateError[], title: string | null = null, logAnyways = false) { - if (errors.length <= 0) return false; - - if (!logAnyways) { - errors = errors.filter(e => ErrorCache.triggerError(e.roomId, e.errorKind)); - if (errors.length <= 0) { - LogService.warn("Mjolnir", "Multiple errors are happening, however they are muted. Please check the management room."); - return true; - } - } - - let html = ""; - let text = ""; - - const htmlTitle = title ? `${title}
    ` : ''; - const textTitle = title ? `${title}\n` : ''; - - html += `${htmlTitle}${errors.length} errors updating protected rooms!
      `; - text += `${textTitle}${errors.length} errors updating protected rooms!\n`; - const viaServers = [(new UserID(await this.client.getUserId())).domain]; - for (const error of errors) { - const alias = (await this.client.getPublishedAlias(error.roomId)) || error.roomId; - const url = Permalinks.forRoom(alias, viaServers); - html += `
    • ${alias} - ${error.errorMessage}
    • `; - text += `${url} - ${error.errorMessage}\n`; - } - html += "
    "; - - const message = { - msgtype: "m.notice", - body: text, - format: "org.matrix.custom.html", - formatted_body: html, - }; - await this.client.sendMessage(this.managementRoomId, message); - return true; - } - public async isSynapseAdmin(): Promise { try { const endpoint = `/_synapse/admin/v1/users/${await this.client.getUserId()}/admin`; @@ -1189,26 +630,4 @@ export class Mjolnir { return extractRequestError(e); } } - - public queueRedactUserMessagesIn(userId: string, roomId: string) { - this.eventRedactionQueue.add(new RedactUserInRoom(userId, roomId)); - } - - /** - * Process all queued redactions, this is usually called at the end of the sync process, - * after all users have been banned and ACLs applied. - * If a redaction cannot be processed, the redaction is skipped and removed from the queue. - * We then carry on processing the next redactions. - * @param roomId Limit processing to one room only, otherwise process redactions for all rooms. - * @returns The list of errors encountered, for reporting to the management room. - */ - public async processRedactionQueue(roomId?: string): Promise { - return await this.eventRedactionQueue.process(this, roomId); - } - - private async handleReport({ roomId, reporterId, event, reason }: { roomId: string, reporterId: string, event: any, reason?: string }) { - for (const protection of this.enabledProtections) { - await protection.handleReport(this, roomId, reporterId, event, reason); - } - } } diff --git a/src/ProtectedRooms.ts b/src/ProtectedRooms.ts new file mode 100644 index 0000000..fee4230 --- /dev/null +++ b/src/ProtectedRooms.ts @@ -0,0 +1,524 @@ +/* +Copyright 2019, 2022 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import { LogLevel, LogService, MatrixClient, MatrixGlob, Permalinks, UserID } from "matrix-bot-sdk"; +import { IConfig } from "./config"; +import ErrorCache, { ERROR_KIND_FATAL, ERROR_KIND_PERMISSION } from "./ErrorCache"; +import ManagementRoomOutput from "./ManagementRoomOutput"; +import { RULE_ROOM, RULE_SERVER, RULE_USER } from "./models/ListRule"; +import PolicyList, { ListRuleChange } from "./models/PolicyList"; +import { RoomUpdateError } from "./models/RoomUpdateError"; +import { ServerAcl } from "./models/ServerAcl"; +import { ProtectionManager } from "./protections/ProtectionManager"; +import { EventRedactionQueue, RedactUserInRoom } from "./queues/EventRedactionQueue"; +import { ProtectedRoomActivityTracker } from "./queues/ProtectedRoomActivityTracker"; +import { htmlEscape } from "./utils"; + +/** + * This class aims to synchronize `m.ban` rules in a set of policy lists with + * a set of rooms by applying member bans and server ACL to them. + * + * It is important to understand that the use of `m.ban` in the lists that `ProtectedRooms` watch + * are interpreted to be the final decision about whether to ban a user and are a synchronization tool. + * This is different to watching a community curated list to be informed about reputation information and then making + * some sort of decision and is not the purpose of this class (as of writing, Mjolnir does not have a way to do this, we want it to). + * The outcome of that decision process (which should take place in other components) + * will likely be whether or not to create an `m.ban` rule in a list watched by + * your protected rooms. + * + * It is also important not to tie this to the one group of rooms that a mjolnir may watch + * as in future we might want to borrow this class to represent a space https://github.com/matrix-org/mjolnir/issues/283. + */ +export class ProtectedRooms { + + private protectedRooms = new Set(); + + /** + * These are the `m.bans` we want to synchronize across this set of rooms. + */ + private policyLists: PolicyList[] = []; + + /** + * Tracks the rooms so that the most recently active rooms can be synchronized first. + */ + private protectedRoomActivityTracker: ProtectedRoomActivityTracker; + + /** + * This is a queue for redactions to process after mjolnir + * has finished applying ACL and bans when syncing. + */ + private readonly eventRedactionQueue = new EventRedactionQueue(); + + private readonly errorCache = new ErrorCache(); + + /** + * These are globs sourced from `config.automaticallyRedactForReasons` that are matched against the reason of an + * `m.ban` recommendation against a user. + * If a rule matches a user in a room, and a glob from here matches that rule's reason, then we will redact + * all of the messages from that user. + */ + private automaticRedactionReasons: MatrixGlob[] = []; + + /** + * Used to provide mutual exclusion when synchronizing rooms with the state of a policy list. + * This is because requests operating with rules from an older version of the list that are slow + * could race & give the room an inconsistent state. An example is if we add multiple m.policy.rule.server rules, + * which would cause several requests to a room to send a new m.room.server_acl event. + * These requests could finish in any order, which has left rooms with an inconsistent server_acl event + * until Mjolnir synchronises the room with its policy lists again, which can be in the region of hours. + */ + private aclChain: Promise = Promise.resolve(); + + constructor( + private readonly client: MatrixClient, + private readonly clientUserId: string, + private readonly managementRoomId: string, + private readonly managementRoomOutput: ManagementRoomOutput, + /** + * The protection manager is only used to verify the permissions + * that the protection manager requires are correct for this set of rooms. + * The protection manager is not really compatible with this abstraction yet + * because of a direct dependency on the protection manager in Mjolnir commands. + */ + private readonly protectionManager: ProtectionManager, + private readonly config: IConfig, + ) { + for (const reason of this.config.automaticallyRedactForReasons) { + this.automaticRedactionReasons.push(new MatrixGlob(reason.toLowerCase())); + } + + // Setup room activity watcher + this.protectedRoomActivityTracker = new ProtectedRoomActivityTracker(); + } + + /** + * Queue a user's messages in a room for redaction once we have stopped synchronizing bans + * over the protected rooms. + * + * @param userId The user whose messages we want to redact. + * @param roomId The room we want to redact them in. + */ + public redactUser(userId: string, roomId: string) { + this.eventRedactionQueue.add(new RedactUserInRoom(userId, roomId)); + } + + /** + * These are globs sourced from `config.automaticallyRedactForReasons` that are matched against the reason of an + * `m.ban` recommendation against a user. + * If a rule matches a user in a room, and a glob from here matches that rule's reason, then we will redact + * all of the messages from that user. + */ + public get automaticRedactGlobs(): Readonly { + return this.automaticRedactionReasons; + } + + public getProtectedRooms () { + return [...this.protectedRooms.keys()] + } + + public isProtectedRoom(roomId: string): boolean { + return this.protectedRooms.has(roomId); + } + + public watchList(policyList: PolicyList): void { + if (!this.policyLists.includes(policyList)) { + this.policyLists.push(policyList); + } + } + + public unwatchList(policyList: PolicyList): void { + this.policyLists = this.policyLists.filter(list => list.roomId !== policyList.roomId); + } + + /** + * Process all queued redactions, this is usually called at the end of the sync process, + * after all users have been banned and ACLs applied. + * If a redaction cannot be processed, the redaction is skipped and removed from the queue. + * We then carry on processing the next redactions. + * @param roomId Limit processing to one room only, otherwise process redactions for all rooms. + * @returns The list of errors encountered, for reporting to the management room. + */ + public async processRedactionQueue(roomId?: string): Promise { + return await this.eventRedactionQueue.process(this.client, this.managementRoomOutput, roomId); + } + + /** + * @returns The protected rooms ordered by the most recently active first. + */ + public protectedRoomsByActivity(): string[] { + return this.protectedRoomActivityTracker.protectedRoomsByActivity(); + } + + public async handleEvent(roomId: string, event: any) { + if (event['sender'] === this.clientUserId) { + throw new TypeError("`ProtectedRooms::handleEvent` should not be used to inform about events sent by mjolnir."); + } + this.protectedRoomActivityTracker.handleEvent(roomId, event); + if (event['type'] === 'm.room.power_levels' && event['state_key'] === '') { + // power levels were updated - recheck permissions + this.errorCache.resetError(roomId, ERROR_KIND_PERMISSION); + await this.managementRoomOutput.logMessage(LogLevel.DEBUG, "Mjolnir", `Power levels changed in ${roomId} - checking permissions...`, roomId); + const errors = await this.protectionManager.verifyPermissionsIn(roomId); + const hadErrors = await this.printActionResult(errors); + if (!hadErrors) { + await this.managementRoomOutput.logMessage(LogLevel.DEBUG, "Mjolnir", `All permissions look OK.`); + } + return; + } else if (event['type'] === "m.room.member") { + // The reason we have to apply bans on each member change is because + // we cannot eagerly ban users (that is to ban them when they have never been a member) + // as they can be force joined to a room they might not have known existed. + // Only apply bans and then redactions in the room we are currently looking at. + const banErrors = await this.applyUserBans(this.policyLists, [roomId]); + const redactionErrors = await this.processRedactionQueue(roomId); + await this.printActionResult(banErrors); + await this.printActionResult(redactionErrors); + } + } + + /** + * Sync all the rooms with all the watched lists, banning and applying any changed ACLS. + * @param verbose Whether to report any errors to the management room. + */ + public async syncLists(verbose = true) { + for (const list of this.policyLists) { + const changes = await list.updateList(); + await this.printBanlistChanges(changes, list, true); + } + + let hadErrors = false; + const [aclErrors, banErrors] = await Promise.all([ + this.applyServerAcls(this.policyLists, this.protectedRoomsByActivity()), + this.applyUserBans(this.policyLists, this.protectedRoomsByActivity()) + ]); + const redactionErrors = await this.processRedactionQueue(); + hadErrors = hadErrors || await this.printActionResult(aclErrors, "Errors updating server ACLs:"); + hadErrors = hadErrors || await this.printActionResult(banErrors, "Errors updating member bans:"); + hadErrors = hadErrors || await this.printActionResult(redactionErrors, "Error updating redactions:"); + + if (!hadErrors && verbose) { + const html = `Done updating rooms - no errors`; + const text = "Done updating rooms - no errors"; + await this.client.sendMessage(this.managementRoomId, { + msgtype: "m.notice", + body: text, + format: "org.matrix.custom.html", + formatted_body: html, + }); + } + } + + public async addProtectedRoom(roomId: string): Promise { + if (this.protectedRooms.has(roomId)) { + // we need to protect ourselves form syncing all the lists unnecessarily + // as Mjolnir does call this method repeatedly. + return; + } + this.protectedRooms.add(roomId); + this.protectedRoomActivityTracker.addProtectedRoom(roomId); + await this.syncLists(this.config.verboseLogging); + } + + public removeProtectedRoom(roomId: string): void { + this.protectedRoomActivityTracker.removeProtectedRoom(roomId); + this.protectedRooms.delete(roomId); + } + + /** + * Pulls any changes to the rules that are in a policy room and updates all protected rooms + * with those changes. Does not fail if there are errors updating the room, these are reported to the management room. + * @param policyList The `PolicyList` which we will check for changes and apply them to all protected rooms. + * @returns When all of the protected rooms have been updated. + */ + public async syncWithPolicyList(policyList: PolicyList): Promise { + // this bit can move away into a listener. + const changes = await policyList.updateList(); + + let hadErrors = false; + const [aclErrors, banErrors] = await Promise.all([ + this.applyServerAcls(this.policyLists, this.protectedRoomsByActivity()), + this.applyUserBans(this.policyLists, this.protectedRoomsByActivity()) + ]); + const redactionErrors = await this.processRedactionQueue(); + hadErrors = hadErrors || await this.printActionResult(aclErrors, "Errors updating server ACLs:"); + hadErrors = hadErrors || await this.printActionResult(banErrors, "Errors updating member bans:"); + hadErrors = hadErrors || await this.printActionResult(redactionErrors, "Error updating redactions:"); + + if (!hadErrors) { + const html = `Done updating rooms - no errors`; + const text = "Done updating rooms - no errors"; + await this.client.sendMessage(this.managementRoomId, { + msgtype: "m.notice", + body: text, + format: "org.matrix.custom.html", + formatted_body: html, + }); + } + // This can fail if the change is very large and it is much less important than applying bans, so do it last. + await this.printBanlistChanges(changes, policyList, true); + } + + /** + * Applies the server ACLs represented by the ban lists to the provided rooms, returning the + * room IDs that could not be updated and their error. + * Does not update the banLists before taking their rules to build the server ACL. + * @param {PolicyList[]} lists The lists to construct ACLs from. + * @param {string[]} roomIds The room IDs to apply the ACLs in. + * @param {Mjolnir} mjolnir The Mjolnir client to apply the ACLs with. + */ + private async applyServerAcls(lists: PolicyList[], roomIds: string[]): Promise { + // we need to provide mutual exclusion so that we do not have requests updating the m.room.server_acl event + // finish out of order and therefore leave the room out of sync with the policy lists. + return new Promise((resolve, reject) => { + this.aclChain = this.aclChain + .then(() => this._applyServerAcls(lists, roomIds)) + .then(resolve, reject); + }); + } + + private async _applyServerAcls(lists: PolicyList[], roomIds: string[]): Promise { + const serverName: string = new UserID(await this.client.getUserId()).domain; + + // Construct a server ACL first + const acl = new ServerAcl(serverName).denyIpAddresses().allowServer("*"); + for (const list of lists) { + for (const rule of list.serverRules) { + acl.denyServer(rule.entity); + } + } + + const finalAcl = acl.safeAclContent(); + + if (finalAcl.deny.length !== acl.literalAclContent().deny.length) { + this.managementRoomOutput.logMessage(LogLevel.WARN, "ApplyAcl", `Mjölnir has detected and removed an ACL that would exclude itself. Please check the ACL lists.`); + } + + if (this.config.verboseLogging) { + // We specifically use sendNotice to avoid having to escape HTML + await this.client.sendNotice(this.managementRoomId, `Constructed server ACL:\n${JSON.stringify(finalAcl, null, 2)}`); + } + + const errors: RoomUpdateError[] = []; + for (const roomId of roomIds) { + try { + await this.managementRoomOutput.logMessage(LogLevel.DEBUG, "ApplyAcl", `Checking ACLs for ${roomId}`, roomId); + + try { + const currentAcl = await this.client.getRoomStateEvent(roomId, "m.room.server_acl", ""); + if (acl.matches(currentAcl)) { + await this.managementRoomOutput.logMessage(LogLevel.DEBUG, "ApplyAcl", `Skipping ACLs for ${roomId} because they are already the right ones`, roomId); + continue; + } + } catch (e) { + // ignore - assume no ACL + } + + // We specifically use sendNotice to avoid having to escape HTML + await this.managementRoomOutput.logMessage(LogLevel.DEBUG, "ApplyAcl", `Applying ACL in ${roomId}`, roomId); + + if (!this.config.noop) { + await this.client.sendStateEvent(roomId, "m.room.server_acl", "", finalAcl); + } else { + await this.managementRoomOutput.logMessage(LogLevel.WARN, "ApplyAcl", `Tried to apply ACL in ${roomId} but Mjolnir is running in no-op mode`, roomId); + } + } catch (e) { + const message = e.message || (e.body ? e.body.error : ''); + const kind = message && message.includes("You don't have permission to post that to the room") ? ERROR_KIND_PERMISSION : ERROR_KIND_FATAL; + errors.push({ roomId, errorMessage: message, errorKind: kind }); + } + } + return errors; + } + + /** + * Applies the member bans represented by the ban lists to the provided rooms, returning the + * room IDs that could not be updated and their error. + * @param {PolicyList[]} lists The lists to determine bans from. + * @param {string[]} roomIds The room IDs to apply the bans in. + * @param {Mjolnir} mjolnir The Mjolnir client to apply the bans with. + */ + private async applyUserBans(lists: PolicyList[], roomIds: string[]): Promise { + // We can only ban people who are not already banned, and who match the rules. + const errors: RoomUpdateError[] = []; + for (const roomId of roomIds) { + try { + // We specifically use sendNotice to avoid having to escape HTML + await this.managementRoomOutput.logMessage(LogLevel.DEBUG, "ApplyBan", `Updating member bans in ${roomId}`, roomId); + + let members: { userId: string, membership: string }[]; + + if (this.config.fasterMembershipChecks) { + const memberIds = await this.client.getJoinedRoomMembers(roomId); + members = memberIds.map(u => { + return { userId: u, membership: "join" }; + }); + } else { + const state = await this.client.getRoomState(roomId); + members = state.filter(s => s['type'] === 'm.room.member' && !!s['state_key']).map(s => { + return { userId: s['state_key'], membership: s['content'] ? s['content']['membership'] : 'leave' }; + }); + } + + for (const member of members) { + if (member.membership === 'ban') { + continue; // user already banned + } + + let banned = false; + for (const list of lists) { + for (const userRule of list.userRules) { + if (userRule.isMatch(member.userId)) { + // User needs to be banned + + // We specifically use sendNotice to avoid having to escape HTML + await this.managementRoomOutput.logMessage(LogLevel.INFO, "ApplyBan", `Banning ${member.userId} in ${roomId} for: ${userRule.reason}`, roomId); + + if (!this.config.noop) { + await this.client.banUser(member.userId, roomId, userRule.reason); + if (this.automaticRedactGlobs.find(g => g.test(userRule.reason.toLowerCase()))) { + this.redactUser(member.userId, roomId); + } + } else { + await this.managementRoomOutput.logMessage(LogLevel.WARN, "ApplyBan", `Tried to ban ${member.userId} in ${roomId} but Mjolnir is running in no-op mode`, roomId); + } + + banned = true; + break; + } + } + if (banned) break; + } + } + } catch (e) { + const message = e.message || (e.body ? e.body.error : ''); + errors.push({ + roomId, + errorMessage: message, + errorKind: message && message.includes("You don't have permission to ban") ? ERROR_KIND_PERMISSION : ERROR_KIND_FATAL, + }); + } + } + + return errors; + } + + /** + * Print the changes to a banlist to the management room. + * @param changes A list of changes that have been made to a particular ban list. + * @param ignoreSelf Whether to exclude changes that have been made by Mjolnir. + * @returns true if the message was sent, false if it wasn't (because there there were no changes to report). + */ + private async printBanlistChanges(changes: ListRuleChange[], list: PolicyList, ignoreSelf = false): Promise { + if (ignoreSelf) { + const sender = await this.client.getUserId(); + changes = changes.filter(change => change.sender !== sender); + } + if (changes.length <= 0) return false; + + let html = ""; + let text = ""; + + const changesInfo = `updated with ${changes.length} ` + (changes.length === 1 ? 'change:' : 'changes:'); + const shortcodeInfo = list.listShortcode ? ` (shortcode: ${htmlEscape(list.listShortcode)})` : ''; + + html += `${htmlEscape(list.roomId)}${shortcodeInfo} ${changesInfo}
      `; + text += `${list.roomRef}${shortcodeInfo} ${changesInfo}:\n`; + + for (const change of changes) { + const rule = change.rule; + let ruleKind: string = rule.kind; + if (ruleKind === RULE_USER) { + ruleKind = 'user'; + } else if (ruleKind === RULE_SERVER) { + ruleKind = 'server'; + } else if (ruleKind === RULE_ROOM) { + ruleKind = 'room'; + } + html += `
    • ${change.changeType} ${htmlEscape(ruleKind)} (${htmlEscape(rule.recommendation ?? "")}): ${htmlEscape(rule.entity)} (${htmlEscape(rule.reason)})
    • `; + text += `* ${change.changeType} ${ruleKind} (${rule.recommendation}): ${rule.entity} (${rule.reason})\n`; + } + + const message = { + msgtype: "m.notice", + body: text, + format: "org.matrix.custom.html", + formatted_body: html, + }; + await this.client.sendMessage(this.managementRoomId, message); + return true; + } + + private async printActionResult(errors: RoomUpdateError[], title: string | null = null, logAnyways = false) { + if (errors.length <= 0) return false; + + if (!logAnyways) { + errors = errors.filter(e => this.errorCache.triggerError(e.roomId, e.errorKind)); + if (errors.length <= 0) { + LogService.warn("Mjolnir", "Multiple errors are happening, however they are muted. Please check the management room."); + return true; + } + } + + let html = ""; + let text = ""; + + const htmlTitle = title ? `${title}
      ` : ''; + const textTitle = title ? `${title}\n` : ''; + + html += `${htmlTitle}${errors.length} errors updating protected rooms!
        `; + text += `${textTitle}${errors.length} errors updating protected rooms!\n`; + const viaServers = [(new UserID(await this.client.getUserId())).domain]; + for (const error of errors) { + const alias = (await this.client.getPublishedAlias(error.roomId)) || error.roomId; + const url = Permalinks.forRoom(alias, viaServers); + html += `
      • ${alias} - ${error.errorMessage}
      • `; + text += `${url} - ${error.errorMessage}\n`; + } + html += "
      "; + + const message = { + msgtype: "m.notice", + body: text, + format: "org.matrix.custom.html", + formatted_body: html, + }; + await this.client.sendMessage(this.managementRoomId, message); + return true; + } + + public requiredProtectionPermissions() { + throw new TypeError("Unimplemented, need to put protections into here too.") + } + + public async verifyPermissions(verbose = true, printRegardless = false) { + const errors: RoomUpdateError[] = []; + for (const roomId of this.protectedRooms) { + errors.push(...(await this.protectionManager.verifyPermissionsIn(roomId))); + } + + const hadErrors = await this.printActionResult(errors, "Permission errors in protected rooms:", printRegardless); + if (!hadErrors && verbose) { + const html = `All permissions look OK.`; + const text = "All permissions look OK."; + await this.client.sendMessage(this.managementRoomId, { + msgtype: "m.notice", + body: text, + format: "org.matrix.custom.html", + formatted_body: html, + }); + } + } +} diff --git a/src/actions/ApplyAcl.ts b/src/actions/ApplyAcl.ts deleted file mode 100644 index 917be55..0000000 --- a/src/actions/ApplyAcl.ts +++ /dev/null @@ -1,95 +0,0 @@ -/* -Copyright 2019, 2020 The Matrix.org Foundation C.I.C. - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -import PolicyList from "../models/PolicyList"; -import { ServerAcl } from "../models/ServerAcl"; -import { RoomUpdateError } from "../models/RoomUpdateError"; -import { Mjolnir } from "../Mjolnir"; -import { LogLevel, UserID } from "matrix-bot-sdk"; -import { ERROR_KIND_FATAL, ERROR_KIND_PERMISSION } from "../ErrorCache"; - -/** - * Applies the server ACLs represented by the ban lists to the provided rooms, returning the - * room IDs that could not be updated and their error. - * Does not update the banLists before taking their rules to build the server ACL. - * @param {PolicyList[]} lists The lists to construct ACLs from. - * @param {string[]} roomIds The room IDs to apply the ACLs in. - * @param {Mjolnir} mjolnir The Mjolnir client to apply the ACLs with. - */ -export async function applyServerAcls(lists: PolicyList[], roomIds: string[], mjolnir: Mjolnir): Promise { - // we need to provide mutual exclusion so that we do not have requests updating the m.room.server_acl event - // finish out of order and therefore leave the room out of sync with the policy lists. - return new Promise((resolve, reject) => { - mjolnir.aclChain = mjolnir.aclChain - .then(() => _applyServerAcls(lists, roomIds, mjolnir)) - .then(resolve, reject); - }); -} - -async function _applyServerAcls(lists: PolicyList[], roomIds: string[], mjolnir: Mjolnir): Promise { - const serverName: string = new UserID(await mjolnir.client.getUserId()).domain; - - // Construct a server ACL first - const acl = new ServerAcl(serverName).denyIpAddresses().allowServer("*"); - for (const list of lists) { - for (const rule of list.serverRules) { - acl.denyServer(rule.entity); - } - } - - const finalAcl = acl.safeAclContent(); - - if (finalAcl.deny.length !== acl.literalAclContent().deny.length) { - mjolnir.logMessage(LogLevel.WARN, "ApplyAcl", `Mjölnir has detected and removed an ACL that would exclude itself. Please check the ACL lists.`); - } - - if (mjolnir.config.verboseLogging) { - // We specifically use sendNotice to avoid having to escape HTML - await mjolnir.client.sendNotice(mjolnir.managementRoomId, `Constructed server ACL:\n${JSON.stringify(finalAcl, null, 2)}`); - } - - const errors: RoomUpdateError[] = []; - for (const roomId of roomIds) { - try { - await mjolnir.logMessage(LogLevel.DEBUG, "ApplyAcl", `Checking ACLs for ${roomId}`, roomId); - - try { - const currentAcl = await mjolnir.client.getRoomStateEvent(roomId, "m.room.server_acl", ""); - if (acl.matches(currentAcl)) { - await mjolnir.logMessage(LogLevel.DEBUG, "ApplyAcl", `Skipping ACLs for ${roomId} because they are already the right ones`, roomId); - continue; - } - } catch (e) { - // ignore - assume no ACL - } - - // We specifically use sendNotice to avoid having to escape HTML - await mjolnir.logMessage(LogLevel.DEBUG, "ApplyAcl", `Applying ACL in ${roomId}`, roomId); - - if (!mjolnir.config.noop) { - await mjolnir.client.sendStateEvent(roomId, "m.room.server_acl", "", finalAcl); - } else { - await mjolnir.logMessage(LogLevel.WARN, "ApplyAcl", `Tried to apply ACL in ${roomId} but Mjolnir is running in no-op mode`, roomId); - } - } catch (e) { - const message = e.message || (e.body ? e.body.error : ''); - const kind = message && message.includes("You don't have permission to post that to the room") ? ERROR_KIND_PERMISSION : ERROR_KIND_FATAL; - errors.push({ roomId, errorMessage: message, errorKind: kind }); - } - } - - return errors; -} diff --git a/src/actions/ApplyBan.ts b/src/actions/ApplyBan.ts deleted file mode 100644 index e842cc6..0000000 --- a/src/actions/ApplyBan.ts +++ /dev/null @@ -1,93 +0,0 @@ -/* -Copyright 2019, 2020 The Matrix.org Foundation C.I.C. - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -import PolicyList from "../models/PolicyList"; -import { RoomUpdateError } from "../models/RoomUpdateError"; -import { Mjolnir } from "../Mjolnir"; -import { LogLevel } from "matrix-bot-sdk"; -import { ERROR_KIND_FATAL, ERROR_KIND_PERMISSION } from "../ErrorCache"; - -/** - * Applies the member bans represented by the ban lists to the provided rooms, returning the - * room IDs that could not be updated and their error. - * @param {PolicyList[]} lists The lists to determine bans from. - * @param {string[]} roomIds The room IDs to apply the bans in. - * @param {Mjolnir} mjolnir The Mjolnir client to apply the bans with. - */ -export async function applyUserBans(lists: PolicyList[], roomIds: string[], mjolnir: Mjolnir): Promise { - // We can only ban people who are not already banned, and who match the rules. - const errors: RoomUpdateError[] = []; - for (const roomId of roomIds) { - try { - // We specifically use sendNotice to avoid having to escape HTML - await mjolnir.logMessage(LogLevel.DEBUG, "ApplyBan", `Updating member bans in ${roomId}`, roomId); - - let members: { userId: string, membership: string }[]; - - if (mjolnir.config.fasterMembershipChecks) { - const memberIds = await mjolnir.client.getJoinedRoomMembers(roomId); - members = memberIds.map(u => { - return { userId: u, membership: "join" }; - }); - } else { - const state = await mjolnir.client.getRoomState(roomId); - members = state.filter(s => s['type'] === 'm.room.member' && !!s['state_key']).map(s => { - return { userId: s['state_key'], membership: s['content'] ? s['content']['membership'] : 'leave' }; - }); - } - - for (const member of members) { - if (member.membership === 'ban') { - continue; // user already banned - } - - let banned = false; - for (const list of lists) { - for (const userRule of list.userRules) { - if (userRule.isMatch(member.userId)) { - // User needs to be banned - - // We specifically use sendNotice to avoid having to escape HTML - await mjolnir.logMessage(LogLevel.INFO, "ApplyBan", `Banning ${member.userId} in ${roomId} for: ${userRule.reason}`, roomId); - - if (!mjolnir.config.noop) { - await mjolnir.client.banUser(member.userId, roomId, userRule.reason); - if (mjolnir.automaticRedactGlobs.find(g => g.test(userRule.reason.toLowerCase()))) { - mjolnir.queueRedactUserMessagesIn(member.userId, roomId); - } - } else { - await mjolnir.logMessage(LogLevel.WARN, "ApplyBan", `Tried to ban ${member.userId} in ${roomId} but Mjolnir is running in no-op mode`, roomId); - } - - banned = true; - break; - } - } - if (banned) break; - } - } - } catch (e) { - const message = e.message || (e.body ? e.body.error : ''); - errors.push({ - roomId, - errorMessage: message, - errorKind: message && message.includes("You don't have permission to ban") ? ERROR_KIND_PERMISSION : ERROR_KIND_FATAL, - }); - } - } - - return errors; -} diff --git a/src/commands/AddRemoveProtectedRoomsCommand.ts b/src/commands/AddRemoveProtectedRoomsCommand.ts index d8d5832..978b9c8 100644 --- a/src/commands/AddRemoveProtectedRoomsCommand.ts +++ b/src/commands/AddRemoveProtectedRoomsCommand.ts @@ -32,7 +32,7 @@ export async function execRemoveProtectedRoom(roomId: string, event: any, mjolni await mjolnir.client.leaveRoom(protectedRoomId); } catch (e) { LogService.warn("AddRemoveProtectedRoomsCommand", extractRequestError(e)); - await mjolnir.logMessage(LogLevel.WARN, "AddRemoveProtectedRoomsCommand", `Failed to leave ${protectedRoomId} - the room is no longer being protected, but the bot could not leave`, protectedRoomId); + await mjolnir.managementRoomOutput.logMessage(LogLevel.WARN, "AddRemoveProtectedRoomsCommand", `Failed to leave ${protectedRoomId} - the room is no longer being protected, but the bot could not leave`, protectedRoomId); } await mjolnir.client.unstableApis.addReactionToEvent(roomId, event['event_id'], '✅'); } diff --git a/src/commands/KickCommand.ts b/src/commands/KickCommand.ts index dde837d..8a5c85e 100644 --- a/src/commands/KickCommand.ts +++ b/src/commands/KickCommand.ts @@ -22,7 +22,7 @@ export async function execKickCommand(roomId: string, event: any, mjolnir: Mjoln let force = false; const glob = parts[2]; - let rooms = [...Object.keys(mjolnir.protectedRooms)]; + let rooms = mjolnir.protectedRoomsTracker.getProtectedRooms(); if (parts[parts.length - 1] === "--force") { force = true; @@ -57,7 +57,7 @@ export async function execKickCommand(roomId: string, event: any, mjolnir: Mjoln const victim = member.membershipFor; if (kickRule.test(victim)) { - await mjolnir.logMessage(LogLevel.DEBUG, "KickCommand", `Removing ${victim} in ${protectedRoomId}`, protectedRoomId); + await mjolnir.managementRoomOutput.logMessage(LogLevel.DEBUG, "KickCommand", `Removing ${victim} in ${protectedRoomId}`, protectedRoomId); if (!mjolnir.config.noop) { try { @@ -65,10 +65,10 @@ export async function execKickCommand(roomId: string, event: any, mjolnir: Mjoln return mjolnir.client.kickUser(victim, protectedRoomId, reason); }); } catch (e) { - await mjolnir.logMessage(LogLevel.WARN, "KickCommand", `An error happened while trying to kick ${victim}: ${e}`); + await mjolnir.managementRoomOutput.logMessage(LogLevel.WARN, "KickCommand", `An error happened while trying to kick ${victim}: ${e}`); } } else { - await mjolnir.logMessage(LogLevel.WARN, "KickCommand", `Tried to kick ${victim} in ${protectedRoomId} but the bot is running in no-op mode.`, protectedRoomId); + await mjolnir.managementRoomOutput.logMessage(LogLevel.WARN, "KickCommand", `Tried to kick ${victim} in ${protectedRoomId} but the bot is running in no-op mode.`, protectedRoomId); } } } diff --git a/src/commands/ListProtectedRoomsCommand.ts b/src/commands/ListProtectedRoomsCommand.ts index abd6e15..66574ea 100644 --- a/src/commands/ListProtectedRoomsCommand.ts +++ b/src/commands/ListProtectedRoomsCommand.ts @@ -15,18 +15,19 @@ limitations under the License. */ import { Mjolnir } from "../Mjolnir"; -import { RichReply } from "matrix-bot-sdk"; +import { Permalinks, RichReply } from "matrix-bot-sdk"; // !mjolnir rooms export async function execListProtectedRooms(roomId: string, event: any, mjolnir: Mjolnir) { - let html = `Protected rooms (${Object.keys(mjolnir.protectedRooms).length}):
        `; - let text = `Protected rooms (${Object.keys(mjolnir.protectedRooms).length}):\n`; + const rooms = mjolnir.protectedRoomsTracker.getProtectedRooms(); + let html = `Protected rooms (${rooms.length}):
          `; + let text = `Protected rooms (${rooms.length}):\n`; let hasRooms = false; - for (const protectedRoomId in mjolnir.protectedRooms) { + for (const protectedRoomId in rooms) { hasRooms = true; - const roomUrl = mjolnir.protectedRooms[protectedRoomId]; + const roomUrl = Permalinks.forRoom(protectedRoomId); html += `
        • ${protectedRoomId}
        • `; text += `* ${roomUrl}\n`; } diff --git a/src/commands/PermissionCheckCommand.ts b/src/commands/PermissionCheckCommand.ts index d3459f1..a90983d 100644 --- a/src/commands/PermissionCheckCommand.ts +++ b/src/commands/PermissionCheckCommand.ts @@ -18,5 +18,5 @@ import { Mjolnir } from "../Mjolnir"; // !mjolnir verify export async function execPermissionCheckCommand(roomId: string, event: any, mjolnir: Mjolnir) { - return mjolnir.verifyPermissions(true, true); + return mjolnir.protectedRoomsTracker.verifyPermissions(true, true); } diff --git a/src/commands/ProtectionsCommands.ts b/src/commands/ProtectionsCommands.ts index 99b3dc4..8df7ee9 100644 --- a/src/commands/ProtectionsCommands.ts +++ b/src/commands/ProtectionsCommands.ts @@ -22,7 +22,7 @@ import { isListSetting } from "../protections/ProtectionSettings"; // !mjolnir enable export async function execEnableProtection(roomId: string, event: any, mjolnir: Mjolnir, parts: string[]) { try { - await mjolnir.enableProtection(parts[2]); + await mjolnir.protectionManager.enableProtection(parts[2]); await mjolnir.client.unstableApis.addReactionToEvent(roomId, event['event_id'], '✅'); } catch (e) { LogService.error("ProtectionsCommands", extractRequestError(e)); @@ -50,8 +50,8 @@ enum ConfigAction { */ async function _execConfigChangeProtection(mjolnir: Mjolnir, parts: string[], action: ConfigAction): Promise { const [protectionName, ...settingParts] = parts[0].split("."); - const protection = mjolnir.protections.get(protectionName); - if (protection === undefined) { + const protection = mjolnir.protectionManager.getProtection(protectionName); + if (!protection) { return `Unknown protection ${protectionName}`; } @@ -83,7 +83,7 @@ async function _execConfigChangeProtection(mjolnir: Mjolnir, parts: string[], ac } try { - await mjolnir.setProtectionSettings(protectionName, { [settingName]: value }); + await mjolnir.protectionManager.setProtectionSettings(protectionName, { [settingName]: value }); } catch (e) { return `Failed to set setting: ${e.message}`; } @@ -139,7 +139,7 @@ export async function execConfigRemoveProtection(roomId: string, event: any, mjo * !mjolnir get [protection name] */ export async function execConfigGetProtection(roomId: string, event: any, mjolnir: Mjolnir, parts: string[]) { - let pickProtections = Object.keys(mjolnir.protections); + let pickProtections = Object.keys(mjolnir.protectionManager.protections); if (parts.length < 3) { // no specific protectionName provided, show all of them. @@ -163,7 +163,7 @@ export async function execConfigGetProtection(roomId: string, event: any, mjolni let anySettings = false; for (const protectionName of pickProtections) { - const protectionSettings = mjolnir.protections.get(protectionName)?.settings ?? {}; + const protectionSettings = mjolnir.protectionManager.getProtection(protectionName)?.settings ?? {}; if (Object.keys(protectionSettings).length === 0) { continue; @@ -196,18 +196,18 @@ export async function execConfigGetProtection(roomId: string, event: any, mjolni // !mjolnir disable export async function execDisableProtection(roomId: string, event: any, mjolnir: Mjolnir, parts: string[]) { - await mjolnir.disableProtection(parts[2]); + await mjolnir.protectionManager.disableProtection(parts[2]); await mjolnir.client.unstableApis.addReactionToEvent(roomId, event['event_id'], '✅'); } // !mjolnir protections export async function execListProtections(roomId: string, event: any, mjolnir: Mjolnir, parts: string[]) { - const enabledProtections = mjolnir.enabledProtections.map(p => p.name); + const enabledProtections = mjolnir.protectionManager.enabledProtections.map(p => p.name); let html = "Available protections:
            "; let text = "Available protections:\n"; - for (const [protectionName, protection] of mjolnir.protections) { + for (const [protectionName, protection] of mjolnir.protectionManager.protections) { const emoji = enabledProtections.includes(protectionName) ? '🟢 (enabled)' : '🔴 (disabled)'; html += `
          • ${emoji} ${protectionName} - ${protection.description}
          • `; text += `* ${emoji} ${protectionName} - ${protection.description}\n`; diff --git a/src/commands/RedactCommand.ts b/src/commands/RedactCommand.ts index bfc980e..f809a60 100644 --- a/src/commands/RedactCommand.ts +++ b/src/commands/RedactCommand.ts @@ -45,8 +45,8 @@ export async function execRedactCommand(roomId: string, event: any, mjolnir: Mjo return; } - const targetRoomIds = roomAlias ? [roomAlias] : Object.keys(mjolnir.protectedRooms); - await redactUserMessagesIn(mjolnir, userId, targetRoomIds, limit); + const targetRoomIds = roomAlias ? [roomAlias] : mjolnir.protectedRoomsTracker.getProtectedRooms(); + await redactUserMessagesIn(mjolnir.client, mjolnir.managementRoomOutput, userId, targetRoomIds, limit); await mjolnir.client.unstableApis.addReactionToEvent(roomId, event['event_id'], '✅'); await mjolnir.client.redactEvent(roomId, processingReactionId, 'done processing'); diff --git a/src/commands/SetPowerLevelCommand.ts b/src/commands/SetPowerLevelCommand.ts index 5c46f49..58553eb 100644 --- a/src/commands/SetPowerLevelCommand.ts +++ b/src/commands/SetPowerLevelCommand.ts @@ -23,14 +23,14 @@ export async function execSetPowerLevelCommand(roomId: string, event: any, mjoln const level = Math.round(Number(parts[3])); const inRoom = parts[4]; - let targetRooms = inRoom ? [await mjolnir.client.resolveRoom(inRoom)] : Object.keys(mjolnir.protectedRooms); + let targetRooms = inRoom ? [await mjolnir.client.resolveRoom(inRoom)] : mjolnir.protectedRoomsTracker.getProtectedRooms(); for (const targetRoomId of targetRooms) { try { await mjolnir.client.setUserPowerLevel(victim, targetRoomId, level); } catch (e) { const message = e.message || (e.body ? e.body.error : ''); - await mjolnir.logMessage(LogLevel.ERROR, "SetPowerLevelCommand", `Failed to set power level of ${victim} to ${level} in ${targetRoomId}: ${message}`, targetRoomId); + await mjolnir.managementRoomOutput.logMessage(LogLevel.ERROR, "SetPowerLevelCommand", `Failed to set power level of ${victim} to ${level} in ${targetRoomId}: ${message}`, targetRoomId); LogService.error("SetPowerLevelCommand", extractRequestError(e)); } } diff --git a/src/commands/SinceCommand.ts b/src/commands/SinceCommand.ts index 879a42e..daced1e 100644 --- a/src/commands/SinceCommand.ts +++ b/src/commands/SinceCommand.ts @@ -100,7 +100,7 @@ export async function execSinceCommand(destinationRoomId: string, event: any, mj let result = await execSinceCommandAux(destinationRoomId, event, mjolnir, tokens); if ("error" in result) { mjolnir.client.unstableApis.addReactionToEvent(destinationRoomId, event['event_id'], '❌'); - mjolnir.logMessage(LogLevel.WARN, "SinceCommand", result.error); + mjolnir.managementRoomOutput.logMessage(LogLevel.WARN, "SinceCommand", result.error); const reply = RichReply.createFor(destinationRoomId, event, result.error, htmlEscape(result.error)); reply["msgtype"] = "m.notice"; /* no need to await */ mjolnir.client.sendMessage(destinationRoomId, reply); @@ -185,6 +185,7 @@ async function execSinceCommandAux(destinationRoomId: string, event: any, mjolni // Now list affected rooms. const rooms: Set = new Set(); let reasonParts: string[] | undefined; + const protectedRooms = new Set(mjolnir.protectedRoomsTracker.getProtectedRooms()); for (let token of optionalTokens) { const maybeArg = getTokenAsString(reasonParts ? "[reason]" : "[room]", token); if ("error" in maybeArg) { @@ -194,14 +195,14 @@ async function execSinceCommandAux(destinationRoomId: string, event: any, mjolni if (!reasonParts) { // If we haven't reached the reason yet, attempt to use `maybeRoom` as a room. if (maybeRoom === "*") { - for (let roomId of Object.keys(mjolnir.protectedRooms)) { + for (let roomId of mjolnir.protectedRoomsTracker.getProtectedRooms()) { rooms.add(roomId); } continue; } else if (maybeRoom.startsWith("#") || maybeRoom.startsWith("!")) { const roomId = await mjolnir.client.resolveRoom(maybeRoom); - if (!(roomId in mjolnir.protectedRooms)) { - return mjolnir.logMessage(LogLevel.WARN, "SinceCommand", `This room is not protected: ${htmlEscape(roomId)}.`); + if (!protectedRooms.has(roomId)) { + return mjolnir.managementRoomOutput.logMessage(LogLevel.WARN, "SinceCommand", `This room is not protected: ${htmlEscape(roomId)}.`); } rooms.add(roomId); continue; diff --git a/src/commands/StatusCommand.ts b/src/commands/StatusCommand.ts index bcaa66a..6e597ec 100644 --- a/src/commands/StatusCommand.ts +++ b/src/commands/StatusCommand.ts @@ -91,7 +91,7 @@ async function showMjolnirStatus(roomId: string, event: any, mjolnir: Mjolnir) { async function showProtectionStatus(roomId: string, event: any, mjolnir: Mjolnir, parts: string[]) { const protectionName = parts[0]; - const protection = mjolnir.getProtection(protectionName); + const protection = mjolnir.protectionManager.getProtection(protectionName); let text; let html; if (!protection) { diff --git a/src/commands/SyncCommand.ts b/src/commands/SyncCommand.ts index 0ebb174..995d31c 100644 --- a/src/commands/SyncCommand.ts +++ b/src/commands/SyncCommand.ts @@ -18,5 +18,5 @@ import { Mjolnir } from "../Mjolnir"; // !mjolnir sync export async function execSyncCommand(roomId: string, event: any, mjolnir: Mjolnir) { - return mjolnir.syncLists(); + return mjolnir.protectedRoomsTracker.syncLists(mjolnir.config.verboseLogging); } diff --git a/src/commands/UnbanBanCommand.ts b/src/commands/UnbanBanCommand.ts index 14bc47f..ad7d7c7 100644 --- a/src/commands/UnbanBanCommand.ts +++ b/src/commands/UnbanBanCommand.ts @@ -131,21 +131,21 @@ export async function execUnbanCommand(roomId: string, event: any, mjolnir: Mjol if (USER_RULE_TYPES.includes(bits.ruleType!) && bits.reason === 'true') { const rule = new MatrixGlob(bits.entity); - await mjolnir.logMessage(LogLevel.INFO, "UnbanBanCommand", "Unbanning users that match glob: " + bits.entity); + await mjolnir.managementRoomOutput.logMessage(LogLevel.INFO, "UnbanBanCommand", "Unbanning users that match glob: " + bits.entity); let unbannedSomeone = false; - for (const protectedRoomId of Object.keys(mjolnir.protectedRooms)) { + for (const protectedRoomId of mjolnir.protectedRoomsTracker.getProtectedRooms()) { const members = await mjolnir.client.getRoomMembers(protectedRoomId, undefined, ['ban'], undefined); - await mjolnir.logMessage(LogLevel.DEBUG, "UnbanBanCommand", `Found ${members.length} banned user(s)`); + await mjolnir.managementRoomOutput.logMessage(LogLevel.DEBUG, "UnbanBanCommand", `Found ${members.length} banned user(s)`); for (const member of members) { const victim = member.membershipFor; if (member.membership !== 'ban') continue; if (rule.test(victim)) { - await mjolnir.logMessage(LogLevel.DEBUG, "UnbanBanCommand", `Unbanning ${victim} in ${protectedRoomId}`, protectedRoomId); + await mjolnir.managementRoomOutput.logMessage(LogLevel.DEBUG, "UnbanBanCommand", `Unbanning ${victim} in ${protectedRoomId}`, protectedRoomId); if (!mjolnir.config.noop) { await mjolnir.client.unbanUser(victim, protectedRoomId); } else { - await mjolnir.logMessage(LogLevel.WARN, "UnbanBanCommand", `Attempted to unban ${victim} in ${protectedRoomId} but Mjolnir is running in no-op mode`, protectedRoomId); + await mjolnir.managementRoomOutput.logMessage(LogLevel.WARN, "UnbanBanCommand", `Attempted to unban ${victim} in ${protectedRoomId} but Mjolnir is running in no-op mode`, protectedRoomId); } unbannedSomeone = true; @@ -154,8 +154,8 @@ export async function execUnbanCommand(roomId: string, event: any, mjolnir: Mjol } if (unbannedSomeone) { - await mjolnir.logMessage(LogLevel.DEBUG, "UnbanBanCommand", `Syncing lists to ensure no users were accidentally unbanned`); - await mjolnir.syncLists(mjolnir.config.verboseLogging); + await mjolnir.managementRoomOutput.logMessage(LogLevel.DEBUG, "UnbanBanCommand", `Syncing lists to ensure no users were accidentally unbanned`); + await mjolnir.protectedRoomsTracker.syncLists(mjolnir.config.verboseLogging); } } diff --git a/src/protections/BasicFlooding.ts b/src/protections/BasicFlooding.ts index 66c82fa..10ef582 100644 --- a/src/protections/BasicFlooding.ts +++ b/src/protections/BasicFlooding.ts @@ -62,11 +62,11 @@ export class BasicFlooding extends Protection { } if (messageCount >= this.settings.maxPerMinute.value) { - await mjolnir.logMessage(LogLevel.WARN, "BasicFlooding", `Banning ${event['sender']} in ${roomId} for flooding (${messageCount} messages in the last minute)`, roomId); + await mjolnir.managementRoomOutput.logMessage(LogLevel.WARN, "BasicFlooding", `Banning ${event['sender']} in ${roomId} for flooding (${messageCount} messages in the last minute)`, roomId); if (!mjolnir.config.noop) { await mjolnir.client.banUser(event['sender'], roomId, "spam"); } else { - await mjolnir.logMessage(LogLevel.WARN, "BasicFlooding", `Tried to ban ${event['sender']} in ${roomId} but Mjolnir is running in no-op mode`, roomId); + await mjolnir.managementRoomOutput.logMessage(LogLevel.WARN, "BasicFlooding", `Tried to ban ${event['sender']} in ${roomId} but Mjolnir is running in no-op mode`, roomId); } if (this.recentlyBanned.includes(event['sender'])) return; // already handled (will be redacted) @@ -79,7 +79,7 @@ export class BasicFlooding extends Protection { await mjolnir.client.redactEvent(roomId, eventId, "spam"); } } else { - await mjolnir.logMessage(LogLevel.WARN, "BasicFlooding", `Tried to redact messages for ${event['sender']} in ${roomId} but Mjolnir is running in no-op mode`, roomId); + await mjolnir.managementRoomOutput.logMessage(LogLevel.WARN, "BasicFlooding", `Tried to redact messages for ${event['sender']} in ${roomId} but Mjolnir is running in no-op mode`, roomId); } // Free up some memory now that we're ready to handle it elsewhere diff --git a/src/protections/DetectFederationLag.ts b/src/protections/DetectFederationLag.ts index 548bf65..8ffb3f8 100644 --- a/src/protections/DetectFederationLag.ts +++ b/src/protections/DetectFederationLag.ts @@ -627,7 +627,7 @@ export class DetectFederationLag extends Protection { roomInfo.latestAlertStart = now; // Background-send message. const stats = roomInfo.globalStats(); - /* do not await */ mjolnir.logMessage(LogLevel.WARN, "FederationLag", + /* do not await */ mjolnir.managementRoomOutput.logMessage(LogLevel.WARN, "FederationLag", `Room ${roomId} is experiencing ${isLocalDomainOnAlert ? "LOCAL" : "federated"} lag since ${roomInfo.latestAlertStart}.\n${roomInfo.alerts} homeservers are lagging: ${[...roomInfo.serversOnAlert()].sort()} .\nRoom lag statistics: ${JSON.stringify(stats, null, 2)}.`); // Drop a state event, for the use of potential other bots. const warnStateEventId = await mjolnir.client.sendStateEvent(mjolnir.managementRoomId, LAG_STATE_EVENT, roomId, { @@ -642,7 +642,7 @@ export class DetectFederationLag extends Protection { } else if (roomInfo.alerts < this.settings.numberOfLaggingFederatedHomeserversExitWarningZone.value || !isLocalDomainOnAlert) { // Stop the alarm! - /* do not await */ mjolnir.logMessage(LogLevel.INFO, "FederationLag", + /* do not await */ mjolnir.managementRoomOutput.logMessage(LogLevel.INFO, "FederationLag", `Room ${roomId} lag has decreased to an acceptable level. Currently, ${roomInfo.alerts} homeservers are still lagging` ); if (roomInfo.warnStateEventId) { diff --git a/src/protections/FirstMessageIsImage.ts b/src/protections/FirstMessageIsImage.ts index a1cc588..dd7dd41 100644 --- a/src/protections/FirstMessageIsImage.ts +++ b/src/protections/FirstMessageIsImage.ts @@ -56,11 +56,11 @@ export class FirstMessageIsImage extends Protection { const formattedBody = content['formatted_body'] || ''; const isMedia = msgtype === 'm.image' || msgtype === 'm.video' || formattedBody.toLowerCase().includes('= this.settings.maxPer.value) { - await mjolnir.logMessage(LogLevel.WARN, "JoinWaveShortCircuit", `Setting ${roomId} to invite-only as more than ${this.settings.maxPer.value} users have joined over the last ${this.settings.timescaleMinutes.value} minutes (since ${this.joinBuckets[roomId].lastBucketStart})`, roomId); + await mjolnir.managementRoomOutput.logMessage(LogLevel.WARN, "JoinWaveShortCircuit", `Setting ${roomId} to invite-only as more than ${this.settings.maxPer.value} users have joined over the last ${this.settings.timescaleMinutes.value} minutes (since ${this.joinBuckets[roomId].lastBucketStart})`, roomId); if (!mjolnir.config.noop) { await mjolnir.client.sendStateEvent(roomId, "m.room.join_rules", "", {"join_rule": "invite"}) } else { - await mjolnir.logMessage(LogLevel.WARN, "JoinWaveShortCircuit", `Tried to set ${roomId} to invite-only, but Mjolnir is running in no-op mode`, roomId); + await mjolnir.managementRoomOutput.logMessage(LogLevel.WARN, "JoinWaveShortCircuit", `Tried to set ${roomId} to invite-only, but Mjolnir is running in no-op mode`, roomId); } } } diff --git a/src/protections/MessageIsMedia.ts b/src/protections/MessageIsMedia.ts index 6d4c759..64b3728 100644 --- a/src/protections/MessageIsMedia.ts +++ b/src/protections/MessageIsMedia.ts @@ -40,12 +40,12 @@ export class MessageIsMedia extends Protection { const formattedBody = content['formatted_body'] || ''; const isMedia = msgtype === 'm.image' || msgtype === 'm.video' || formattedBody.toLowerCase().includes('(); + get protections(): Readonly> { + return this._protections; + } + + + constructor(private readonly mjolnir: Mjolnir) { + } + + /* + * Take all the builtin protections, register them to set their enabled (or not) state and + * update their settings with any saved non-default values + */ + public async start() { + this.mjolnir.reportManager.on("report.new", this.handleReport.bind(this)); + this.mjolnir.client.on("room.event", this.handleEvent.bind(this)); + for (const protection of PROTECTIONS) { + try { + await this.registerProtection(protection); + } catch (e) { + this.mjolnir.managementRoomOutput.logMessage(LogLevel.WARN, "ProtectionManager", extractRequestError(e)); + } + } + } + + /** + * Given a protection object; add it to our list of protections, set it up if it has been enabled previously (in account data) + * and update its settings with any saved non-default values. See `ENABLED_PROTECTIONS_EVENT_TYPE`. + * + * @param protection The protection object we want to register + */ + public async registerProtection(protection: Protection) { + this._protections.set(protection.name, protection) + + let enabledProtections: { enabled: string[] } | null = null; + try { + enabledProtections = await this.mjolnir.client.getAccountData(ENABLED_PROTECTIONS_EVENT_TYPE); + } catch { + // this setting either doesn't exist, or we failed to read it (bad network?) + // TODO: retry on certain failures? + } + protection.enabled = enabledProtections?.enabled.includes(protection.name) ?? false; + + const savedSettings = await this.getProtectionSettings(protection.name); + for (let [key, value] of Object.entries(savedSettings)) { + // this.getProtectionSettings() validates this data for us, so we don't need to + protection.settings[key].setValue(value); + } + } + + /* + * Given a protection object; remove it from our list of protections. + * + * @param protection The protection object we want to unregister + */ + public unregisterProtection(protectionName: string) { + if (!(this._protections.has(protectionName))) { + throw new Error("Failed to find protection by name: " + protectionName); + } + this._protections.delete(protectionName); + } + + /* + * Takes an object of settings we want to change and what their values should be, + * check that their values are valid, combine them with current saved settings, + * then save the amalgamation to a state event + * + * @param protectionName Which protection these settings belong to + * @param changedSettings The settings to change and their values + */ + public async setProtectionSettings(protectionName: string, changedSettings: { [setting: string]: any }): Promise { + const protection = this._protections.get(protectionName); + if (protection === undefined) { + return; + } + + const validatedSettings: { [setting: string]: any } = await this.getProtectionSettings(protectionName); + + for (let [key, value] of Object.entries(changedSettings)) { + if (!(key in protection.settings)) { + throw new ProtectionSettingValidationError(`Failed to find protection setting by name: ${key}`); + } + if (typeof (protection.settings[key].value) !== typeof (value)) { + throw new ProtectionSettingValidationError(`Invalid type for protection setting: ${key} (${typeof (value)})`); + } + if (!protection.settings[key].validate(value)) { + throw new ProtectionSettingValidationError(`Invalid value for protection setting: ${key} (${value})`); + } + validatedSettings[key] = value; + } + + await this.mjolnir.client.sendStateEvent( + this.mjolnir.managementRoomId, 'org.matrix.mjolnir.setting', protectionName, validatedSettings + ); + } + + /* + * Make a list of the names of enabled protections and save them in a state event + */ + private async saveEnabledProtections() { + const protections = this.enabledProtections.map(p => p.name); + await this.mjolnir.client.setAccountData(ENABLED_PROTECTIONS_EVENT_TYPE, { enabled: protections }); + } + /* + * Enable a protection by name and persist its enable state in to a state event + * + * @param name The name of the protection whose settings we're enabling + */ + public async enableProtection(name: string) { + const protection = this._protections.get(name); + if (protection !== undefined) { + protection.enabled = true; + await this.saveEnabledProtections(); + } + } + + public get enabledProtections(): Protection[] { + return [...this._protections.values()].filter(p => p.enabled); + } + + /** + * Get a protection by name. + * + * @return If there is a protection with this name *and* it is enabled, + * return the protection. + */ + public getProtection(protectionName: string): Protection | null { + return this._protections.get(protectionName) ?? null; + } + + + /* + * Disable a protection by name and remove it from the persistent list of enabled protections + * + * @param name The name of the protection whose settings we're disabling + */ + public async disableProtection(name: string) { + const protection = this._protections.get(name); + if (protection !== undefined) { + protection.enabled = false; + await this.saveEnabledProtections(); + } + } + + /* + * Read org.matrix.mjolnir.setting state event, find any saved settings for + * the requested protectionName, then iterate and validate against their parser + * counterparts in Protection.settings and return those which validate + * + * @param protectionName The name of the protection whose settings we're reading + * @returns Every saved setting for this protectionName that has a valid value + */ + public async getProtectionSettings(protectionName: string): Promise<{ [setting: string]: any }> { + let savedSettings: { [setting: string]: any } = {} + try { + savedSettings = await this.mjolnir.client.getRoomStateEvent( + this.mjolnir.managementRoomId, 'org.matrix.mjolnir.setting', protectionName + ); + } catch { + // setting does not exist, return empty object + return {}; + } + + const settingDefinitions = this._protections.get(protectionName)?.settings ?? {}; + const validatedSettings: { [setting: string]: any } = {} + for (let [key, value] of Object.entries(savedSettings)) { + if ( + // is this a setting name with a known parser? + key in settingDefinitions + // is the datatype of this setting's value what we expect? + && typeof (settingDefinitions[key].value) === typeof (value) + // is this setting's value valid for the setting? + && settingDefinitions[key].validate(value) + ) { + validatedSettings[key] = value; + } else { + await this.mjolnir.managementRoomOutput.logMessage( + LogLevel.WARN, + "getProtectionSetting", + `Tried to read ${protectionName}.${key} and got invalid value ${value}` + ); + } + } + return validatedSettings; + } + + private async handleConsequences(protection: Protection, roomId: string, eventId: string, sender: string, consequences: Consequence[]) { + for (const consequence of consequences) { + try { + if (consequence.name === "alert") { + /* take no additional action, just print the below message to management room */ + } else if (consequence.name === "ban") { + await this.mjolnir.client.banUser(sender, roomId, "abuse detected"); + } else if (consequence.name === "redact") { + await this.mjolnir.client.redactEvent(roomId, eventId, "abuse detected"); + } else { + throw new Error(`unknown consequence ${consequence.name}`); + } + + let message = `protection ${protection.name} enacting` + + ` ${consequence.name}` + + ` against ${htmlEscape(sender)}` + + ` in ${htmlEscape(roomId)}` + + ` (reason: ${htmlEscape(consequence.reason)})`; + await this.mjolnir.client.sendMessage(this.mjolnir.managementRoomId, { + msgtype: "m.notice", + body: message, + [CONSEQUENCE_EVENT_DATA]: { + who: sender, + room: roomId, + types: [consequence.name], + } + }); + } catch (e) { + await this.mjolnir.managementRoomOutput.logMessage(LogLevel.ERROR, "handleConsequences", `Failed to enact ${consequence.name} consequence: ${e}`); + } + } + } + + private async handleEvent(roomId: string, event: any) { + if (this.mjolnir.protectedRoomsTracker.getProtectedRooms().includes(roomId)) { + if (event['sender'] === await this.mjolnir.client.getUserId()) return; // Ignore ourselves + + // Iterate all the enabled protections + for (const protection of this.enabledProtections) { + let consequences: Consequence[] | undefined = undefined; + try { + consequences = await protection.handleEvent(this.mjolnir, roomId, event); + } catch (e) { + const eventPermalink = Permalinks.forEvent(roomId, event['event_id']); + LogService.error("ProtectionManager", "Error handling protection: " + protection.name); + LogService.error("ProtectionManager", "Failed event: " + eventPermalink); + LogService.error("ProtectionManager", extractRequestError(e)); + await this.mjolnir.client.sendNotice(this.mjolnir.managementRoomId, "There was an error processing an event through a protection - see log for details. Event: " + eventPermalink); + continue; + } + + if (consequences !== undefined) { + await this.handleConsequences(protection, roomId, event["event_id"], event["sender"], consequences); + } + } + + // Run the event handlers - we always run this after protections so that the protections + // can flag the event for redaction. + await this.mjolnir.unlistedUserRedactionHandler.handleEvent(roomId, event, this.mjolnir); // FIXME: That's rather spaghetti + } + } + + + private requiredProtectionPermissions(): Set { + return new Set(this.enabledProtections.map((p) => p.requiredStatePermissions).flat()) + } + + public async verifyPermissionsIn(roomId: string): Promise { + const errors: RoomUpdateError[] = []; + const additionalPermissions = this.requiredProtectionPermissions(); + + try { + const ownUserId = await this.mjolnir.client.getUserId(); + + const powerLevels = await this.mjolnir.client.getRoomStateEvent(roomId, "m.room.power_levels", ""); + if (!powerLevels) { + // noinspection ExceptionCaughtLocallyJS + throw new Error("Missing power levels state event"); + } + + function plDefault(val: number | undefined | null, def: number): number { + if (!val && val !== 0) return def; + return val; + } + + const users = powerLevels['users'] || {}; + const events = powerLevels['events'] || {}; + const usersDefault = plDefault(powerLevels['users_default'], 0); + const stateDefault = plDefault(powerLevels['state_default'], 50); + const ban = plDefault(powerLevels['ban'], 50); + const kick = plDefault(powerLevels['kick'], 50); + const redact = plDefault(powerLevels['redact'], 50); + + const userLevel = plDefault(users[ownUserId], usersDefault); + const aclLevel = plDefault(events["m.room.server_acl"], stateDefault); + + // Wants: ban, kick, redact, m.room.server_acl + + if (userLevel < ban) { + errors.push({ + roomId, + errorMessage: `Missing power level for bans: ${userLevel} < ${ban}`, + errorKind: ERROR_KIND_PERMISSION, + }); + } + if (userLevel < kick) { + errors.push({ + roomId, + errorMessage: `Missing power level for kicks: ${userLevel} < ${kick}`, + errorKind: ERROR_KIND_PERMISSION, + }); + } + if (userLevel < redact) { + errors.push({ + roomId, + errorMessage: `Missing power level for redactions: ${userLevel} < ${redact}`, + errorKind: ERROR_KIND_PERMISSION, + }); + } + if (userLevel < aclLevel) { + errors.push({ + roomId, + errorMessage: `Missing power level for server ACLs: ${userLevel} < ${aclLevel}`, + errorKind: ERROR_KIND_PERMISSION, + }); + } + + // Wants: Additional permissions + + for (const additionalPermission of additionalPermissions) { + const permLevel = plDefault(events[additionalPermission], stateDefault); + + if (userLevel < permLevel) { + errors.push({ + roomId, + errorMessage: `Missing power level for "${additionalPermission}" state events: ${userLevel} < ${permLevel}`, + errorKind: ERROR_KIND_PERMISSION, + }); + } + } + + // Otherwise OK + } catch (e) { + LogService.error("Mjolnir", extractRequestError(e)); + errors.push({ + roomId, + errorMessage: e.message || (e.body ? e.body.error : ''), + errorKind: ERROR_KIND_FATAL, + }); + } + + return errors; + } + + private async handleReport({ roomId, reporterId, event, reason }: { roomId: string, reporterId: string, event: any, reason?: string }) { + for (const protection of this.enabledProtections) { + await protection.handleReport(this.mjolnir, roomId, reporterId, event, reason); + } + } +} diff --git a/src/protections/WordList.ts b/src/protections/WordList.ts index b26ab9f..260fb0e 100644 --- a/src/protections/WordList.ts +++ b/src/protections/WordList.ts @@ -92,7 +92,7 @@ export class WordList extends Protection { "i" ); } catch (ex) { - await mjolnir.logMessage(LogLevel.ERROR, "WordList", `Could not produce a regex from the word list:\n${ex}.`) + await mjolnir.managementRoomOutput.logMessage(LogLevel.ERROR, "WordList", `Could not produce a regex from the word list:\n${ex}.`) } } diff --git a/src/protections/protections.ts b/src/protections/protections.ts deleted file mode 100644 index c838af7..0000000 --- a/src/protections/protections.ts +++ /dev/null @@ -1,36 +0,0 @@ -/* -Copyright 2019 - 2022 The Matrix.org Foundation C.I.C. - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -import { FirstMessageIsImage } from "./FirstMessageIsImage"; -import { Protection } from "./IProtection"; -import { BasicFlooding } from "./BasicFlooding"; -import { DetectFederationLag } from "./DetectFederationLag"; -import { WordList } from "./WordList"; -import { MessageIsVoice } from "./MessageIsVoice"; -import { MessageIsMedia } from "./MessageIsMedia"; -import { TrustedReporters } from "./TrustedReporters"; -import { JoinWaveShortCircuit } from "./JoinWaveShortCircuit"; - -export const PROTECTIONS: Protection[] = [ - new FirstMessageIsImage(), - new BasicFlooding(), - new WordList(), - new MessageIsVoice(), - new MessageIsMedia(), - new TrustedReporters(), - new DetectFederationLag(), - new JoinWaveShortCircuit(), -]; diff --git a/src/queues/EventRedactionQueue.ts b/src/queues/EventRedactionQueue.ts index 273e7f7..4e3d84c 100644 --- a/src/queues/EventRedactionQueue.ts +++ b/src/queues/EventRedactionQueue.ts @@ -13,11 +13,11 @@ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ -import { LogLevel } from "matrix-bot-sdk" +import { LogLevel, MatrixClient } from "matrix-bot-sdk" import { ERROR_KIND_FATAL } from "../ErrorCache"; import { RoomUpdateError } from "../models/RoomUpdateError"; import { redactUserMessagesIn } from "../utils"; -import { Mjolnir } from "../Mjolnir"; +import ManagementRoomOutput from "../ManagementRoomOutput"; export interface QueuedRedaction { /** The room which the redaction will take place in. */ @@ -27,7 +27,7 @@ export interface QueuedRedaction { * Called by the EventRedactionQueue. * @param client A MatrixClient to use to carry out the redaction. */ - redact(mjolnir: Mjolnir): Promise + redact(client: MatrixClient, managementRoom: ManagementRoomOutput): Promise /** * Used to test whether the redaction is the equivalent to another redaction. * @param redaction Another QueuedRedaction to test if this redaction is an equivalent to. @@ -47,9 +47,9 @@ export class RedactUserInRoom implements QueuedRedaction { this.roomId = roomId; } - public async redact(mjolnir: Mjolnir) { - await mjolnir.logMessage(LogLevel.DEBUG, "Mjolnir", `Redacting events from ${this.userId} in room ${this.roomId}.`); - await redactUserMessagesIn(mjolnir, this.userId, [this.roomId]); + public async redact(client: MatrixClient, managementRoom: ManagementRoomOutput) { + await managementRoom.logMessage(LogLevel.DEBUG, "Mjolnir", `Redacting events from ${this.userId} in room ${this.roomId}.`); + await redactUserMessagesIn(client, managementRoom, this.userId, [this.roomId]); } public redactionEqual(redaction: QueuedRedaction): boolean { @@ -107,12 +107,12 @@ export class EventRedactionQueue { * @param limitToRoomId If the roomId is provided, only redactions for that room will be processed. * @returns A description of any errors encountered by each QueuedRedaction that was processed. */ - public async process(mjolnir: Mjolnir, limitToRoomId?: string): Promise { + public async process(client: MatrixClient, managementRoom: ManagementRoomOutput, limitToRoomId?: string): Promise { const errors: RoomUpdateError[] = []; const redact = async (currentBatch: QueuedRedaction[]) => { for (const redaction of currentBatch) { try { - await redaction.redact(mjolnir); + await redaction.redact(client, managementRoom); } catch (e) { let roomError: RoomUpdateError; if (e.roomId && e.errorMessage && e.errorKind) { diff --git a/src/queues/ProtectedRoomActivityTracker.ts b/src/queues/ProtectedRoomActivityTracker.ts index 643980e..56e3a21 100644 --- a/src/queues/ProtectedRoomActivityTracker.ts +++ b/src/queues/ProtectedRoomActivityTracker.ts @@ -13,7 +13,6 @@ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ -import { MatrixClient } from "matrix-bot-sdk"; /** * Used to keep track of protected rooms so they are always ordered for activity. @@ -29,9 +28,6 @@ export class ProtectedRoomActivityTracker { * A slot to cache the rooms for `protectedRoomsByActivity` ordered so the most recently active room is first. */ private activeRoomsCache: null|string[] = null - constructor(client: MatrixClient) { - client.on('room.event', this.handleEvent.bind(this)); - } /** * Inform the tracker that a new room is being protected by Mjolnir. @@ -55,6 +51,7 @@ export class ProtectedRoomActivityTracker { * Inform the tracker of a new event in a room, so that the internal ranking of rooms can be updated * @param roomId The room the new event is in. * @param event The new event. + * */ public handleEvent(roomId: string, event: any): void { const last_origin_server_ts = this.protectedRoomActivities.get(roomId); diff --git a/src/queues/ThrottlingQueue.ts b/src/queues/ThrottlingQueue.ts index b63c9c8..efa3cce 100644 --- a/src/queues/ThrottlingQueue.ts +++ b/src/queues/ThrottlingQueue.ts @@ -178,7 +178,7 @@ export class ThrottlingQueue { try { await task(); } catch (ex) { - await this.mjolnir.logMessage( + await this.mjolnir.managementRoomOutput.logMessage( LogLevel.WARN, 'Error while executing task', extractRequestError(ex) diff --git a/src/queues/UnlistedUserRedactionQueue.ts b/src/queues/UnlistedUserRedactionQueue.ts index 91ac878..9c50baa 100644 --- a/src/queues/UnlistedUserRedactionQueue.ts +++ b/src/queues/UnlistedUserRedactionQueue.ts @@ -45,10 +45,10 @@ export class UnlistedUserRedactionQueue { if (!mjolnir.config.noop) { await mjolnir.client.redactEvent(roomId, event['event_id']); } else { - await mjolnir.logMessage(LogLevel.WARN, "AutomaticRedactionQueue", `Tried to redact ${permalink} but Mjolnir is running in no-op mode`); + await mjolnir.managementRoomOutput.logMessage(LogLevel.WARN, "AutomaticRedactionQueue", `Tried to redact ${permalink} but Mjolnir is running in no-op mode`); } } catch (e) { - mjolnir.logMessage(LogLevel.WARN, "AutomaticRedactionQueue", `Unable to redact message: ${permalink}`); + mjolnir.managementRoomOutput.logMessage(LogLevel.WARN, "AutomaticRedactionQueue", `Unable to redact message: ${permalink}`); LogService.warn("AutomaticRedactionQueue", extractRequestError(e)); } } diff --git a/src/report/ReportPoller.ts b/src/report/ReportPoller.ts index 2e98833..d1a03c2 100644 --- a/src/report/ReportPoller.ts +++ b/src/report/ReportPoller.ts @@ -75,13 +75,13 @@ export class ReportPoller { } ); } catch (ex) { - await this.mjolnir.logMessage(LogLevel.ERROR, "getAbuseReports", `failed to poll events: ${ex}`); + await this.mjolnir.managementRoomOutput.logMessage(LogLevel.ERROR, "getAbuseReports", `failed to poll events: ${ex}`); return; } const response = response_!; for (let report of response.event_reports) { - if (!(report.room_id in this.mjolnir.protectedRooms)) { + if (!this.mjolnir.protectedRoomsTracker.isProtectedRoom(report.room_id)) { continue; } @@ -92,7 +92,7 @@ export class ReportPoller { `/_synapse/admin/v1/rooms/${report.room_id}/context/${report.event_id}?limit=1` )).event; } catch (ex) { - this.mjolnir.logMessage(LogLevel.ERROR, "getAbuseReports", `failed to get context: ${ex}`); + this.mjolnir.managementRoomOutput.logMessage(LogLevel.ERROR, "getAbuseReports", `failed to get context: ${ex}`); continue; } @@ -114,7 +114,7 @@ export class ReportPoller { try { await this.mjolnir.client.setAccountData(REPORT_POLL_EVENT_TYPE, { from: response.next_token }); } catch (ex) { - await this.mjolnir.logMessage(LogLevel.ERROR, "getAbuseReports", `failed to update progress: ${ex}`); + await this.mjolnir.managementRoomOutput.logMessage(LogLevel.ERROR, "getAbuseReports", `failed to update progress: ${ex}`); } } } @@ -125,7 +125,7 @@ export class ReportPoller { try { await this.getAbuseReports() } catch (ex) { - await this.mjolnir.logMessage(LogLevel.ERROR, "tryGetAbuseReports", `failed to get abuse reports: ${ex}`); + await this.mjolnir.managementRoomOutput.logMessage(LogLevel.ERROR, "tryGetAbuseReports", `failed to get abuse reports: ${ex}`); } this.schedulePoll(); diff --git a/src/utils.ts b/src/utils.ts index 8c6ad25..07009c6 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -15,21 +15,16 @@ limitations under the License. */ import { - extractRequestError, LogLevel, LogService, MatrixClient, MatrixGlob, - MessageType, - Permalinks, - TextualMessageEventContent, - UserID, getRequestFn, setRequestFn, } from "matrix-bot-sdk"; -import { Mjolnir } from "./Mjolnir"; import { ClientRequest, IncomingMessage } from "http"; import { default as parseDuration } from "parse-duration"; +import ManagementRoomOutput from "./ManagementRoomOutput"; // Define a few aliases to simplify parsing durations. @@ -70,17 +65,29 @@ export function isTrueJoinEvent(event: any): boolean { return membership === 'join' && prevMembership !== "join"; } -export async function redactUserMessagesIn(mjolnir: Mjolnir, userIdOrGlob: string, targetRoomIds: string[], limit = 1000) { +/** + * Redact a user's messages in a set of rooms. + * See `getMessagesByUserIn`. + * + * @param client Client to redact the messages with. + * @param managementRoom Management room to log messages back to. + * @param userIdOrGlob A mxid or a glob which is applied to the whole sender field of events in the room, which will be redacted if they match. + * See `MatrixGlob` in matrix-bot-sdk. + * @param targetRoomIds Rooms to redact the messages from. + * @param limit The number of messages to redact from most recent first. If the limit is reached then no further messages will be redacted. + * @param noop Whether to operate in noop mode. + */ +export async function redactUserMessagesIn(client: MatrixClient, managementRoom: ManagementRoomOutput, userIdOrGlob: string, targetRoomIds: string[], limit = 1000, noop = false) { for (const targetRoomId of targetRoomIds) { - await mjolnir.logMessage(LogLevel.DEBUG, "utils#redactUserMessagesIn", `Fetching sent messages for ${userIdOrGlob} in ${targetRoomId} to redact...`, targetRoomId); + await managementRoom.logMessage(LogLevel.DEBUG, "utils#redactUserMessagesIn", `Fetching sent messages for ${userIdOrGlob} in ${targetRoomId} to redact...`, targetRoomId); - await getMessagesByUserIn(mjolnir.client, userIdOrGlob, targetRoomId, limit, async (eventsToRedact) => { + await getMessagesByUserIn(client, userIdOrGlob, targetRoomId, limit, async (eventsToRedact) => { for (const victimEvent of eventsToRedact) { - await mjolnir.logMessage(LogLevel.DEBUG, "utils#redactUserMessagesIn", `Redacting ${victimEvent['event_id']} in ${targetRoomId}`, targetRoomId); - if (!mjolnir.config.noop) { - await mjolnir.client.redactEvent(targetRoomId, victimEvent['event_id']); + await managementRoom.logMessage(LogLevel.DEBUG, "utils#redactUserMessagesIn", `Redacting ${victimEvent['event_id']} in ${targetRoomId}`, targetRoomId); + if (!noop) { + await client.redactEvent(targetRoomId, victimEvent['event_id']); } else { - await mjolnir.logMessage(LogLevel.WARN, "utils#redactUserMessagesIn", `Tried to redact ${victimEvent['event_id']} in ${targetRoomId} but Mjolnir is running in no-op mode`, targetRoomId); + await managementRoom.logMessage(LogLevel.WARN, "utils#redactUserMessagesIn", `Tried to redact ${victimEvent['event_id']} in ${targetRoomId} but Mjolnir is running in no-op mode`, targetRoomId); } } }); @@ -189,51 +196,6 @@ export async function getMessagesByUserIn(client: MatrixClient, sender: string, } while (token && processed < limit) } -/* - * Take an arbitrary string and a set of room IDs, and return a - * TextualMessageEventContent whose plaintext component replaces those room - * IDs with their canonical aliases, and whose html component replaces those - * room IDs with their matrix.to room pills. - * - * @param client The matrix client on which to query for room aliases - * @param text An arbitrary string to rewrite with room aliases and pills - * @param roomIds A set of room IDs to find and replace in `text` - * @param msgtype The desired message type of the returned TextualMessageEventContent - * @returns A TextualMessageEventContent with replaced room IDs - */ -export async function replaceRoomIdsWithPills(mjolnir: Mjolnir, text: string, roomIds: Set, msgtype: MessageType = "m.text"): Promise { - const content: TextualMessageEventContent = { - body: text, - formatted_body: htmlEscape(text), - msgtype: msgtype, - format: "org.matrix.custom.html", - }; - - const escapeRegex = (v: string): string => { - return v.replace(/[-\/\\^$*+?.()|[\]{}]/g, '\\$&'); - }; - - const viaServers = [(new UserID(await mjolnir.client.getUserId())).domain]; - for (const roomId of roomIds) { - let alias = roomId; - try { - alias = (await mjolnir.client.getPublishedAlias(roomId)) || roomId; - } catch (e) { - // This is a recursive call, so tell the function not to try and call us - await mjolnir.logMessage(LogLevel.WARN, "utils", `Failed to resolve room alias for ${roomId} - see console for details`, null, true); - LogService.warn("utils", extractRequestError(e)); - } - const regexRoomId = new RegExp(escapeRegex(roomId), "g"); - content.body = content.body.replace(regexRoomId, alias); - if (content.formatted_body) { - const permalink = Permalinks.forRoom(alias, alias !== roomId ? [] : viaServers); - content.formatted_body = content.formatted_body.replace(regexRoomId, `${alias}`); - } - } - - return content; -} - let isMatrixClientPatchedForConciseExceptions = false; /** diff --git a/test/integration/abuseReportTest.ts b/test/integration/abuseReportTest.ts index e3c58b7..2bbb9bb 100644 --- a/test/integration/abuseReportTest.ts +++ b/test/integration/abuseReportTest.ts @@ -24,8 +24,8 @@ describe("Test: Reporting abuse", async () => { this.timeout(60000); // Listen for any notices that show up. - let notices = []; - matrixClient().on("room.event", (roomId, event) => { + let notices: any[] = []; + matrixClient()!.on("room.event", (roomId, event) => { if (roomId = this.mjolnir.managementRoomId) { notices.push(event); } diff --git a/test/integration/banListTest.ts b/test/integration/banListTest.ts index db09fde..40add9b 100644 --- a/test/integration/banListTest.ts +++ b/test/integration/banListTest.ts @@ -247,7 +247,7 @@ describe('Test: ACL updates will batch when rules are added in succession.', fun } // If a previous test hasn't cleaned up properly, these rooms will be populated by bogus ACLs at this point. - await mjolnir.syncLists(); + await mjolnir.protectedRoomsTracker.syncLists(mjolnir.config.verboseLogging); await Promise.all(protectedRooms.map(async room => { // We're going to need timeline pagination I'm afraid. const roomAcl = await mjolnir.client.getRoomStateEvent(room, "m.room.server_acl", ""); @@ -269,7 +269,7 @@ describe('Test: ACL updates will batch when rules are added in succession.', fun } // We do this because it should force us to wait until all the ACL events have been applied. // Even if that does mean the last few events will not go through batching... - await mjolnir.syncLists(); + await mjolnir.protectedRoomsTracker.syncLists(mjolnir.config.verboseLogging); // At this point we check that the state within Mjolnir is internally consistent, this is just because debugging the following // is a pita. @@ -300,7 +300,7 @@ describe('Test: unbaning entities via the PolicyList.', function() { it('Will remove rules that have legacy types', async function() { const mjolnir: Mjolnir = this.mjolnir! const serverName: string = new UserID(await mjolnir.client.getUserId()).domain - const moderator = await newTestUser(this.config.homeserverUrl, { name: { contains: "moderator" } }); + const moderator: MatrixClient = await newTestUser(this.config.homeserverUrl, { name: { contains: "moderator" } }); this.moderator = moderator; await moderator.joinRoom(mjolnir.managementRoomId); const mjolnirId = await mjolnir.client.getUserId(); @@ -312,7 +312,7 @@ describe('Test: unbaning entities via the PolicyList.', function() { await mjolnir.addProtectedRoom(protectedRoom); // If a previous test hasn't cleaned up properly, these rooms will be populated by bogus ACLs at this point. - await mjolnir.syncLists(); + await mjolnir.protectedRoomsTracker.syncLists(mjolnir.config.verboseLogging); // If this is not present, then it means the room isn't being protected, which is really bad. const roomAcl = await mjolnir.client.getRoomStateEvent(protectedRoom, "m.room.server_acl", ""); assert.equal(roomAcl?.deny?.length ?? 0, 0, 'There should be no entries in the deny ACL.'); @@ -333,7 +333,7 @@ describe('Test: unbaning entities via the PolicyList.', function() { await createPolicyRule(moderator, banListId, RULE_SERVER, newerBadServer, 'this is bad sort it out.'); await createPolicyRule(moderator, banListId, RULE_SERVER, newerBadServer, 'hidden with a non-standard state key', undefined, "rule_1"); // Wait for the ACL event to be applied to our protected room. - await this.mjolnir!.syncLists(); + await mjolnir.protectedRoomsTracker.syncLists(); await banList.updateList(); // rules are normalized by rule type, that's why there should only be 3. @@ -359,7 +359,7 @@ describe('Test: unbaning entities via the PolicyList.', function() { } // Wait for mjolnir to sync protected rooms to update ACL. - await mjolnir.syncLists(); + await mjolnir.protectedRoomsTracker.syncLists(mjolnir.config.verboseLogging); // Confirm that the server is unbanned. await banList.updateList(); assert.equal(banList.allRules.length, 0); @@ -388,7 +388,7 @@ describe('Test: should apply bans to the most recently active rooms first', func } // If a previous test hasn't cleaned up properly, these rooms will be populated by bogus ACLs at this point. - await mjolnir.syncLists(); + await mjolnir.protectedRoomsTracker.syncLists(mjolnir.config.verboseLogging); await Promise.all(protectedRooms.map(async room => { const roomAcl = await mjolnir.client.getRoomStateEvent(room, "m.room.server_acl", "").catch(e => e.statusCode === 404 ? { deny: [] } : Promise.reject(e)); assert.equal(roomAcl?.deny?.length ?? 0, 0, 'There should be no entries in the deny ACL.'); @@ -399,7 +399,7 @@ describe('Test: should apply bans to the most recently active rooms first', func await mjolnir.client.joinRoom(banListId); await mjolnir.watchList(Permalinks.forRoom(banListId)); - await mjolnir.syncLists(); + await mjolnir.protectedRoomsTracker.syncLists(mjolnir.config.verboseLogging); // shuffle protected rooms https://stackoverflow.com/a/12646864, we do this so we can create activity "randomly" in them. for (let i = protectedRooms.length - 1; i > 0; i--) { @@ -408,25 +408,38 @@ describe('Test: should apply bans to the most recently active rooms first', func } // create some activity in the same order. for (const roomId of protectedRooms.slice().reverse()) { - await mjolnir.client.sendMessage(roomId, { body: `activity`, msgtype: 'm.text' }); + await moderator.sendMessage(roomId, { body: `activity`, msgtype: 'm.text' }); await new Promise(resolve => setTimeout(resolve, 100)); } // check the rooms are in the expected order for (let i = 0; i < protectedRooms.length; i++) { - assert.equal(mjolnir.protectedRoomsByActivity()[i], protectedRooms[i]); + assert.equal(mjolnir.protectedRoomsTracker.protectedRoomsByActivity()[i], protectedRooms[i]); } - const badServer = `evil.com`; // just ban one server + const badServer = `evil.com`; const acl = new ServerAcl(serverName).denyIpAddresses().allowServer("*").denyServer(badServer); - await createPolicyRule(moderator, banListId, RULE_SERVER, badServer, `Rule ${badServer}`); + // collect all the rooms that received an ACL event. + const aclRooms: any[] = await new Promise(async resolve => { + const rooms: any[] = []; + this.mjolnir.client.on('room.event', (room: string, event: any) => { + if (protectedRooms.includes(room)) { + rooms.push(room); + } + if (rooms.length === protectedRooms.length) { + resolve(rooms) + } + }); + // create the rule that will ban the server. + await createPolicyRule(moderator, banListId, RULE_SERVER, badServer, `Rule ${badServer}`); + }) // Wait until all the ACL events have been applied. - await mjolnir.syncLists(); + await mjolnir.protectedRoomsTracker.syncLists(mjolnir.config.verboseLogging); for (let i = 0; i < protectedRooms.length; i++) { - assert.equal(mjolnir.protectedRoomsByActivity()[i], protectedRooms.at(-i - 1)); + assert.equal(aclRooms[i], protectedRooms[i], "The ACL should have been applied to the active rooms first."); } // Check that the most recently active rooms got the ACL update first. diff --git a/test/integration/detectFederationLagTest.ts b/test/integration/detectFederationLagTest.ts index c8711d6..16cd23b 100644 --- a/test/integration/detectFederationLagTest.ts +++ b/test/integration/detectFederationLagTest.ts @@ -24,8 +24,8 @@ describe("Test: DetectFederationLag protection", function() { beforeEach(async function() { // Setup an instance of DetectFederationLag this.detector = new DetectFederationLag(); - await this.mjolnir.registerProtection(this.detector); - await this.mjolnir.enableProtection("DetectFederationLag"); + await this.mjolnir.protectionManager.registerProtection(this.detector); + await this.mjolnir.protectionManager.enableProtection("DetectFederationLag"); // Setup a moderator. this.moderator = await newTestUser(this.config.homeserverUrl, { name: { contains: "moderator" } }); diff --git a/test/integration/protectionSettingsTest.ts b/test/integration/protectionSettingsTest.ts index 0e38f13..10940fd 100644 --- a/test/integration/protectionSettingsTest.ts +++ b/test/integration/protectionSettingsTest.ts @@ -2,7 +2,6 @@ import { strict as assert } from "assert"; import { Mjolnir } from "../../src/Mjolnir"; import { IProtection } from "../../src/protections/IProtection"; -import { PROTECTIONS } from "../../src/protections/protections"; import { ProtectionSettingValidationError } from "../../src/protections/ProtectionSettings"; import { NumberProtectionSetting, StringProtectionSetting, StringListProtectionSetting } from "../../src/protections/ProtectionSettings"; import { newTestUser, noticeListener } from "./clientHelper"; @@ -20,29 +19,29 @@ describe("Test: Protection settings", function() { it("Mjolnir refuses to save invalid protection setting values", async function() { this.timeout(20000); await assert.rejects( - async () => await this.mjolnir.setProtectionSettings("BasicFloodingProtection", {"maxPerMinute": "soup"}), + async () => await this.mjolnir.protectionManager.setProtectionSettings("BasicFloodingProtection", {"maxPerMinute": "soup"}), ProtectionSettingValidationError ); }); it("Mjolnir successfully saves valid protection setting values", async function() { this.timeout(20000); - await this.mjolnir.registerProtection(new class implements IProtection { + await this.mjolnir.protectionManager.registerProtection(new class implements IProtection { name = "05OVMS"; description = "A test protection"; settings = { test: new NumberProtectionSetting(3) }; }); - await this.mjolnir.setProtectionSettings("05OVMS", { test: 123 }); + await this.mjolnir.protectionManager.setProtectionSettings("05OVMS", { test: 123 }); assert.equal( - (await this.mjolnir.getProtectionSettings("05OVMS"))["test"], + (await this.mjolnir.protectionManager.getProtectionSettings("05OVMS"))["test"], 123 ); }); it("Mjolnir should accumulate changed settings", async function() { this.timeout(20000); - await this.mjolnir.registerProtection(new class implements IProtection { + await this.mjolnir.protectionManager.registerProtection(new class implements IProtection { name = "HPUjKN"; settings = { test1: new NumberProtectionSetting(3), @@ -50,9 +49,9 @@ describe("Test: Protection settings", function() { }; }); - await this.mjolnir.setProtectionSettings("HPUjKN", { test1: 1 }); - await this.mjolnir.setProtectionSettings("HPUjKN", { test2: 2 }); - const settings = await this.mjolnir.getProtectionSettings("HPUjKN"); + await this.mjolnir.protectionManager.setProtectionSettings("HPUjKN", { test1: 1 }); + await this.mjolnir.protectionManager.setProtectionSettings("HPUjKN", { test2: 2 }); + const settings = await this.mjolnir.protectionManager.getProtectionSettings("HPUjKN"); assert.equal(settings["test1"], 1); assert.equal(settings["test2"], 2); }); @@ -60,7 +59,7 @@ describe("Test: Protection settings", function() { this.timeout(20000); await client.joinRoom(this.config.managementRoom); - await this.mjolnir.registerProtection(new class implements IProtection { + await this.mjolnir.protectionManager.registerProtection(new class implements IProtection { name = "JY2TPN"; description = "A test protection"; settings = { test: new StringProtectionSetting() }; @@ -78,14 +77,14 @@ describe("Test: Protection settings", function() { await client.sendMessage(this.mjolnir.managementRoomId, {msgtype: "m.text", body: "!mjolnir config set JY2TPN.test asd"}) await reply - const settings = await this.mjolnir.getProtectionSettings("JY2TPN"); + const settings = await this.mjolnir.protectionManager.getProtectionSettings("JY2TPN"); assert.equal(settings["test"], "asd"); }); it("Mjolnir adds a value to a list setting", async function() { this.timeout(20000); await client.joinRoom(this.config.managementRoom); - await this.mjolnir.registerProtection(new class implements IProtection { + await this.mjolnir.protectionManager.registerProtection(new class implements IProtection { name = "r33XyT"; description = "A test protection"; settings = { test: new StringListProtectionSetting() }; @@ -103,13 +102,13 @@ describe("Test: Protection settings", function() { await client.sendMessage(this.mjolnir.managementRoomId, {msgtype: "m.text", body: "!mjolnir config add r33XyT.test asd"}) await reply - assert.deepEqual(await this.mjolnir.getProtectionSettings("r33XyT"), { "test": ["asd"] }); + assert.deepEqual(await this.mjolnir.protectionManager.getProtectionSettings("r33XyT"), { "test": ["asd"] }); }); it("Mjolnir removes a value from a list setting", async function() { this.timeout(20000); await client.joinRoom(this.config.managementRoom); - await this.mjolnir.registerProtection(new class implements IProtection { + await this.mjolnir.protectionManager.registerProtection(new class implements IProtection { name = "oXzT0E"; description = "A test protection"; settings = { test: new StringListProtectionSetting() }; @@ -128,13 +127,13 @@ describe("Test: Protection settings", function() { await client.sendMessage(this.mjolnir.managementRoomId, {msgtype: "m.text", body: "!mjolnir config remove oXzT0E.test asd"}) await reply(); - assert.deepEqual(await this.mjolnir.getProtectionSettings("oXzT0E"), { "test": [] }); + assert.deepEqual(await this.mjolnir.protectionManager.getProtectionSettings("oXzT0E"), { "test": [] }); }); it("Mjolnir will change a protection setting in-place", async function() { this.timeout(20000); await client.joinRoom(this.config.managementRoom); - await this.mjolnir.registerProtection(new class implements IProtection { + await this.mjolnir.protectionManager.registerProtection(new class implements IProtection { name = "d0sNrt"; description = "A test protection"; settings = { test: new StringProtectionSetting() }; diff --git a/test/integration/reportPollingTest.ts b/test/integration/reportPollingTest.ts index a749bf0..03aba82 100644 --- a/test/integration/reportPollingTest.ts +++ b/test/integration/reportPollingTest.ts @@ -16,7 +16,7 @@ describe("Test: Report polling", function() { const eventId = await client.sendMessage(protectedRoomId, {msgtype: "m.text", body: "uwNd3q"}); await new Promise(async resolve => { - await this.mjolnir.registerProtection(new class implements IProtection { + await this.mjolnir.protectionManager.registerProtection(new class implements IProtection { name = "jYvufI"; description = "A test protection"; settings = { }; @@ -27,7 +27,7 @@ describe("Test: Report polling", function() { } }; }); - await this.mjolnir.enableProtection("jYvufI"); + await this.mjolnir.protectionManager.enableProtection("jYvufI"); await client.doRequest( "POST", `/_matrix/client/r0/rooms/${encodeURIComponent(protectedRoomId)}/report/${encodeURIComponent(eventId)}`, "", { diff --git a/test/integration/standardConsequenceTest.ts b/test/integration/standardConsequenceTest.ts index 4a14299..f762f4c 100644 --- a/test/integration/standardConsequenceTest.ts +++ b/test/integration/standardConsequenceTest.ts @@ -27,7 +27,7 @@ describe("Test: standard consequences", function() { await badUser.joinRoom(protectedRoomId); await this.mjolnir.addProtectedRoom(protectedRoomId); - await this.mjolnir.registerProtection(new class implements IProtection { + await this.mjolnir.protectionManager.registerProtection(new class implements IProtection { name = "JY2TPN"; description = "A test protection"; settings = { }; @@ -37,7 +37,7 @@ describe("Test: standard consequences", function() { } }; }); - await this.mjolnir.enableProtection("JY2TPN"); + await this.mjolnir.protectionManager.enableProtection("JY2TPN"); let reply = new Promise(async (resolve, reject) => { const messageId = await badUser.sendMessage(protectedRoomId, {msgtype: "m.text", body: "ngmWkF"}); @@ -71,7 +71,7 @@ describe("Test: standard consequences", function() { await badUser.joinRoom(protectedRoomId); await this.mjolnir.addProtectedRoom(protectedRoomId); - await this.mjolnir.registerProtection(new class implements IProtection { + await this.mjolnir.protectionManager.registerProtection(new class implements IProtection { name = "0LxMTy"; description = "A test protection"; settings = { }; @@ -81,7 +81,7 @@ describe("Test: standard consequences", function() { } }; }); - await this.mjolnir.enableProtection("0LxMTy"); + await this.mjolnir.protectionManager.enableProtection("0LxMTy"); let reply = new Promise(async (resolve, reject) => { const messageId = await badUser.sendMessage(protectedRoomId, {msgtype: "m.text", body: "7Uga3d"}); @@ -118,7 +118,7 @@ describe("Test: standard consequences", function() { await goodUser.joinRoom(protectedRoomId); await this.mjolnir.addProtectedRoom(protectedRoomId); - await this.mjolnir.registerProtection(new class implements IProtection { + await this.mjolnir.protectionManager.registerProtection(new class implements IProtection { name = "95B1Cr"; description = "A test protection"; settings = { }; @@ -128,7 +128,7 @@ describe("Test: standard consequences", function() { } }; }); - await this.mjolnir.enableProtection("95B1Cr"); + await this.mjolnir.protectionManager.enableProtection("95B1Cr"); let reply = new Promise(async (resolve, reject) => { this.mjolnir.client.on('room.message', async (roomId, event) => { diff --git a/test/integration/utilsTest.ts b/test/integration/utilsTest.ts index deb0252..41c396d 100644 --- a/test/integration/utilsTest.ts +++ b/test/integration/utilsTest.ts @@ -1,15 +1,11 @@ import { strict as assert } from "assert"; - -import { UserID } from "matrix-bot-sdk"; -import config from "../../src/config"; -import { replaceRoomIdsWithPills } from "../../src/utils"; +import { LogLevel } from "matrix-bot-sdk"; +import ManagementRoomOutput from "../../src/ManagementRoomOutput"; describe("Test: utils", function() { it("replaceRoomIdsWithPills correctly turns a room ID in to a pill", async function() { - this.timeout(20000); - const managementRoomAlias = this.config.managementRoom; - + const managementRoomOutput: ManagementRoomOutput = this.mjolnir.managementRoomOutput; await this.mjolnir.client.sendStateEvent( this.mjolnir.managementRoomId, "m.room.canonical_alias", @@ -17,15 +13,20 @@ describe("Test: utils", function() { { alias: managementRoomAlias } ); - const out = await replaceRoomIdsWithPills( - this.mjolnir, - `it's fun here in ${this.mjolnir.managementRoomId}`, - new Set([this.mjolnir.managementRoomId, "!myfaketestid:example.com"]) - ); - - const ourHomeserver = new UserID(await this.mjolnir.client.getUserId()).domain; + const message: any = await new Promise(async resolve => { + this.mjolnir.client.on('room.message', (roomId, event) => { + if (roomId === this.mjolnir.managementRoomId) { + if (event.content?.body?.startsWith("it's")) { + resolve(event); + } + } + }) + await managementRoomOutput.logMessage(LogLevel.INFO, 'replaceRoomIdsWithPills test', + `it's fun here in ${this.mjolnir.managementRoomId}`, + [this.mjolnir.managementRoomId, "!myfaketestid:example.com"]); + }); assert.equal( - out.formatted_body, + message.content.formatted_body, `it's fun here in ${managementRoomAlias}` ); });