Skip to content

Commit

Permalink
Use admin api for redactions if possible (#538)
Browse files Browse the repository at this point in the history
* clairfy room variable

* use admin api for redactions if possible

* tests

* refactor to handle queued redactions

* pull out functions

* lint

* use const
H-Shay authored Oct 10, 2024
1 parent 943f878 commit aa7e8d4
Showing 6 changed files with 335 additions and 25 deletions.
3 changes: 3 additions & 0 deletions src/Mjolnir.ts
Original file line number Diff line number Diff line change
@@ -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:");
7 changes: 6 additions & 1 deletion src/ProtectedRoomsSet.ts
Original file line number Diff line number Diff line change
@@ -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));
}

/**
10 changes: 6 additions & 4 deletions src/commands/RedactCommand.ts
Original file line number Diff line number Diff line change
@@ -21,10 +21,11 @@ import { Permalinks } from "@vector-im/matrix-bot-sdk";
// !mjolnir redact <user ID> [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");
6 changes: 4 additions & 2 deletions src/queues/EventRedactionQueue.ts
Original file line number Diff line number Diff line change
@@ -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 {
93 changes: 76 additions & 17 deletions src/utils.ts
Original file line number Diff line number Diff line change
@@ -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.
241 changes: 240 additions & 1 deletion test/integration/commands/redactCommandTest.ts
Original file line number Diff line number Diff line change
@@ -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.`);
}
});
});

0 comments on commit aa7e8d4

Please sign in to comment.