From 927f2bd70f226db41bd636763e2831278f5623aa Mon Sep 17 00:00:00 2001 From: gnuxie Date: Thu, 18 Aug 2022 11:11:43 +0100 Subject: [PATCH] Attempt to factor out protected rooms from Mjolnir. This is useful to the appservice because it means we don't have to wrap a Mjolnir that is designed to sync. It's also useful if we later on want to have specific settings per space. It's also just a nice seperation between Mjolnir's needs while syncing via client-server and the behaviour of syncing policy rooms. ### Things that have changed - `ErrorCache` no longer a static class (phew), gets used by `ProtectedRooms`. - `ManagementRoomOutput` class gets created to handle logging back to the management room. - Responsibilities for syncing member bans and server ACL are handled by `ProtectedRooms`. - Responsibilities for watched lists should be moved to `ProtectedRooms` if they haven't been. - `EventRedactionQueue` is moved to `ProtectedRooms` since this needs to happen after member bans. - ApplyServerAcls moved to `ProtectedRooms` - ApplyMemberBans move to `ProtectedRooms` - `logMessage` and `replaceRoomIdsWithPills` moved to `ManagementRoomOutput`. - `resyncJoinedRooms` has been made a little more clear, though I am concerned about how often it does run because it does seem expensive. ### Things to change just plugging in all the gaps until it "works". Maybe figuring out the dependency between "verifyPermissions" and protections. I didn't want to include protections themselves into `ProtectedRooms` since they have a lot of machinery. Maybe if they are split into their own class that could be delegated to via `ProtectedRooms` it could work. I think that is it --- src/ErrorCache.ts | 20 +- src/ManagementRoom.ts | 115 +++++++ src/Mjolnir.ts | 406 ++-------------------- src/ProtectedRooms.ts | 551 ++++++++++++++++++++++++++++++ src/actions/ApplyAcl.ts | 95 ------ src/actions/ApplyBan.ts | 93 ----- src/queues/EventRedactionQueue.ts | 15 +- src/utils.ts | 60 +--- 8 files changed, 721 insertions(+), 634 deletions(-) create mode 100644 src/ManagementRoom.ts create mode 100644 src/ProtectedRooms.ts delete mode 100644 src/actions/ApplyAcl.ts delete mode 100644 src/actions/ApplyBan.ts diff --git a/src/ErrorCache.ts b/src/ErrorCache.ts index 0c00387..5760b8c 100644 --- a/src/ErrorCache.ts +++ b/src/ErrorCache.ts @@ -23,24 +23,24 @@ const TRIGGER_INTERVALS: { [key: string]: number } = { }; export default class ErrorCache { - private static roomsToErrors: { [roomId: string]: { [kind: string]: number } } = {}; + private roomsToErrors: { [roomId: string]: { [kind: string]: number } } = {}; - private constructor() { + constructor() { } - public static resetError(roomId: string, kind: string) { - if (!ErrorCache.roomsToErrors[roomId]) { - ErrorCache.roomsToErrors[roomId] = {}; + public resetError(roomId: string, kind: string) { + if (!this.roomsToErrors[roomId]) { + this.roomsToErrors[roomId] = {}; } - ErrorCache.roomsToErrors[roomId][kind] = 0; + this.roomsToErrors[roomId][kind] = 0; } - public static triggerError(roomId: string, kind: string): boolean { - if (!ErrorCache.roomsToErrors[roomId]) { - ErrorCache.roomsToErrors[roomId] = {}; + public triggerError(roomId: string, kind: string): boolean { + if (!this.roomsToErrors[roomId]) { + this.roomsToErrors[roomId] = {}; } - const triggers = ErrorCache.roomsToErrors[roomId]; + const triggers = this.roomsToErrors[roomId]; if (!triggers[kind]) { triggers[kind] = 0; } diff --git a/src/ManagementRoom.ts b/src/ManagementRoom.ts new file mode 100644 index 0000000..d832d6a --- /dev/null +++ b/src/ManagementRoom.ts @@ -0,0 +1,115 @@ +/* +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 ErrorCache from "./ErrorCache"; +import { RoomUpdateError } from "./models/RoomUpdateError"; +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, +}; + +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", + }; + + 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, `${alias}`); + } + } + + return content; + } + + 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(this, clientMessage, new Set(roomIds), "m.notice"); + } + + await client.sendMessage(this.managementRoomId, evContent); + } + + levelToFn[level.toString()](module, message); + } + + +} \ No newline at end of file diff --git a/src/Mjolnir.ts b/src/Mjolnir.ts index ce6637c..7d608fb 100644 --- a/src/Mjolnir.ts +++ b/src/Mjolnir.ts @@ -43,20 +43,14 @@ 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 { ProtectedRooms } from "./ProtectedRooms"; +import ManagementRoomOutput from "./ManagementRoom"; export const STATE_NOT_STARTED = "not_started"; export const STATE_CHECKING_PERMISSIONS = "checking_permissions"; @@ -85,33 +79,19 @@ export class Mjolnir { * 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`. */ private protectedJoinedRoomIds: string[] = []; + private protectedRoomsTracker: ProtectedRooms; /** * 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[] = []; private unprotectedWatchedListRooms: string[] = []; 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. - */ - public aclChain: Promise = Promise.resolve(); + private managementRoom: ManagementRoomOutput; /* * Config-enabled polling of reports in Synapse, so Mjolnir can react to reports */ @@ -278,9 +258,6 @@ 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); @@ -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.managementRoom.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.managementRoom.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); @@ -366,25 +339,25 @@ export class Mjolnir { this.applyUnprotectedRooms(); if (this.config.verifyPermissionsOnStartup) { - await this.logMessage(LogLevel.INFO, "Mjolnir@startup", "Checking permissions..."); + await this.managementRoom.logMessage(LogLevel.INFO, "Mjolnir@startup", "Checking permissions..."); await this.verifyPermissions(this.config.verboseLogging); } this.currentState = STATE_SYNCING; if (this.config.syncOnStartup) { - await this.logMessage(LogLevel.INFO, "Mjolnir@startup", "Syncing lists..."); + await this.managementRoom.logMessage(LogLevel.INFO, "Mjolnir@startup", "Syncing lists..."); await this.syncLists(this.config.verboseLogging); await this.registerProtections(); } this.currentState = STATE_RUNNING; - await this.logMessage(LogLevel.INFO, "Mjolnir@startup", "Startup complete. Now monitoring rooms."); + await this.managementRoom.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.managementRoom.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 +376,9 @@ 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); const unprotectedIdx = this.unprotectedWatchedListRooms.indexOf(roomId); if (unprotectedIdx >= 0) this.unprotectedWatchedListRooms.splice(unprotectedIdx, 1); @@ -450,13 +393,11 @@ 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); const idx = this.explicitlyProtectedRoomIds.indexOf(roomId); if (idx >= 0) this.explicitlyProtectedRoomIds.splice(idx, 1); @@ -471,27 +412,33 @@ export class Mjolnir { await this.client.setAccountData(PROTECTED_ROOMS_EVENT_TYPE, additionalProtectedRooms); } + // need to brewritten to add/remove from a ProtectedRooms instance. private async resyncJoinedRooms(withSync = true) { + // this is really terrible! + // what the fuck does it do??? + // just fix it bloody hell mate. 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.protectedRoomsTracker.removeProtectedRoom(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); + await this.protectedRoomsTracker.addProtectedRoom(roomId); } } @@ -580,7 +527,7 @@ export class Mjolnir { ) { validatedSettings[key] = value; } else { - await this.logMessage( + await this.managementRoom.logMessage( LogLevel.WARN, "getProtectionSetting", `Tried to read ${protectionName}.${key} and got invalid value ${value}` @@ -741,7 +688,7 @@ 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.managementRoom.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 }); } @@ -777,188 +724,12 @@ 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 handleConsequence(protection: Protection, roomId: string, eventId: string, sender: string, consequence: Consequence) { switch (consequence.type) { case ConsequenceType.alert: @@ -1039,113 +810,10 @@ export class Mjolnir { // 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); - } + } } - /** - * 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,22 +857,6 @@ export class Mjolnir { } } - 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..20d25f0 --- /dev/null +++ b/src/ProtectedRooms.ts @@ -0,0 +1,551 @@ +/* +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, MatrixGlob, Permalinks, UserID } from "matrix-bot-sdk"; +import { IConfig } from "./config"; +import ErrorCache, { ERROR_KIND_FATAL, ERROR_KIND_PERMISSION } from "./ErrorCache"; +import ManagementRoomOutput from "./ManagementRoom"; +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 { EventRedactionQueue, RedactUserInRoom } from "./queues/EventRedactionQueue"; +import { ProtectedRoomActivityTracker } from "./queues/ProtectedRoomActivityTracker"; +import { htmlEscape } from "./utils"; + +/** + * When you consider spaces https://github.com/matrix-org/mjolnir/issues/283 + * rather than indexing rooms via some collection, you instead have rooms + * and then you find out which lists apply to them. + * This is important because right now we have a collection of rooms + * and implicitly a bunch of lists. + * + * It's important not to tie this to the one group of rooms that a mjolnir may watch too much + * as in future we might want to borrow this class to represent a space. + */ +export class ProtectedRooms { + + private protectedRooms = new Set(); + + private policyLists: PolicyList[]; + + 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(); + + 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. + */ + public aclChain: Promise = Promise.resolve(); + + constructor( + private readonly client: MatrixClient, + private readonly clientUserId: string, + private readonly managementRoomId: string, + private readonly managementRoom: ManagementRoomOutput, + private readonly config: IConfig, + ) { + // Setup room activity watcher + this.protectedRoomActivityTracker = new ProtectedRoomActivityTracker(client); + } + + public queueRedactUserMessagesIn(userId: string, roomId: string) { + this.eventRedactionQueue.add(new RedactUserInRoom(userId, roomId)); + } + + public get automaticRedactGlobs(): MatrixGlob[] { + return this.automaticRedactionReasons; + } + + /** + * 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.managementRoom, 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."); + } + 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.managementRoom.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.managementRoom.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. + */ + private 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.managementRoom.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.managementRoom.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.managementRoom.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.managementRoom.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.managementRoom.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.managementRoom.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.managementRoom.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.queueRedactUserMessagesIn(member.userId, roomId); + } + } else { + await this.managementRoom.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 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; + } +} \ No newline at end of file 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/queues/EventRedactionQueue.ts b/src/queues/EventRedactionQueue.ts index 273e7f7..d435931 100644 --- a/src/queues/EventRedactionQueue.ts +++ b/src/queues/EventRedactionQueue.ts @@ -13,11 +13,12 @@ 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 "../ManagementRoom"; export interface QueuedRedaction { /** The room which the redaction will take place in. */ @@ -27,7 +28,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 +48,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 +108,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/utils.ts b/src/utils.ts index 8c6ad25..c3ea51b 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -30,6 +30,7 @@ import { import { Mjolnir } from "./Mjolnir"; import { ClientRequest, IncomingMessage } from "http"; import { default as parseDuration } from "parse-duration"; +import ManagementRoomOutput from "./ManagementRoom"; // Define a few aliases to simplify parsing durations. @@ -70,17 +71,17 @@ export function isTrueJoinEvent(event: any): boolean { return membership === 'join' && prevMembership !== "join"; } -export async function redactUserMessagesIn(mjolnir: Mjolnir, userIdOrGlob: string, targetRoomIds: string[], limit = 1000) { +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 +190,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; /**