diff --git a/package.json b/package.json index 90c347c..d8b1592 100644 --- a/package.json +++ b/package.json @@ -36,6 +36,7 @@ "typescript-formatter": "^7.2" }, "dependencies": { + "await-lock": "^2.2.2", "express": "^4.17", "html-to-text": "^8.0.0", "humanize-duration": "^3.27.1", diff --git a/src/Mjolnir.ts b/src/Mjolnir.ts index 0400a72..165295b 100644 --- a/src/Mjolnir.ts +++ b/src/Mjolnir.ts @@ -35,7 +35,7 @@ import RuleServer from "./models/RuleServer"; import { ThrottlingQueue } from "./queues/ThrottlingQueue"; import { IConfig } from "./config"; import PolicyList from "./models/PolicyList"; -import { ProtectedRooms } from "./ProtectedRooms"; +import { ProtectedRoomsSet } from "./ProtectedRoomsSet"; import ManagementRoomOutput from "./ManagementRoomOutput"; import { ProtectionManager } from "./protections/ProtectionManager"; import { RoomMemberManager } from "./RoomMembers"; @@ -45,7 +45,6 @@ export const STATE_CHECKING_PERMISSIONS = "checking_permissions"; export const STATE_SYNCING = "syncing"; export const STATE_RUNNING = "running"; -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."; @@ -78,7 +77,7 @@ export class Mjolnir { * These are eventually are exluded from `protectedRooms` in `applyUnprotectedRooms` via `resyncJoinedRooms`. */ private unprotectedWatchedListRooms: string[] = []; - public readonly protectedRoomsTracker: ProtectedRooms; + public readonly protectedRoomsTracker: ProtectedRoomsSet; private webapis: WebAPIs; public taskQueue: ThrottlingQueue; /** @@ -272,7 +271,7 @@ export class Mjolnir { this.managementRoomOutput = new ManagementRoomOutput(managementRoomId, client, config); const protections = new ProtectionManager(this); - this.protectedRoomsTracker = new ProtectedRooms(client, clientUserId, managementRoomId, this.managementRoomOutput, protections, config); + this.protectedRoomsTracker = new ProtectedRoomsSet(client, clientUserId, managementRoomId, this.managementRoomOutput, protections, config); } public get lists(): PolicyList[] { diff --git a/src/ProtectedRoomsConfig.ts b/src/ProtectedRoomsConfig.ts new file mode 100644 index 0000000..0c3643b --- /dev/null +++ b/src/ProtectedRoomsConfig.ts @@ -0,0 +1,129 @@ +/* +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 AwaitLock from 'await-lock'; +import { extractRequestError, LogService, MatrixClient, Permalinks } from "matrix-bot-sdk"; +import { IConfig } from "./config"; +const PROTECTED_ROOMS_EVENT_TYPE = "org.matrix.mjolnir.protected_rooms"; + +/** + * Manages the set of rooms that the user has EXPLICITLY asked to be protected. + */ +export default class ProtectedRoomsConfig { + + /** + * These are rooms that we EXPLICITLY asked Mjolnir to protect, usually via the `rooms add` command. + * These are NOT all of the rooms that mjolnir is protecting as with `config.protectAllJoinedRooms`. + */ + private explicitlyProtectedRooms = new Set(); + /** This is to prevent clobbering the account data for the protected rooms if several rooms are explicitly protected concurrently. */ + private accountDataLock = new AwaitLock(); + + constructor(private readonly client: MatrixClient) { + + } + + /** + * Load any rooms that have been explicitly protected from a Mjolnir config. + * Will also ensure we are able to join all of the rooms. + * @param config The config to load the rooms from under `config.protectedRooms`. + */ + public async loadProtectedRoomsFromConfig(config: IConfig): Promise { + // Ensure we're also joined to the rooms we're protecting + LogService.info("ProtectedRoomsConfig", "Resolving protected rooms..."); + const joinedRooms = await this.client.getJoinedRooms(); + for (const roomRef of config.protectedRooms) { + const permalink = Permalinks.parseUrl(roomRef); + if (!permalink.roomIdOrAlias) continue; + + let roomId = await this.client.resolveRoom(permalink.roomIdOrAlias); + if (!joinedRooms.includes(roomId)) { + roomId = await this.client.joinRoom(permalink.roomIdOrAlias, permalink.viaServers); + } + this.explicitlyProtectedRooms.add(roomId); + } + } + + /** + * Load any rooms that have been explicitly protected from the account data of the mjolnir user. + * Will not ensure we can join all the rooms. This so mjolnir can continue to operate if bogus rooms have been persisted to the account data. + */ + public async loadProtectedRoomsFromAccountData(): Promise { + LogService.debug("ProtectedRoomsConfig", "Loading protected rooms..."); + try { + const data: { rooms?: string[] } | null = await this.client.getAccountData(PROTECTED_ROOMS_EVENT_TYPE); + if (data && data['rooms']) { + for (const roomId of data['rooms']) { + this.explicitlyProtectedRooms.add(roomId); + } + } + } catch (e) { + if (e.statusCode === 404) { + LogService.warn("ProtectedRoomsConfig", "Couldn't find any explicitly protected rooms from Mjolnir's account data, assuming first start.", extractRequestError(e)); + } else { + throw e; + } + } + } + + /** + * Save the room as explicitly protected. + * @param roomId The room to persist as explicitly protected. + */ + public async addProtectedRoom(roomId: string): Promise { + this.explicitlyProtectedRooms.add(roomId); + await this.saveProtectedRoomsToAccountData(); + } + + /** + * Remove the room from the explicitly protected set of rooms. + * @param roomId The room that should no longer be persisted as protected. + */ + public async removeProtectedRoom(roomId: string): Promise { + this.explicitlyProtectedRooms.delete(roomId); + await this.saveProtectedRoomsToAccountData([roomId]); + } + + /** + * Get the set of explicitly protected rooms. + * This will NOT be the complete set of protected rooms, if `config.protectAllJoinedRooms` is true and should never be treated as the complete set. + * @returns The rooms that are marked as explicitly protected in both the config and Mjolnir's account data. + */ + public getExplicitlyProtectedRooms(): string[] { + return [...this.explicitlyProtectedRooms.keys()] + } + + /** + * Persist the set of explicitly protected rooms to the client's account data. + * @param excludeRooms Rooms that should not be persisted to the account data, and removed if already present. + */ + private async saveProtectedRoomsToAccountData(excludeRooms: string[] = []): Promise { + // NOTE: this stops Mjolnir from racing with itself when saving the config + // but it doesn't stop a third party client on the same account racing with us instead. + await this.accountDataLock.acquireAsync(); + try { + const additionalProtectedRooms: string[] = await this.client.getAccountData(PROTECTED_ROOMS_EVENT_TYPE) + .then((rooms: {rooms?: string[]}) => Array.isArray(rooms?.rooms) ? rooms.rooms : []) + .catch(e => (LogService.warn("ProtectedRoomsConfig", "Could not load protected rooms from account data", extractRequestError(e)), [])); + + const roomsToSave = new Set([...this.explicitlyProtectedRooms.keys(), ...additionalProtectedRooms]); + excludeRooms.forEach(roomsToSave.delete, roomsToSave); + await this.client.setAccountData(PROTECTED_ROOMS_EVENT_TYPE, { rooms: Array.from(roomsToSave.keys()) }); + } finally { + this.accountDataLock.release(); + } + } +} diff --git a/src/ProtectedRooms.ts b/src/ProtectedRoomsSet.ts similarity index 99% rename from src/ProtectedRooms.ts rename to src/ProtectedRoomsSet.ts index 797baed..6a3fe83 100644 --- a/src/ProtectedRooms.ts +++ b/src/ProtectedRoomsSet.ts @@ -42,7 +42,7 @@ import { htmlEscape } from "./utils"; * 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 { +export class ProtectedRoomsSet { private protectedRooms = new Set(); @@ -228,7 +228,7 @@ export class ProtectedRooms { } } - public async addProtectedRoom(roomId: string): Promise { + public addProtectedRoom(roomId: string): void { if (this.protectedRooms.has(roomId)) { // we need to protect ourselves form syncing all the lists unnecessarily // as Mjolnir does call this method repeatedly. @@ -236,7 +236,6 @@ export class ProtectedRooms { } this.protectedRooms.add(roomId); this.protectedRoomActivityTracker.addProtectedRoom(roomId); - await this.syncLists(this.config.verboseLogging); } public removeProtectedRoom(roomId: string): void { diff --git a/yarn.lock b/yarn.lock index 83fba16..355e94c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -389,6 +389,11 @@ asynckit@^0.4.0: resolved "https://registry.yarnpkg.com/asynckit/-/asynckit-0.4.0.tgz#c79ed97f7f34cb8f2ba1bc9790bcc366474b4b79" integrity sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q== +await-lock@^2.2.2: + version "2.2.2" + resolved "https://registry.yarnpkg.com/await-lock/-/await-lock-2.2.2.tgz#a95a9b269bfd2f69d22b17a321686f551152bcef" + integrity sha512-aDczADvlvTGajTDjcjpJMqRkOF6Qdz3YbPZm/PyW6tKPkx2hlYBzxMhEywM/tU72HrVZjgl5VCdRuMlA7pZ8Gw== + aws-sign2@~0.7.0: version "0.7.0" resolved "https://registry.yarnpkg.com/aws-sign2/-/aws-sign2-0.7.0.tgz#b46e890934a9591f2d2f6f86d7e6a9f1b3fe76a8"