From aa7e8d46c848c2956fda09a29f937a1297dba58d Mon Sep 17 00:00:00 2001 From: Shay Date: Thu, 10 Oct 2024 11:02:34 -0700 Subject: [PATCH] Use admin api for redactions if possible (#538) * clairfy room variable * use admin api for redactions if possible * tests * refactor to handle queued redactions * pull out functions * lint * use const --- src/Mjolnir.ts | 3 + src/ProtectedRoomsSet.ts | 7 +- src/commands/RedactCommand.ts | 10 +- src/queues/EventRedactionQueue.ts | 6 +- src/utils.ts | 93 +++++-- .../integration/commands/redactCommandTest.ts | 241 +++++++++++++++++- 6 files changed, 335 insertions(+), 25 deletions(-) diff --git a/src/Mjolnir.ts b/src/Mjolnir.ts index 0fd89bc4..c5a5ab23 100644 --- a/src/Mjolnir.ts +++ b/src/Mjolnir.ts @@ -351,11 +351,14 @@ export class Mjolnir { } this.currentState = STATE_RUNNING; + await this.managementRoomOutput.logMessage( LogLevel.INFO, "Mjolnir@startup", "Startup complete. Now monitoring rooms.", ); + // update protected rooms set + this.protectedRoomsTracker.isAdmin = await this.isSynapseAdmin(); } catch (err) { try { LogService.error("Mjolnir", "Error during startup:"); diff --git a/src/ProtectedRoomsSet.ts b/src/ProtectedRoomsSet.ts index 4a4d36d5..c1e78b25 100644 --- a/src/ProtectedRoomsSet.ts +++ b/src/ProtectedRoomsSet.ts @@ -101,6 +101,11 @@ export class ProtectedRoomsSet { /** The last revision we used to sync protected rooms. */ Revision >(); + /** + * whether the mjolnir instance is server admin + */ + public isAdmin = false; + constructor( private readonly client: MatrixSendClient, private readonly clientUserId: string, @@ -126,7 +131,7 @@ export class ProtectedRoomsSet { * @param roomId The room we want to redact them in. */ public redactUser(userId: string, roomId: string) { - this.eventRedactionQueue.add(new RedactUserInRoom(userId, roomId)); + this.eventRedactionQueue.add(new RedactUserInRoom(userId, roomId, this.isAdmin)); } /** diff --git a/src/commands/RedactCommand.ts b/src/commands/RedactCommand.ts index c6a12a6c..9a7c817d 100644 --- a/src/commands/RedactCommand.ts +++ b/src/commands/RedactCommand.ts @@ -21,10 +21,11 @@ import { Permalinks } from "@vector-im/matrix-bot-sdk"; // !mjolnir redact [room alias] [limit] export async function execRedactCommand(roomId: string, event: any, mjolnir: Mjolnir, parts: string[]) { const userId = parts[2]; - let roomAlias: string | null = null; + + let targetRoom: string | null = null; let limit = Number.parseInt(parts.length > 3 ? parts[3] : "", 10); // default to NaN for later if (parts.length > 3 && isNaN(limit)) { - roomAlias = await mjolnir.client.resolveRoom(parts[3]); + targetRoom = await mjolnir.client.resolveRoom(parts[3]); if (parts.length > 4) { limit = Number.parseInt(parts[4], 10); } @@ -49,8 +50,9 @@ export async function execRedactCommand(roomId: string, event: any, mjolnir: Mjo return; } - const targetRoomIds = roomAlias ? [roomAlias] : mjolnir.protectedRoomsTracker.getProtectedRooms(); - await redactUserMessagesIn(mjolnir.client, mjolnir.managementRoomOutput, userId, targetRoomIds, limit); + const targetRoomIds = targetRoom ? [targetRoom] : mjolnir.protectedRoomsTracker.getProtectedRooms(); + const isAdmin = await mjolnir.isSynapseAdmin(); + await redactUserMessagesIn(mjolnir.client, mjolnir.managementRoomOutput, userId, targetRoomIds, isAdmin, limit); await mjolnir.client.unstableApis.addReactionToEvent(roomId, event["event_id"], "✅"); await mjolnir.client.redactEvent(roomId, processingReactionId, "done processing"); diff --git a/src/queues/EventRedactionQueue.ts b/src/queues/EventRedactionQueue.ts index 0ef7926c..5aff9158 100644 --- a/src/queues/EventRedactionQueue.ts +++ b/src/queues/EventRedactionQueue.ts @@ -42,10 +42,12 @@ export interface QueuedRedaction { export class RedactUserInRoom implements QueuedRedaction { userId: string; roomId: string; + isAdmin: boolean; - constructor(userId: string, roomId: string) { + constructor(userId: string, roomId: string, isAdmin: boolean) { this.userId = userId; this.roomId = roomId; + this.isAdmin = isAdmin; } public async redact(client: MatrixClient, managementRoom: ManagementRoomOutput) { @@ -54,7 +56,7 @@ export class RedactUserInRoom implements QueuedRedaction { "Mjolnir", `Redacting events from ${this.userId} in room ${this.roomId}.`, ); - await redactUserMessagesIn(client, managementRoom, this.userId, [this.roomId]); + await redactUserMessagesIn(client, managementRoom, this.userId, [this.roomId], this.isAdmin); } public redactionEqual(redaction: QueuedRedaction): boolean { diff --git a/src/utils.ts b/src/utils.ts index 82a03159..fb480a24 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -14,16 +14,23 @@ See the License for the specific language governing permissions and limitations under the License. */ -import { LogLevel, LogService, MatrixGlob, getRequestFn, setRequestFn } from "@vector-im/matrix-bot-sdk"; +import { + LogLevel, + LogService, + MatrixGlob, + getRequestFn, + setRequestFn, + extractRequestError, +} from "@vector-im/matrix-bot-sdk"; import { ClientRequest, IncomingMessage } from "http"; import { default as parseDuration } from "parse-duration"; import * as Sentry from "@sentry/node"; import * as _ from "@sentry/tracing"; // Performing the import activates tracing. import { collectDefaultMetrics, Counter, Histogram, register } from "prom-client"; -import ManagementRoomOutput from "./ManagementRoomOutput"; import { IHealthConfig } from "./config"; import { MatrixSendClient } from "./MatrixEmitter"; +import ManagementRoomOutput from "./ManagementRoomOutput"; // Define a few aliases to simplify parsing durations. @@ -67,27 +74,33 @@ export function isTrueJoinEvent(event: any): boolean { return membership === "join" && prevMembership !== "join"; } -/** - * Redact a user's messages in a set of rooms. - * See `getMessagesByUserIn`. - * - * @param client Client to redact the messages with. - * @param managementRoom Management room to log messages back to. - * @param userIdOrGlob A mxid or a glob which is applied to the whole sender field of events in the room, which will be redacted if they match. - * See `MatrixGlob` in matrix-bot-sdk. - * @param targetRoomIds Rooms to redact the messages from. - * @param limit The number of messages to redact from most recent first. If the limit is reached then no further messages will be redacted. - * @param noop Whether to operate in noop mode. - */ -export async function redactUserMessagesIn( +async function adminRedactUserMessagesIn( + client: MatrixSendClient, + managementRoom: ManagementRoomOutput, + userId: string, + targetRooms: string[], + limit = 1000, +) { + const body = { limit: limit, rooms: targetRooms }; + const redactEndpoint = `/_synapse/admin/v1/user/${userId}/redact`; + const response = await client.doRequest("GET", redactEndpoint, null, body); + const redactID = response["redact_id"]; + await managementRoom.logMessage( + LogLevel.INFO, + "utils#redactUserMessagesIn", + `Successfully requested redaction, ID for task is ${redactID}`, + ); +} + +async function botRedactUserMessagesIn( client: MatrixSendClient, managementRoom: ManagementRoomOutput, userIdOrGlob: string, - targetRoomIds: string[], + targetRooms: string[], limit = 1000, noop = false, ) { - for (const targetRoomId of targetRoomIds) { + for (const targetRoomId of targetRooms) { await managementRoom.logMessage( LogLevel.DEBUG, "utils#redactUserMessagesIn", @@ -127,6 +140,52 @@ export async function redactUserMessagesIn( } } +/** + * Redact a user's messages in a set of rooms. + * See `getMessagesByUserIn`. + * + * @param client Client to redact the messages with. + * @param managementRoom Management room to log messages back to. + * @param userIdOrGlob A mxid or a glob which is applied to the whole sender field of events in the room, which will be redacted if they match. + * See `MatrixGlob` in matrix-bot-sdk. + * @param targetRoomIds Rooms to redact the messages from. + * @param isAdmin whether the bot is server admin + * @param limit The number of messages to redact from most recent first. If the limit is reached then no further messages will be redacted. + * @param noop Whether to operate in noop mode. + */ + +export async function redactUserMessagesIn( + client: MatrixSendClient, + managementRoom: ManagementRoomOutput, + userIdOrGlob: string, + targetRoomIds: string[], + isAdmin: boolean, + limit = 1000, + noop = false, +) { + const usingGlob = userIdOrGlob.includes("*"); + // if admin use the Admin API, but admin endpoint does not support globs + if (isAdmin && !usingGlob) { + try { + await adminRedactUserMessagesIn(client, managementRoom, userIdOrGlob, targetRoomIds, limit); + } catch (e) { + LogService.error( + "utils#redactUserMessagesIn", + `Error using admin API to redact messages: ${extractRequestError(e)}`, + ); + await managementRoom.logMessage( + LogLevel.ERROR, + "utils#redactUserMessagesIn", + `Error using admin API to redact messages for user ${userIdOrGlob}, please check logs for more info - falling + back to non-admin redaction process.`, + ); + await botRedactUserMessagesIn(client, managementRoom, userIdOrGlob, targetRoomIds, limit, noop); + } + } else { + await botRedactUserMessagesIn(client, managementRoom, userIdOrGlob, targetRoomIds, limit, noop); + } +} + /** * Gets all the events sent by a user (or users if using wildcards) in a given room ID, since * the time they joined. diff --git a/test/integration/commands/redactCommandTest.ts b/test/integration/commands/redactCommandTest.ts index 0c22417b..e0d20afb 100644 --- a/test/integration/commands/redactCommandTest.ts +++ b/test/integration/commands/redactCommandTest.ts @@ -4,8 +4,16 @@ import { newTestUser } from "../clientHelper"; import { getMessagesByUserIn } from "../../../src/utils"; import { LogService } from "@vector-im/matrix-bot-sdk"; import { getFirstReaction } from "./commandUtils"; +import { SynapseAdminApis } from "@vector-im/matrix-bot-sdk"; -describe("Test: The redaction command", function () { +describe("Test: The redaction command - if admin", function () { + this.beforeEach(async function () { + // verify mjolnir is admin + const admin = await this.mjolnir.isSynapseAdmin(); + if (!admin) { + throw new Error(`Mjolnir needs to be admin for this test.`); + } + }); // If a test has a timeout while awaitng on a promise then we never get given control back. afterEach(function () { this.moderator?.stop(); @@ -175,3 +183,234 @@ describe("Test: The redaction command", function () { assert.equal(Object.keys(redactedEvent.content).length, 0, "This event should have been redacted"); }); }); + +describe("Test: The redaction command - if not admin", function () { + // If a test has a timeout while awaiting on a promise then we never get given control back. + afterEach(function () { + this.moderator?.stop(); + }); + + it("Mjölnir redacts all of the events sent by a spammer when instructed to by giving their id and a room id.", async function () { + this.timeout(60000); + // Create a few users and a room. + let badUser = await newTestUser(this.config.homeserverUrl, { name: { contains: "spammer-needs-redacting" } }); + let badUserId = await badUser.getUserId(); + const mjolnir = this.config.RUNTIME.client!; + let mjolnirUserId = await mjolnir.getUserId(); + + // demote mjolnir from admin + let newAdmin = await newTestUser(this.config.homeserverUrl, { name: { contains: "new-admin" } }); + const adminUserId = await newAdmin.getUserId(); + const mjolnirAdmin = new SynapseAdminApis(this.mjolnir.client); + await mjolnirAdmin.upsertUser(adminUserId, { admin: true }); + const newAdminClient = new SynapseAdminApis(newAdmin); + await newAdminClient.upsertUser(mjolnirUserId, { admin: false }); + const admin = await this.mjolnir.isSynapseAdmin(); + if (admin) { + throw new Error(`Mjolnir needs to not be admin for this test.`); + } + + let moderator = await newTestUser(this.config.homeserverUrl, { name: { contains: "moderator" } }); + this.moderator = moderator; + await moderator.joinRoom(this.config.managementRoom); + let targetRoom = await moderator.createRoom({ invite: [await badUser.getUserId(), mjolnirUserId] }); + await moderator.setUserPowerLevel(mjolnirUserId, targetRoom, 100); + await badUser.joinRoom(targetRoom); + moderator.sendMessage(this.mjolnir.managementRoomId, { + msgtype: "m.text.", + body: `!mjolnir rooms add ${targetRoom}`, + }); + + LogService.debug("redactionTest", `targetRoom: ${targetRoom}, managementRoom: ${this.config.managementRoom}`); + // Sandwich irrelevant messages in bad messages. + await badUser.sendMessage(targetRoom, { msgtype: "m.text", body: "Very Bad Stuff" }); + await Promise.all( + [...Array(50).keys()].map((i) => + moderator.sendMessage(targetRoom, { msgtype: "m.text.", body: `Irrelevant Message #${i}` }), + ), + ); + for (let i = 0; i < 5; i++) { + await badUser.sendMessage(targetRoom, { msgtype: "m.text", body: "Very Bad Stuff" }); + } + await Promise.all( + [...Array(50).keys()].map((i) => + moderator.sendMessage(targetRoom, { msgtype: "m.text.", body: `Irrelevant Message #${i}` }), + ), + ); + await badUser.sendMessage(targetRoom, { msgtype: "m.text", body: "Very Bad Stuff" }); + + try { + await moderator.start(); + await getFirstReaction(moderator, this.mjolnir.managementRoomId, "✅", async () => { + return await moderator.sendMessage(this.mjolnir.managementRoomId, { + msgtype: "m.text", + body: `!mjolnir redact ${badUserId} ${targetRoom}`, + }); + }); + } finally { + moderator.stop(); + } + + await getMessagesByUserIn(moderator, badUserId, targetRoom, 1000, function (events) { + events.map((e) => { + if (e.type === "m.room.member") { + assert.equal( + Object.keys(e.content).length, + 1, + "Only membership should be left on the membership even when it has been redacted.", + ); + } else if (Object.keys(e.content).length !== 0) { + throw new Error(`This event should have been redacted: ${JSON.stringify(e, null, 2)}`); + } + }); + }); + + // reinstall mjolnir as admin before reference to new admin account goes away + await newAdminClient.upsertUser(mjolnirUserId, { admin: true }); + const returnedAdmin = await this.mjolnir.isSynapseAdmin(); + if (!returnedAdmin) { + throw new Error(`Error restoring mjolnir to admin.`); + } + }); + + it("Mjölnir redacts all of the events sent by a spammer when instructed to by giving their id in multiple rooms.", async function () { + this.timeout(60000); + // Create a few users and a room. + let badUser = await newTestUser(this.config.homeserverUrl, { name: { contains: "spammer-needs-redacting" } }); + let badUserId = await badUser.getUserId(); + const mjolnir = this.config.RUNTIME.client!; + let mjolnirUserId = await mjolnir.getUserId(); + + // demote mjolnir from admin + let newAdmin = await newTestUser(this.config.homeserverUrl, { name: { contains: "new-admin" } }); + const adminUserId = await newAdmin.getUserId(); + const mjolnirAdmin = new SynapseAdminApis(this.mjolnir.client); + await mjolnirAdmin.upsertUser(adminUserId, { admin: true }); + const newAdminClient = new SynapseAdminApis(newAdmin); + await newAdminClient.upsertUser(mjolnirUserId, { admin: false }); + const admin = await this.mjolnir.isSynapseAdmin(); + if (admin) { + throw new Error(`Mjolnir needs to not be admin for this test.`); + } + + let moderator = await newTestUser(this.config.homeserverUrl, { name: { contains: "moderator" } }); + this.moderator = moderator; + await moderator.joinRoom(this.config.managementRoom); + let targetRooms: string[] = []; + for (let i = 0; i < 5; i++) { + let targetRoom = await moderator.createRoom({ invite: [await badUser.getUserId(), mjolnirUserId] }); + await moderator.setUserPowerLevel(mjolnirUserId, targetRoom, 100); + await badUser.joinRoom(targetRoom); + await moderator.sendMessage(this.mjolnir.managementRoomId, { + msgtype: "m.text.", + body: `!mjolnir rooms add ${targetRoom}`, + }); + targetRooms.push(targetRoom); + + // Sandwich irrelevant messages in bad messages. + await badUser.sendMessage(targetRoom, { msgtype: "m.text", body: "Very Bad Stuff" }); + await Promise.all( + [...Array(50).keys()].map((j) => + moderator.sendMessage(targetRoom, { msgtype: "m.text.", body: `Irrelevant Message #${j}` }), + ), + ); + for (let j = 0; j < 5; j++) { + await badUser.sendMessage(targetRoom, { msgtype: "m.text", body: "Very Bad Stuff" }); + } + await Promise.all( + [...Array(50).keys()].map((j) => + moderator.sendMessage(targetRoom, { msgtype: "m.text.", body: `Irrelevant Message #${j}` }), + ), + ); + await badUser.sendMessage(targetRoom, { msgtype: "m.text", body: "Very Bad Stuff" }); + } + + try { + await moderator.start(); + await getFirstReaction(moderator, this.mjolnir.managementRoomId, "✅", async () => { + return await moderator.sendMessage(this.mjolnir.managementRoomId, { + msgtype: "m.text", + body: `!mjolnir redact ${badUserId}`, + }); + }); + } finally { + moderator.stop(); + } + + targetRooms.map(async (targetRoom) => { + await getMessagesByUserIn(moderator, badUserId, targetRoom, 1000, function (events) { + events.map((e) => { + if (e.type === "m.room.member") { + assert.equal( + Object.keys(e.content).length, + 1, + "Only membership should be left on the membership even when it has been redacted.", + ); + } else if (Object.keys(e.content).length !== 0) { + throw new Error(`This event should have been redacted: ${JSON.stringify(e, null, 2)}`); + } + }); + }); + }); + // reinstall mjolnir as admin before reference to new admin account goes away + await newAdminClient.upsertUser(mjolnirUserId, { admin: true }); + const returnedAdmin = await this.mjolnir.isSynapseAdmin(); + if (!returnedAdmin) { + throw new Error(`Error restoring mjolnir to admin.`); + } + }); + + it("Redacts a single event when instructed to.", async function () { + this.timeout(60000); + // Create a few users and a room. + let badUser = await newTestUser(this.config.homeserverUrl, { name: { contains: "spammer-needs-redacting" } }); + const mjolnir = this.config.RUNTIME.client!; + let mjolnirUserId = await mjolnir.getUserId(); + + // demote mjolnir from admin + let newAdmin = await newTestUser(this.config.homeserverUrl, { name: { contains: "new-admin" } }); + const adminUserId = await newAdmin.getUserId(); + const mjolnirAdmin = new SynapseAdminApis(this.mjolnir.client); + await mjolnirAdmin.upsertUser(adminUserId, { admin: true }); + const newAdminClient = new SynapseAdminApis(newAdmin); + await newAdminClient.upsertUser(mjolnirUserId, { admin: false }); + const admin = await this.mjolnir.isSynapseAdmin(); + if (admin) { + throw new Error(`Mjolnir needs to not be admin for this test.`); + } + + let moderator = await newTestUser(this.config.homeserverUrl, { name: { contains: "moderator" } }); + this.moderator = moderator; + await moderator.joinRoom(this.config.managementRoom); + let targetRoom = await moderator.createRoom({ invite: [await badUser.getUserId(), mjolnirUserId] }); + await moderator.setUserPowerLevel(mjolnirUserId, targetRoom, 100); + await badUser.joinRoom(targetRoom); + moderator.sendMessage(this.mjolnir.managementRoomId, { + msgtype: "m.text.", + body: `!mjolnir rooms add ${targetRoom}`, + }); + let eventToRedact = await badUser.sendMessage(targetRoom, { msgtype: "m.text", body: "Very Bad Stuff" }); + + try { + await moderator.start(); + await getFirstReaction(moderator, this.mjolnir.managementRoomId, "✅", async () => { + return await moderator.sendMessage(this.mjolnir.managementRoomId, { + msgtype: "m.text", + body: `!mjolnir redact https://matrix.to/#/${encodeURIComponent(targetRoom)}/${encodeURIComponent(eventToRedact)}`, + }); + }); + } finally { + moderator.stop(); + } + + let redactedEvent = await moderator.getEvent(targetRoom, eventToRedact); + assert.equal(Object.keys(redactedEvent.content).length, 0, "This event should have been redacted"); + + // reinstall mjolnir as admin before reference to new admin account goes away + await newAdminClient.upsertUser(mjolnirUserId, { admin: true }); + const returnedAdmin = await this.mjolnir.isSynapseAdmin(); + if (!returnedAdmin) { + throw new Error(`Error restoring mjolnir to admin.`); + } + }); +});