diff --git a/src/Mjolnir.ts b/src/Mjolnir.ts index 3c1c098..596ed9c 100644 --- a/src/Mjolnir.ts +++ b/src/Mjolnir.ts @@ -47,6 +47,7 @@ import { WebAPIs } from "./webapis/WebAPIs"; import { replaceRoomIdsWithPills } from "./utils"; import RuleServer from "./models/RuleServer"; import { RoomMemberManager } from "./RoomMembers"; +import { ProtectedRoomActivityTracker } from "./queues/ProtectedRoomActivityTracker"; const levelToFn = { [LogLevel.DEBUG.toString()]: LogService.debug, @@ -83,10 +84,17 @@ export class Mjolnir { */ 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[] = []; + /** + * 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 knownUnprotectedRooms: string[] = []; + private unprotectedWatchedListRooms: string[] = []; private webapis: WebAPIs; + private protectedRoomActivityTracker: ProtectedRoomActivityTracker; /** * Adds a listener to the client that will automatically accept invitations. * @param {MatrixClient} client @@ -167,6 +175,10 @@ export class Mjolnir { constructor( public readonly client: MatrixClient, public readonly managementRoomId: string, + /* + * All the rooms that Mjolnir is protecting and their permalinks. + * If `config.protectAllJoinedRooms` is specified, then `protectedRooms` will be all joined rooms except watched banlists that we can't protect (because they aren't curated by us). + */ public readonly protectedRooms: { [roomId: string]: string }, private banLists: BanList[], // Combines the rules from ban lists so they can be served to a homeserver module or another consumer. @@ -235,6 +247,9 @@ 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); @@ -293,6 +308,7 @@ 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) { @@ -372,9 +388,10 @@ export class Mjolnir { public async addProtectedRoom(roomId: string) { this.protectedRooms[roomId] = Permalinks.forRoom(roomId); this.roomJoins.addRoom(roomId); + this.protectedRoomActivityTracker.addProtectedRoom(roomId); - const unprotectedIdx = this.knownUnprotectedRooms.indexOf(roomId); - if (unprotectedIdx >= 0) this.knownUnprotectedRooms.splice(unprotectedIdx, 1); + const unprotectedIdx = this.unprotectedWatchedListRooms.indexOf(roomId); + if (unprotectedIdx >= 0) this.unprotectedWatchedListRooms.splice(unprotectedIdx, 1); this.explicitlyProtectedRoomIds.push(roomId); let additionalProtectedRooms: { rooms?: string[] } | null = null; @@ -392,6 +409,7 @@ export class Mjolnir { 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); @@ -412,15 +430,19 @@ export class Mjolnir { const joinedRoomIds = (await this.client.getJoinedRooms()).filter(r => r !== this.managementRoomId); 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); if (!joinedRoomIdsSet.has(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); if (!oldRoomIdsSet.has(roomId)) { this.roomJoins.addRoom(roomId); } @@ -662,7 +684,7 @@ export class Mjolnir { const createEvent = new CreateEvent(await this.client.getRoomStateEvent(roomId, "m.room.create", "")); if (createEvent.creator === await this.client.getUserId()) return; // we created it - if (!this.knownUnprotectedRooms.includes(roomId)) this.knownUnprotectedRooms.push(roomId); + if (!this.unprotectedWatchedListRooms.includes(roomId)) this.unprotectedWatchedListRooms.push(roomId); this.applyUnprotectedRooms(); try { @@ -677,8 +699,9 @@ export class Mjolnir { } private applyUnprotectedRooms() { - for (const roomId of this.knownUnprotectedRooms) { + for (const roomId of this.unprotectedWatchedListRooms) { delete this.protectedRooms[roomId]; + this.protectedRoomActivityTracker.removeProtectedRoom(roomId); } } @@ -798,6 +821,13 @@ export class Mjolnir { return errors; } + /** + * @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. @@ -809,9 +839,10 @@ export class Mjolnir { } let hadErrors = false; - - const aclErrors = await applyServerAcls(this.banLists, Object.keys(this.protectedRooms), this); - const banErrors = await applyUserBans(this.banLists, Object.keys(this.protectedRooms), this); + const [aclErrors, banErrors] = await Promise.all([ + applyServerAcls(this.banLists, this.protectedRoomsByActivity(), this), + applyUserBans(this.banLists, 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:"); @@ -839,8 +870,10 @@ export class Mjolnir { const changes = await banList.updateList(); let hadErrors = false; - const aclErrors = await applyServerAcls(this.banLists, Object.keys(this.protectedRooms), this); - const banErrors = await applyUserBans(this.banLists, Object.keys(this.protectedRooms), this); + const [aclErrors, banErrors] = await Promise.all([ + applyServerAcls(this.banLists, this.protectedRoomsByActivity(), this), + applyUserBans(this.banLists, 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:"); diff --git a/src/queues/ProtectedRoomActivityTracker.ts b/src/queues/ProtectedRoomActivityTracker.ts new file mode 100644 index 0000000..33692c1 --- /dev/null +++ b/src/queues/ProtectedRoomActivityTracker.ts @@ -0,0 +1,79 @@ +/* +Copyright 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 { MatrixClient } from "matrix-bot-sdk"; + +/** + * Used to keep track of protected rooms so they are always ordered for activity. + * + * We use the same method as Element web for this, the major disadvantage being that we sort on each access to the room list (sort by most recently active first). + * We have tried to mitigate this by caching the sorted list until the activity in rooms changes again. + * See https://github.com/matrix-org/matrix-react-sdk/blob/8a0398b632dff1a5f6cfd4bf95d78854aeadc60e/src/stores/room-list/algorithms/tag-sorting/RecentAlgorithm.ts + * + */ +export class ProtectedRoomActivityTracker { + private protectedRoomActivities = new Map(); + /** + * 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. + * @param roomId The room Mjolnir is now protecting. + */ + public addProtectedRoom(roomId: string): void { + this.protectedRoomActivities.set(roomId, /* epoch */ 0); + } + + /** + * Inform the trakcer that a room is no longer being protected by Mjolnir. + * @param roomId The roomId that is no longer being protected by Mjolnir. + */ + public removeProtectedRoom(roomId: string): void { + this.protectedRoomActivities.delete(roomId); + } + + /** + * 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); + if (last_origin_server_ts !== undefined && Number.isInteger(event.origin_server_ts)) { + if (event.origin_server_ts > last_origin_server_ts) { + this.activeRoomsCache = null; + this.protectedRoomActivities.set(roomId, event.origin_server_ts); + } + } + } + + /** + * @returns A list of protected rooms ids ordered by activity. + */ + public protectedRoomsByActivity(): string[] { + if (!this.activeRoomsCache) { + this.activeRoomsCache = [...this.protectedRoomActivities] + .sort((a, b) => b[1] - a[1]) + .map(pair => pair[0]); + } + return this.activeRoomsCache; + } +} + diff --git a/test/integration/banListTest.ts b/test/integration/banListTest.ts index a6bed47..a646373 100644 --- a/test/integration/banListTest.ts +++ b/test/integration/banListTest.ts @@ -1,11 +1,11 @@ import { strict as assert } from "assert"; import config from "../../src/config"; -import { newTestUser, noticeListener } from "./clientHelper"; +import { newTestUser } from "./clientHelper"; import { LogService, MatrixClient, Permalinks, UserID } from "matrix-bot-sdk"; import BanList, { ALL_RULE_TYPES, ChangeType, ListRuleChange, RULE_SERVER, RULE_USER, SERVER_RULE_TYPES } from "../../src/models/BanList"; -import { ServerAcl, ServerAclContent } from "../../src/models/ServerAcl"; -import { createBanList, getFirstReaction } from "./commands/commandUtils"; +import { ServerAcl } from "../../src/models/ServerAcl"; +import { getFirstReaction } from "./commands/commandUtils"; import { getMessagesByUserIn } from "../../src/utils"; /** @@ -363,3 +363,80 @@ describe('Test: unbaning entities via the BanList.', function () { assert.equal(aclAfter.deny.length, 0, 'Should be no servers denied anymore'); }) }) + +describe.only('Test: should apply bans to the most recently active rooms first', function () { + it('Applies bans to the most recently active rooms first', async function () { + this.timeout(6000000000) + const mjolnir = config.RUNTIME.client! + const serverName: string = new UserID(await mjolnir.getUserId()).domain + const moderator = await newTestUser({ name: { contains: "moderator" }}); + moderator.joinRoom(this.mjolnir.managementRoomId); + const mjolnirId = await mjolnir.getUserId(); + + // Setup some protected rooms so we can check their ACL state later. + const protectedRooms: string[] = []; + for (let i = 0; i < 10; i++) { + const room = await moderator.createRoom({ invite: [mjolnirId]}); + await mjolnir.joinRoom(room); + await moderator.setUserPowerLevel(mjolnirId, room, 100); + await this.mjolnir!.addProtectedRoom(room); + protectedRooms.push(room); + } + + // If a previous test hasn't cleaned up properly, these rooms will be populated by bogus ACLs at this point. + await this.mjolnir!.syncLists(); + await Promise.all(protectedRooms.map(async room => { + const roomAcl = await mjolnir.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.'); + })); + + // Flood the watched list with banned servers, which should prompt Mjolnir to update server ACL in protected rooms. + const banListId = await moderator.createRoom({ invite: [mjolnirId] }); + mjolnir.joinRoom(banListId); + this.mjolnir!.watchList(Permalinks.forRoom(banListId)); + + await this.mjolnir!.syncLists(); + + // 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--) { + const j = Math.floor(Math.random() * (i + 1)); + [protectedRooms[i], protectedRooms[j]] = [protectedRooms[j], protectedRooms[i]]; + } + // create some activity in the same order. + for (const roomId of protectedRooms.slice().reverse()) { + await mjolnir.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(this.mjolnir!.protectedRoomsByActivity()[i], protectedRooms[i]); + } + + const badServer = `evil.com`; + // just ban one server + const acl = new ServerAcl(serverName).denyIpAddresses().allowServer("*").denyServer(badServer); + await createPolicyRule(moderator, banListId, RULE_SERVER, badServer, `Rule ${badServer}`); + + // Wait until all the ACL events have been applied. + await this.mjolnir!.syncLists(); + + for (let i = 0; i < protectedRooms.length; i++) { + assert.equal(this.mjolnir!.protectedRoomsByActivity()[i], protectedRooms.at(-i - 1)); + } + + // Check that the most recently active rooms got the ACL update first. + let last_event_ts = 0; + for (const roomId of protectedRooms) { + let roomAclEvent: null|any; + // Can't be the best way to get the whole event, but ok. + await getMessagesByUserIn(mjolnir, mjolnirId, roomId, 1, events => roomAclEvent = events[0]); + const roomAcl = roomAclEvent!.content; + if (!acl.matches(roomAcl)) { + assert.fail(`Room ${roomId} doesn't have the correct ACL: ${JSON.stringify(roomAcl, null, 2)}`) + } + assert.equal(roomAclEvent.origin_server_ts > last_event_ts, true, `This room was more recently active so should have the more recent timestamp`); + last_event_ts = roomAclEvent.origin_server_ts; + } + }) +}) diff --git a/tsconfig.json b/tsconfig.json index 0327a13..d4c654f 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -21,6 +21,7 @@ "include": [ "./src/**/*", "./test/integration/manualLaunchScript.ts", - "./test/integration/roomMembersTest.ts" + "./test/integration/roomMembersTest.ts", + "./test/integration/banListTest.ts" ] }