diff --git a/src/Mjolnir.ts b/src/Mjolnir.ts index 7cba92d..b875f53 100644 --- a/src/Mjolnir.ts +++ b/src/Mjolnir.ts @@ -243,8 +243,13 @@ export class Mjolnir { this.protectionManager = new ProtectionManager(this); this.managementRoomOutput = new ManagementRoomOutput(managementRoomId, client, config); - const protections = new ProtectionManager(this); - this.protectedRoomsTracker = new ProtectedRoomsSet(client, clientUserId, managementRoomId, this.managementRoomOutput, protections, config); + this.protectedRoomsTracker = new ProtectedRoomsSet( + client, + clientUserId, + managementRoomId, + this.managementRoomOutput, + this.protectionManager, + config); } public get state(): string { diff --git a/src/ProtectedRoomsSet.ts b/src/ProtectedRoomsSet.ts index b97c790..e2d41fd 100644 --- a/src/ProtectedRoomsSet.ts +++ b/src/ProtectedRoomsSet.ts @@ -104,12 +104,6 @@ export class ProtectedRoomsSet { private readonly clientUserId: string, private readonly managementRoomId: string, private readonly managementRoomOutput: ManagementRoomOutput, - /** - * The protection manager is only used to verify the permissions - * that the protection manager requires are correct for this set of rooms. - * The protection manager is not really compatible with this abstraction yet - * because of a direct dependency on the protection manager in Mjolnir commands. - */ private readonly protectionManager: ProtectionManager, private readonly config: IConfig, ) { @@ -264,11 +258,13 @@ export class ProtectedRoomsSet { } this.protectedRooms.add(roomId); this.protectedRoomActivityTracker.addProtectedRoom(roomId); + this.protectionManager.addProtectedRoom(roomId); } public removeProtectedRoom(roomId: string): void { this.protectedRoomActivityTracker.removeProtectedRoom(roomId); this.protectedRooms.delete(roomId); + this.protectionManager.removeProtectedRoom(roomId); } /** diff --git a/src/protections/IProtection.ts b/src/protections/IProtection.ts index 4a7e2e0..63a265a 100644 --- a/src/protections/IProtection.ts +++ b/src/protections/IProtection.ts @@ -31,11 +31,26 @@ export abstract class Protection { readonly requiredStatePermissions: string[] = []; abstract settings: { [setting: string]: AbstractProtectionSetting }; + /** + * A new room has been added to the list of rooms to protect with this protection. + */ + async startProtectingRoom(mjolnir: Mjolnir, roomId: string) { + // By default, do nothing. + } + + /** + * A room has been removed from the list of rooms to protect with this protection. + */ + async stopProtectingRoom(mjolnir: Mjolnir, roomId: string) { + // By default, do nothing. + } + /* * Handle a single event from a protected room, to decide if we need to * respond to it */ async handleEvent(mjolnir: Mjolnir, roomId: string, event: any): Promise { + // By default, do nothing. } /* @@ -43,6 +58,7 @@ export abstract class Protection { * need to respond to it */ async handleReport(mjolnir: Mjolnir, roomId: string, reporterId: string, event: any, reason?: string): Promise { + // By default, do nothing. } /** diff --git a/src/protections/LocalAbuseReports.ts b/src/protections/LocalAbuseReports.ts new file mode 100644 index 0000000..c0ff71f --- /dev/null +++ b/src/protections/LocalAbuseReports.ts @@ -0,0 +1,98 @@ +/* +Copyright 2023 Element. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import { LogLevel } from "matrix-bot-sdk"; +import { Mjolnir } from "../Mjolnir"; +import { Protection } from "./IProtection"; + +/* + An implementation of per decentralized abuse reports, as per + https://github.com/Yoric/matrix-doc/blob/aristotle/proposals/3215-towards-decentralized-moderation.md + */ + +const EVENT_MODERATED_BY = "org.matrix.msc3215.room.moderation.moderated_by"; +const EVENT_MODERATOR_OF = "org.matrix.msc3215.room.moderation.moderator_of"; + +/** + * Setup decentralized abuse reports in protected rooms. + */ +export class LocalAbuseReports extends Protection { + settings: { }; + public readonly name = "LocalAbuseReports"; + public readonly description = "Enables MSC3215-compliant web clients to send abuse reports to the moderator instead of the homeserver admin"; + readonly requiredStatePermissions = [EVENT_MODERATED_BY]; + + /** + * A new room has been added to the list of rooms to protect with this protection. + */ + async startProtectingRoom(mjolnir: Mjolnir, protectedRoomId: string) { + try { + const userId = await mjolnir.client.getUserId(); + + // Fetch the previous state of the room, to avoid overwriting any existing setup. + let previousState: /* previous content */ any | /* there was no previous content */ null; + try { + previousState = await mjolnir.client.getRoomStateEvent(protectedRoomId, EVENT_MODERATED_BY, EVENT_MODERATED_BY); + } catch (ex) { + previousState = null; + } + if (previousState && previousState["room_id"] && previousState["user_id"]) { + if (previousState["room_id"] === mjolnir.managementRoomId && previousState["user_id"] === userId) { + // The room is already setup, do nothing. + return; + } else { + // There is a setup already, but it's not for us. Don't overwrite it. + let protectedRoomAliasOrId = await mjolnir.client.getPublishedAlias(protectedRoomId) || protectedRoomId; + mjolnir.managementRoomOutput.logMessage(LogLevel.INFO, "LocalAbuseReports", `Room ${protectedRoomAliasOrId} is already setup for decentralized abuse reports with bot ${previousState["user_id"]} and room ${previousState["room_id"]}, not overwriting automatically. To overwrite, use command \`!mjolnir rooms setup ${protectedRoomId} reporting\``); + return; + } + } + + // Setup protected room -> moderation room link. + // We do this before the other one to be able to fail early if we do not have a sufficient + // powerlevel. + let eventId; + try { + eventId = await mjolnir.client.sendStateEvent(protectedRoomId, EVENT_MODERATED_BY, EVENT_MODERATED_BY, { + room_id: mjolnir.managementRoomId, + user_id: userId, + }); + } catch (ex) { + mjolnir.managementRoomOutput.logMessage(LogLevel.ERROR, "LocalAbuseReports", `Could not autoset protected room -> moderation room link: ${ex.message}. To set it manually, use command \`!mjolnir rooms setup ${protectedRoomId} reporting\``); + return; + } + + try { + // Setup moderation room -> protected room. + await mjolnir.client.sendStateEvent(mjolnir.managementRoomId, EVENT_MODERATOR_OF, protectedRoomId, { + user_id: userId, + }); + } catch (ex) { + // If the second `sendStateEvent` fails, we could end up with a room half setup, which + // is bad. Attempt to rollback. + mjolnir.managementRoomOutput.logMessage(LogLevel.ERROR, "LocalAbuseReports", `Could not autoset moderation room -> protected room link: ${ex.message}. To set it manually, use command \`!mjolnir rooms setup ${protectedRoomId} reporting\``); + try { + await mjolnir.client.redactEvent(protectedRoomId, eventId, "Rolling back incomplete MSC3215 setup"); + } finally { + // Ignore second exception, propagate first. + throw ex; + } + } + } catch (ex) { + mjolnir.managementRoomOutput.logMessage(LogLevel.ERROR, "LocalAbuseReports", ex.message); + } + } +} \ No newline at end of file diff --git a/src/protections/ProtectionManager.ts b/src/protections/ProtectionManager.ts index 11fb163..1a5971d 100644 --- a/src/protections/ProtectionManager.ts +++ b/src/protections/ProtectionManager.ts @@ -30,6 +30,7 @@ import { Consequence } from "./consequence"; import { htmlEscape } from "../utils"; import { ERROR_KIND_FATAL, ERROR_KIND_PERMISSION } from "../ErrorCache"; import { RoomUpdateError } from "../models/RoomUpdateError"; +import { LocalAbuseReports } from "./LocalAbuseReports"; const PROTECTIONS: Protection[] = [ new FirstMessageIsImage(), @@ -40,6 +41,7 @@ const PROTECTIONS: Protection[] = [ new TrustedReporters(), new DetectFederationLag(), new JoinWaveShortCircuit(), + new LocalAbuseReports(), ]; const ENABLED_PROTECTIONS_EVENT_TYPE = "org.matrix.mjolnir.enabled_protections"; @@ -97,6 +99,11 @@ export class ProtectionManager { // this.getProtectionSettings() validates this data for us, so we don't need to protection.settings[key].setValue(value); } + if (protection.enabled) { + for (let roomId of this.mjolnir.protectedRoomsTracker.getProtectedRooms()) { + await protection.startProtectingRoom(this.mjolnir, roomId); + } + } } /* @@ -104,11 +111,17 @@ export class ProtectionManager { * * @param protection The protection object we want to unregister */ - public unregisterProtection(protectionName: string) { - if (!(this._protections.has(protectionName))) { + public async unregisterProtection(protectionName: string) { + let protection = this._protections.get(protectionName); + if (!protection) { throw new Error("Failed to find protection by name: " + protectionName); } this._protections.delete(protectionName); + if (protection.enabled) { + for (let roomId of this.mjolnir.protectedRoomsTracker.getProtectedRooms()) { + await protection.stopProtectingRoom(this.mjolnir, roomId); + } + } } /* @@ -159,9 +172,13 @@ export class ProtectionManager { */ public async enableProtection(name: string) { const protection = this._protections.get(name); - if (protection !== undefined) { - protection.enabled = true; - await this.saveEnabledProtections(); + if (protection === undefined) { + return; + } + protection.enabled = true; + await this.saveEnabledProtections(); + for (let roomId of this.mjolnir.protectedRoomsTracker.getProtectedRooms()) { + await protection.startProtectingRoom(this.mjolnir, roomId); } } @@ -187,9 +204,13 @@ export class ProtectionManager { */ public async disableProtection(name: string) { const protection = this._protections.get(name); - if (protection !== undefined) { - protection.enabled = false; - await this.saveEnabledProtections(); + if (protection === undefined) { + return; + } + protection.enabled = false; + await this.saveEnabledProtections(); + for (let roomId of this.mjolnir.protectedRoomsTracker.getProtectedRooms()) { + await protection.stopProtectingRoom(this.mjolnir, roomId); } } @@ -394,4 +415,24 @@ export class ProtectionManager { await protection.handleReport(this.mjolnir, roomId, reporterId, event, reason); } } + + public async addProtectedRoom(roomId: string) { + for (const protection of this.enabledProtections) { + try { + await protection.startProtectingRoom(this.mjolnir, roomId); + } catch (ex) { + this.mjolnir.managementRoomOutput.logMessage(LogLevel.ERROR, protection.name, ex); + } + } + } + + public async removeProtectedRoom(roomId: string) { + for (const protection of this.enabledProtections) { + try { + await protection.stopProtectingRoom(this.mjolnir, roomId); + } catch (ex) { + this.mjolnir.managementRoomOutput.logMessage(LogLevel.ERROR, protection.name, ex); + } + } + } } diff --git a/test/integration/moderationRequestTest.ts b/test/integration/moderationRequestTest.ts index 21f87e0..9f776ea 100644 --- a/test/integration/moderationRequestTest.ts +++ b/test/integration/moderationRequestTest.ts @@ -16,7 +16,89 @@ const EVENT_MODERATED_BY = "org.matrix.msc3215.room.moderation.moderated_by"; const EVENT_MODERATOR_OF = "org.matrix.msc3215.room.moderation.moderator_of"; const EVENT_MODERATION_REQUEST = "org.matrix.msc3215.abuse.report"; +enum SetupMechanism { + ManualCommand, + Protection +} + describe("Test: Requesting moderation", async () => { + it(`Mjölnir can setup a room for moderation requests using !mjolnir command`, async function() { + // Create a few users and a room, make sure that Mjölnir is moderator in the room. + let goodUser = await newTestUser(this.config.homeserverUrl, { name: { contains: "reporting-abuse-good-user" }}); + let badUser = await newTestUser(this.config.homeserverUrl, { name: { contains: "reporting-abuse-bad-user" }}); + + let roomId = await goodUser.createRoom({ invite: [await badUser.getUserId(), await this.mjolnir.client.getUserId()] }); + await goodUser.inviteUser(await badUser.getUserId(), roomId); + await badUser.joinRoom(roomId); + await goodUser.setUserPowerLevel(await this.mjolnir.client.getUserId(), roomId, 100); + + // Setup moderated_by/moderator_of. + await this.mjolnir.client.sendText(this.mjolnir.managementRoomId, `!mjolnir rooms setup ${roomId} reporting`); + + // Wait until moderated_by/moderator_of are setup + while (true) { + await new Promise(resolve => setTimeout(resolve, 1000)); + try { + await goodUser.getRoomStateEvent(roomId, EVENT_MODERATED_BY, EVENT_MODERATED_BY); + } catch (ex) { + console.log("moderated_by not setup yet, waiting"); + continue; + } + try { + await this.mjolnir.client.getRoomStateEvent(this.mjolnir.managementRoomId, EVENT_MODERATOR_OF, roomId); + } catch (ex) { + console.log("moderator_of not setup yet, waiting"); + continue; + } + break; + } + }); + it(`Mjölnir can setup a room for moderation requests using room protections`, async function() { + await this.mjolnir.protectionManager.enableProtection("LocalAbuseReports"); + + // Create a few users and a room, make sure that Mjölnir is moderator in the room. + let goodUser = await newTestUser(this.config.homeserverUrl, { name: { contains: "reporting-abuse-good-user" }}); + let badUser = await newTestUser(this.config.homeserverUrl, { name: { contains: "reporting-abuse-bad-user" }}); + + let roomId = await goodUser.createRoom({ invite: [await badUser.getUserId(), await this.mjolnir.client.getUserId()] }); + await goodUser.inviteUser(await badUser.getUserId(), roomId); + await badUser.joinRoom(roomId); + await this.mjolnir.client.joinRoom(roomId); + await goodUser.setUserPowerLevel(await this.mjolnir.client.getUserId(), roomId, 100); + + // Wait until Mjölnir has joined the room. + while (true) { + await new Promise(resolve => setTimeout(resolve, 1000)); + const joinedRooms = await this.mjolnir.client.getJoinedRooms(); + console.debug("Looking for room", roomId, "in", joinedRooms); + if (joinedRooms.some(joinedRoomId => joinedRoomId == roomId)) { + break; + } else { + console.log("Mjölnir hasn't joined the room yet, waiting"); + } + } + + // Setup moderated_by/moderator_of. + this.mjolnir.addProtectedRoom(roomId); + + // Wait until moderated_by/moderator_of are setup + while (true) { + await new Promise(resolve => setTimeout(resolve, 1000)); + try { + await goodUser.getRoomStateEvent(roomId, EVENT_MODERATED_BY, EVENT_MODERATED_BY); + } catch (ex) { + console.log("moderated_by not setup yet, waiting"); + continue; + } + try { + await this.mjolnir.client.getRoomStateEvent(this.mjolnir.managementRoomId, EVENT_MODERATOR_OF, roomId); + } catch (ex) { + console.log("moderator_of not setup yet, waiting"); + continue; + } + break; + } + }); it(`Mjölnir propagates moderation requests`, async function() { this.timeout(90000); diff --git a/test/integration/protectionSettingsTest.ts b/test/integration/protectionSettingsTest.ts index 10940fd..ce435ea 100644 --- a/test/integration/protectionSettingsTest.ts +++ b/test/integration/protectionSettingsTest.ts @@ -1,7 +1,7 @@ import { strict as assert } from "assert"; import { Mjolnir } from "../../src/Mjolnir"; -import { IProtection } from "../../src/protections/IProtection"; +import { Protection } from "../../src/protections/IProtection"; import { ProtectionSettingValidationError } from "../../src/protections/ProtectionSettings"; import { NumberProtectionSetting, StringProtectionSetting, StringListProtectionSetting } from "../../src/protections/ProtectionSettings"; import { newTestUser, noticeListener } from "./clientHelper"; @@ -26,7 +26,7 @@ describe("Test: Protection settings", function() { it("Mjolnir successfully saves valid protection setting values", async function() { this.timeout(20000); - await this.mjolnir.protectionManager.registerProtection(new class implements IProtection { + await this.mjolnir.protectionManager.registerProtection(new class extends Protection { name = "05OVMS"; description = "A test protection"; settings = { test: new NumberProtectionSetting(3) }; @@ -41,8 +41,9 @@ describe("Test: Protection settings", function() { it("Mjolnir should accumulate changed settings", async function() { this.timeout(20000); - await this.mjolnir.protectionManager.registerProtection(new class implements IProtection { + await this.mjolnir.protectionManager.registerProtection(new class extends Protection { name = "HPUjKN"; + description = "A test protection"; settings = { test1: new NumberProtectionSetting(3), test2: new NumberProtectionSetting(4) @@ -59,7 +60,7 @@ describe("Test: Protection settings", function() { this.timeout(20000); await client.joinRoom(this.config.managementRoom); - await this.mjolnir.protectionManager.registerProtection(new class implements IProtection { + await this.mjolnir.protectionManager.registerProtection(new class extends Protection { name = "JY2TPN"; description = "A test protection"; settings = { test: new StringProtectionSetting() }; @@ -84,7 +85,7 @@ describe("Test: Protection settings", function() { this.timeout(20000); await client.joinRoom(this.config.managementRoom); - await this.mjolnir.protectionManager.registerProtection(new class implements IProtection { + await this.mjolnir.protectionManager.registerProtection(new class extends Protection { name = "r33XyT"; description = "A test protection"; settings = { test: new StringListProtectionSetting() }; @@ -108,7 +109,7 @@ describe("Test: Protection settings", function() { this.timeout(20000); await client.joinRoom(this.config.managementRoom); - await this.mjolnir.protectionManager.registerProtection(new class implements IProtection { + await this.mjolnir.protectionManager.registerProtection(new class extends Protection { name = "oXzT0E"; description = "A test protection"; settings = { test: new StringListProtectionSetting() }; @@ -133,13 +134,13 @@ describe("Test: Protection settings", function() { this.timeout(20000); await client.joinRoom(this.config.managementRoom); - await this.mjolnir.protectionManager.registerProtection(new class implements IProtection { + await this.mjolnir.protectionManager.registerProtection(new class extends Protection { name = "d0sNrt"; description = "A test protection"; settings = { test: new StringProtectionSetting() }; }); - let replyPromise = new Promise((resolve, reject) => { + let replyPromise: Promise = new Promise((resolve, reject) => { let i = 0; client.on('room.message', noticeListener(this.mjolnir.managementRoomId, (event) => { if (event.content.body.includes("Changed d0sNrt.test ")) { diff --git a/test/integration/reportPollingTest.ts b/test/integration/reportPollingTest.ts index 03aba82..e98f60f 100644 --- a/test/integration/reportPollingTest.ts +++ b/test/integration/reportPollingTest.ts @@ -1,5 +1,5 @@ import { Mjolnir } from "../../src/Mjolnir"; -import { IProtection } from "../../src/protections/IProtection"; +import { Protection } from "../../src/protections/IProtection"; import { newTestUser } from "./clientHelper"; describe("Test: Report polling", function() { @@ -15,18 +15,21 @@ describe("Test: Report polling", function() { await this.mjolnir.addProtectedRoom(protectedRoomId); const eventId = await client.sendMessage(protectedRoomId, {msgtype: "m.text", body: "uwNd3q"}); + class CustomProtection extends Protection { + name = "jYvufI"; + description = "A test protection"; + settings = { }; + constructor(private resolve) { + super(); + } + async handleReport (mjolnir: Mjolnir, roomId: string, reporterId: string, event: any, reason?: string) { + if (reason === "x5h1Je") { + this.resolve(null); + } + } + } await new Promise(async resolve => { - await this.mjolnir.protectionManager.registerProtection(new class implements IProtection { - name = "jYvufI"; - description = "A test protection"; - settings = { }; - handleEvent = async (mjolnir: Mjolnir, roomId: string, event: any) => { }; - handleReport = (mjolnir: Mjolnir, roomId: string, reporterId: string, event: any, reason?: string) => { - if (reason === "x5h1Je") { - resolve(null); - } - }; - }); + await this.mjolnir.protectionManager.registerProtection(new CustomProtection(resolve)); await this.mjolnir.protectionManager.enableProtection("jYvufI"); await client.doRequest( "POST", diff --git a/test/integration/standardConsequenceTest.ts b/test/integration/standardConsequenceTest.ts index f762f4c..ee5e33c 100644 --- a/test/integration/standardConsequenceTest.ts +++ b/test/integration/standardConsequenceTest.ts @@ -1,7 +1,7 @@ import { strict as assert } from "assert"; import { Mjolnir } from "../../src/Mjolnir"; -import { IProtection } from "../../src/protections/IProtection"; +import { Protection } from "../../src/protections/IProtection"; import { newTestUser, noticeListener } from "./clientHelper"; import { matrixClient, mjolnir } from "./mjolnirSetupUtils"; import { ConsequenceBan, ConsequenceRedact } from "../../src/protections/consequence"; @@ -27,7 +27,7 @@ describe("Test: standard consequences", function() { await badUser.joinRoom(protectedRoomId); await this.mjolnir.addProtectedRoom(protectedRoomId); - await this.mjolnir.protectionManager.registerProtection(new class implements IProtection { + await this.mjolnir.protectionManager.registerProtection(new class extends Protection { name = "JY2TPN"; description = "A test protection"; settings = { }; @@ -39,7 +39,7 @@ describe("Test: standard consequences", function() { }); await this.mjolnir.protectionManager.enableProtection("JY2TPN"); - let reply = new Promise(async (resolve, reject) => { + let reply: Promise = new Promise(async (resolve, reject) => { const messageId = await badUser.sendMessage(protectedRoomId, {msgtype: "m.text", body: "ngmWkF"}); let redaction; badUser.on('room.event', (roomId, event) => { @@ -71,7 +71,7 @@ describe("Test: standard consequences", function() { await badUser.joinRoom(protectedRoomId); await this.mjolnir.addProtectedRoom(protectedRoomId); - await this.mjolnir.protectionManager.registerProtection(new class implements IProtection { + await this.mjolnir.protectionManager.registerProtection(new class extends Protection { name = "0LxMTy"; description = "A test protection"; settings = { }; @@ -83,7 +83,7 @@ describe("Test: standard consequences", function() { }); await this.mjolnir.protectionManager.enableProtection("0LxMTy"); - let reply = new Promise(async (resolve, reject) => { + let reply: Promise = new Promise(async (resolve, reject) => { const messageId = await badUser.sendMessage(protectedRoomId, {msgtype: "m.text", body: "7Uga3d"}); let ban; badUser.on('room.leave', (roomId, event) => { @@ -118,7 +118,7 @@ describe("Test: standard consequences", function() { await goodUser.joinRoom(protectedRoomId); await this.mjolnir.addProtectedRoom(protectedRoomId); - await this.mjolnir.protectionManager.registerProtection(new class implements IProtection { + await this.mjolnir.protectionManager.registerProtection(new class extends Protection { name = "95B1Cr"; description = "A test protection"; settings = { };