diff --git a/package.json b/package.json index 52acd988..abf8b3da 100644 --- a/package.json +++ b/package.json @@ -70,8 +70,8 @@ "jsdom": "^24.0.0", "matrix-appservice-bridge": "^10.3.1", "matrix-bot-sdk": "npm:@vector-im/matrix-bot-sdk@^0.7.1-element.6", - "matrix-protection-suite": "npm:@gnuxie/matrix-protection-suite@2.3.0", - "matrix-protection-suite-for-matrix-bot-sdk": "npm:@gnuxie/matrix-protection-suite-for-matrix-bot-sdk@2.3.2", + "matrix-protection-suite": "npm:@gnuxie/matrix-protection-suite@2.4.0", + "matrix-protection-suite-for-matrix-bot-sdk": "npm:@gnuxie/matrix-protection-suite-for-matrix-bot-sdk@2.4.0", "parse-duration": "^1.0.2", "pg": "^8.8.0", "shell-quote": "^1.7.3", diff --git a/src/Draupnir.ts b/src/Draupnir.ts index 3233813c..103bb139 100644 --- a/src/Draupnir.ts +++ b/src/Draupnir.ts @@ -32,7 +32,7 @@ import { UnlistedUserRedactionQueue } from "./queues/UnlistedUserRedactionQueue" import { ThrottlingQueue } from "./queues/ThrottlingQueue"; import ManagementRoomOutput from "./managementroom/ManagementRoomOutput"; import { ReportPoller } from "./report/ReportPoller"; -import { ReportManager } from "./report/ReportManager"; +import { StandardReportManager } from "./report/ReportManager"; import { MatrixReactionHandler } from "./commands/interface-manager/MatrixReactionHandler"; import { MatrixSendClient, @@ -106,7 +106,7 @@ export class Draupnir implements Client, MatrixAdaptorContext { * Handle user reports from the homeserver. * FIXME: ReportManager should be a protection. */ - public readonly reportManager: ReportManager; + public readonly reportManager: StandardReportManager; public readonly reactionHandler: MatrixReactionHandler; @@ -157,7 +157,7 @@ export class Draupnir implements Client, MatrixAdaptorContext { clientUserID, clientPlatform ); - this.reportManager = new ReportManager(this); + this.reportManager = new StandardReportManager(this); if (config.pollReports) { this.reportPoller = new ReportPoller(this, this.reportManager); } diff --git a/src/report/ReportManager.ts b/src/report/ReportManager.ts index 462a31d2..c27625d0 100644 --- a/src/report/ReportManager.ts +++ b/src/report/ReportManager.ts @@ -102,10 +102,32 @@ enum Kind { ESCALATED_REPORT, } +export interface ReportManager { + handleTimelineEvent(roomID: StringRoomID, event: RoomEvent): void; + handleServerAbuseReport({ + roomID, + reporterId, + event, + reason, + }: { + roomID: StringRoomID; + reporterId: string; + event: RoomEvent; + reason?: string; + }): Promise; + handleReaction({ + roomID, + event, + }: { + roomID: StringRoomID; + event: RoomEvent; + }): Promise; +} + /** * A class designed to respond to abuse reports. */ -export class ReportManager { +export class StandardReportManager { private displayManager: DisplayManager; constructor(public draupnir: Draupnir) { this.displayManager = new DisplayManager(this); @@ -580,7 +602,7 @@ interface IUIAction { * @param report Details on the abuse report. */ canExecute( - manager: ReportManager, + manager: StandardReportManager, report: IReport, moderationroomID: string ): Promise; @@ -590,20 +612,20 @@ interface IUIAction { * * @param report Details on the abuse report. */ - title(manager: ReportManager, report: IReport): Promise; + title(manager: StandardReportManager, report: IReport): Promise; /** * A human-readable help message to display for the end-user. * * @param report Details on the abuse report. */ - help(manager: ReportManager, report: IReport): Promise; + help(manager: StandardReportManager, report: IReport): Promise; /** * Attempt to execute the action. */ execute( - manager: ReportManager, + manager: StandardReportManager, report: IReport, moderationroomID: string, displayManager: DisplayManager @@ -618,25 +640,25 @@ class IgnoreBadReport implements IUIAction { public emoji = "🚯"; public needsConfirmation = true; public async canExecute( - _manager: ReportManager, + _manager: StandardReportManager, _report: IReport ): Promise { return true; } public async title( - _manager: ReportManager, + _manager: StandardReportManager, _report: IReport ): Promise { return "Ignore"; } public async help( - _manager: ReportManager, + _manager: StandardReportManager, _report: IReport ): Promise { return "Ignore bad report"; } public async execute( - manager: ReportManager, + manager: StandardReportManager, report: IReportWithAction ): Promise { await manager.draupnir.client.sendEvent( @@ -667,7 +689,7 @@ class RedactMessage implements IUIAction { public emoji = "🗍"; public needsConfirmation = true; public async canExecute( - manager: ReportManager, + manager: StandardReportManager, report: IReport ): Promise { try { @@ -681,16 +703,19 @@ class RedactMessage implements IUIAction { } } public async title( - _manager: ReportManager, + _manager: StandardReportManager, _report: IReport ): Promise { return "Redact"; } - public async help(_manager: ReportManager, report: IReport): Promise { + public async help( + _manager: StandardReportManager, + report: IReport + ): Promise { return `Redact event ${report.event_id}`; } public async execute( - manager: ReportManager, + manager: StandardReportManager, report: IReport, _moderationroomID: string ): Promise { @@ -707,7 +732,7 @@ class KickAccused implements IUIAction { public emoji = "⚽"; public needsConfirmation = true; public async canExecute( - manager: ReportManager, + manager: StandardReportManager, report: IReport ): Promise { try { @@ -721,16 +746,19 @@ class KickAccused implements IUIAction { } } public async title( - _manager: ReportManager, + _manager: StandardReportManager, _report: IReport ): Promise { return "Kick"; } - public async help(_manager: ReportManager, report: IReport): Promise { + public async help( + _manager: StandardReportManager, + report: IReport + ): Promise { return `Kick ${htmlEscape(report.accused_id)} from room ${htmlEscape(report.room_alias_or_id)}`; } public async execute( - manager: ReportManager, + manager: StandardReportManager, report: IReport ): Promise { await manager.draupnir.client.kickUser(report.accused_id, report.room_id); @@ -746,7 +774,7 @@ class MuteAccused implements IUIAction { public emoji = "🤐"; public needsConfirmation = true; public async canExecute( - manager: ReportManager, + manager: StandardReportManager, report: IReport ): Promise { try { @@ -761,16 +789,19 @@ class MuteAccused implements IUIAction { } } public async title( - _manager: ReportManager, + _manager: StandardReportManager, _report: IReport ): Promise { return "Mute"; } - public async help(_manager: ReportManager, report: IReport): Promise { + public async help( + _manager: StandardReportManager, + report: IReport + ): Promise { return `Mute ${htmlEscape(report.accused_id)} in room ${htmlEscape(report.room_alias_or_id)}`; } public async execute( - manager: ReportManager, + manager: StandardReportManager, report: IReport ): Promise { await manager.draupnir.client.setUserPowerLevel( @@ -790,7 +821,7 @@ class BanAccused implements IUIAction { public emoji = "🚫"; public needsConfirmation = true; public async canExecute( - manager: ReportManager, + manager: StandardReportManager, report: IReport ): Promise { try { @@ -804,16 +835,19 @@ class BanAccused implements IUIAction { } } public async title( - _manager: ReportManager, + _manager: StandardReportManager, _report: IReport ): Promise { return "Ban"; } - public async help(_manager: ReportManager, report: IReport): Promise { + public async help( + _manager: StandardReportManager, + report: IReport + ): Promise { return `Ban ${htmlEscape(report.accused_id)} from room ${htmlEscape(report.room_alias_or_id)}`; } public async execute( - manager: ReportManager, + manager: StandardReportManager, report: IReport ): Promise { await manager.draupnir.client.banUser(report.accused_id, report.room_id); @@ -829,25 +863,25 @@ class Help implements IUIAction { public emoji = "❓"; public needsConfirmation = false; public async canExecute( - _manager: ReportManager, + _manager: StandardReportManager, _report: IReport ): Promise { return true; } public async title( - _manager: ReportManager, + _manager: StandardReportManager, _report: IReport ): Promise { return "Help"; } public async help( - _manager: ReportManager, + _manager: StandardReportManager, _report: IReport ): Promise { return "This help"; } public async execute( - manager: ReportManager, + manager: StandardReportManager, report: IReport, moderationroomID: string ): Promise { @@ -884,7 +918,7 @@ class EscalateToServerModerationRoom implements IUIAction { public emoji = "⏫"; public needsConfirmation = true; public async canExecute( - manager: ReportManager, + manager: StandardReportManager, report: IReport, moderationroomID: string ): Promise { @@ -901,16 +935,19 @@ class EscalateToServerModerationRoom implements IUIAction { return true; } public async title( - _manager: ReportManager, + _manager: StandardReportManager, _report: IReport ): Promise { return "Escalate"; } - public async help(manager: ReportManager, _report: IReport): Promise { + public async help( + manager: StandardReportManager, + _report: IReport + ): Promise { return `Escalate report to ${getHomeserver(await manager.draupnir.client.getUserId())} server moderators`; } public async execute( - manager: ReportManager, + manager: StandardReportManager, report: IReport, _moderationroomID: string, displayManager: DisplayManager @@ -939,7 +976,7 @@ class EscalateToServerModerationRoom implements IUIAction { } class DisplayManager { - constructor(private owner: ReportManager) {} + constructor(private owner: StandardReportManager) {} /** * Display the report and any UI button. diff --git a/src/report/ReportPoller.ts b/src/report/ReportPoller.ts index 73d79894..a8e7e8ed 100644 --- a/src/report/ReportPoller.ts +++ b/src/report/ReportPoller.ts @@ -8,18 +8,19 @@ // https://github.com/matrix-org/mjolnir // -import { MatrixSendClient } from "matrix-protection-suite-for-matrix-bot-sdk"; +import { + MatrixSendClient, + SynapseAdminClient, +} from "matrix-protection-suite-for-matrix-bot-sdk"; import { ReportManager } from "./ReportManager"; -import { LogLevel, LogService } from "matrix-bot-sdk"; +import { LogLevel } from "matrix-bot-sdk"; import ManagementRoomOutput from "../managementroom/ManagementRoomOutput"; import { Draupnir } from "../Draupnir"; import { ActionException, ActionExceptionKind, Ok, - SynapseReport, Task, - Value, isError, } from "matrix-protection-suite"; @@ -50,10 +51,23 @@ export class ReportPoller { */ private timeout: ReturnType | null = null; + private readonly synapseAdminClient: SynapseAdminClient; + + private readonly pollPeriod: number; + constructor( private draupnir: Draupnir, - private manager: ReportManager - ) {} + private manager: ReportManager, + options: { pollPeriod?: number } = {} + ) { + if (draupnir.synapseAdminClient === undefined) { + throw new TypeError( + `Unable to find synapse admin client for report poller` + ); + } + this.synapseAdminClient = draupnir.synapseAdminClient; + this.pollPeriod = options.pollPeriod ?? 30_000; // a minute in milliseconds + } private schedulePoll() { if (this.timeout === null) { @@ -65,7 +79,7 @@ export class ReportPoller { */ this.timeout = setTimeout( this.tryGetAbuseReports.bind(this), - 30_000 // a minute in milliseconds + this.pollPeriod ); } else { throw new InvalidStateError("poll already scheduled"); @@ -73,46 +87,19 @@ export class ReportPoller { } private async getAbuseReports() { - let response: - | { - event_reports: unknown[]; - next_token: number | undefined; - } - | undefined; - try { - response = await this.draupnir.client.doRequest( - "GET", - "/_synapse/admin/v1/event_reports", - { - // short for direction: forward; i.e. show newest last - dir: "f", - from: this.from.toString(), - } - ); - } catch (ex) { + const response = await this.synapseAdminClient.getAbuseReports({ + direction: "f", + from: this.from, + }); + if (isError(response)) { await this.draupnir.managementRoomOutput.logMessage( LogLevel.ERROR, "getAbuseReports", - `failed to poll events: ${ex}` + `failed to poll events: ${response.error.toReadableString()}` ); return; } - if (response === undefined) { - throw new TypeError( - `we should have got a response from /event_reports/, code is wrong.` - ); - } - for (const rawReport of response.event_reports) { - const reportResult = Value.Decode(SynapseReport, rawReport); - if (isError(reportResult)) { - LogService.error( - "ReportPoller", - `Failed to decode a synapse report ${reportResult.error.uuid}`, - rawReport - ); - continue; - } - const report = reportResult.ok; + for (const report of response.ok.event_reports) { // FIXME: shouldn't we have a SafeMatrixSendClient in the BotSDKMPS that gives us ActionResult's with // Decoded events. // Problem is that our current event model isn't going to match up with extensible events. @@ -143,7 +130,7 @@ export class ReportPoller { await this.manager.handleServerAbuseReport({ roomID: report.room_id, - reporterId: report.sender, + reporterId: report.user_id, event: event, ...(report.reason ? { reason: report.reason } : {}), }); @@ -152,13 +139,15 @@ export class ReportPoller { /* * This API endpoint returns an opaque `next_token` number that we * need to give back to subsequent requests for pagination, so here we - * save it in account data + * save it in account data. Except it's not opaque, and there's no way + * to use this API as a poll without cheating and using the total. Brill. */ - if (response.next_token !== undefined) { - this.from = response.next_token; + const nextToken = response.ok.next_token ?? response.ok.total ?? 0; + if (nextToken !== this.from) { + this.from = nextToken; try { await this.draupnir.client.setAccountData(REPORT_POLL_EVENT_TYPE, { - from: response.next_token, + from: nextToken, }); } catch (ex) { await this.draupnir.managementRoomOutput.logMessage( diff --git a/src/webapis/WebAPIs.ts b/src/webapis/WebAPIs.ts index cdede927..ad770b6b 100644 --- a/src/webapis/WebAPIs.ts +++ b/src/webapis/WebAPIs.ts @@ -11,7 +11,7 @@ import { Server } from "http"; import express from "express"; import { MatrixClient } from "matrix-bot-sdk"; -import { ReportManager } from "../report/ReportManager"; +import { StandardReportManager } from "../report/ReportManager"; import { IConfig } from "../config"; import { StringRoomID, @@ -35,7 +35,7 @@ export class WebAPIs { private httpServer?: Server | undefined; constructor( - private reportManager: ReportManager, + private reportManager: StandardReportManager, private readonly config: IConfig ) { // Setup JSON parsing. @@ -257,7 +257,6 @@ export class WebAPIs { // with all Matrix homeservers, rather than just Synapse. event = await reporterClient.getEvent(roomID, eventID); } - const reason = request.body["reason"]; await this.reportManager.handleServerAbuseReport({ roomID, diff --git a/test/integration/reportPollingTest.ts b/test/integration/reportPollingTest.ts index 8cc9c848..169e06f3 100644 --- a/test/integration/reportPollingTest.ts +++ b/test/integration/reportPollingTest.ts @@ -11,31 +11,30 @@ import { MatrixClient } from "matrix-bot-sdk"; import { newTestUser } from "./clientHelper"; import { DraupnirTestContext } from "./mjolnirSetupUtils"; -import { - ActionResult, - Ok, - Protection, - ProtectionDescription, - Task, - describeConfig, -} from "matrix-protection-suite"; import { MatrixRoomReference, StringRoomID, } from "@the-draupnir-project/matrix-basic-types"; -import { Type } from "@sinclair/typebox"; +import { randomUUID } from "crypto"; +import expect from "expect"; +import { createMock } from "ts-auto-mock"; +import { ReportManager } from "../../src/report/ReportManager"; +import { ReportPoller } from "../../src/report/ReportPoller"; describe("Test: Report polling", function () { let client: MatrixClient; + let reportPoller: ReportPoller | undefined; this.beforeEach(async function () { client = await newTestUser(this.config.homeserverUrl, { name: { contains: "protection-settings" }, }); }); + this.afterEach(function () { + reportPoller?.stop(); + }); it("Draupnir correctly retrieves a report from synapse", async function ( this: DraupnirTestContext ) { - this.timeout(40000); const draupnir = this.draupnir; if (draupnir === undefined) { throw new TypeError(`Test didn't setup properly`); @@ -47,63 +46,44 @@ describe("Test: Report polling", function () { await draupnir.protectedRoomsSet.protectedRoomsManager.addRoom( MatrixRoomReference.fromRoomID(protectedRoomId as StringRoomID) ); - - const eventId = await client.sendMessage(protectedRoomId, { - msgtype: "m.text", - body: "uwNd3q", + const testReportReason = randomUUID(); + const reportsFound = new Set(); + const duplicateReports = new Set(); + const reportManager = createMock({ + handleServerAbuseReport({ event, reason }) { + if (reason === testReportReason) { + if (reportsFound.has(event.event_id)) { + duplicateReports.add(event.event_id); + } + reportsFound.add(event.event_id); + } + return Promise.resolve(undefined); + }, }); - await new Promise((resolve) => { - const testProtectionDescription: ProtectionDescription = { - name: "jYvufI", - description: "A test protection", - capabilities: {}, - defaultCapabilities: {}, - factory: function ( - _description, - _protectedRoomsSet, - _context, - _capabilities, - _settings - ): ActionResult> { - return Ok({ - handleEventReport(report) { - if (report.reason === "x5h1Je") { - resolve(null); - } - return Promise.resolve(Ok(undefined)); - }, - description: testProtectionDescription, - requiredEventPermissions: [], - requiredPermissions: [], - requiredStatePermissions: [], - }); - }, - protectionSettings: describeConfig({ schema: Type.Object({}) }), - }; - void Task( - (async () => { - await draupnir.protectedRoomsSet.protections.addProtection( - testProtectionDescription, - draupnir.protectedRoomsSet, - draupnir - ); - await client.doRequest( - "POST", - `/_matrix/client/r0/rooms/${encodeURIComponent(protectedRoomId)}/report/${encodeURIComponent(eventId)}`, - "", - { - reason: "x5h1Je", - } - ); - })() - ); + reportPoller = new ReportPoller(draupnir, reportManager, { + pollPeriod: 500, }); - // So I kid you not, it seems like we can quit before the webserver for reports sends a respond to the client (via L#26) - // because the promise above gets resolved before we finish awaiting the report sending request on L#31, - // then mocha's cleanup code runs (and shuts down the webserver) before the webserver can respond. - // Wait a minute 😲😲🤯 it's not even supposed to be using the webserver if this is testing report polling. - // Ok, well apparently that needs a big refactor to change, but if you change the config before running this test, - // then you can ensure that report polling works. https://github.com/matrix-org/mjolnir/issues/326. - await new Promise((resolve) => setTimeout(resolve, 1000)); + const reportEvent = async () => { + const eventId = await client.sendMessage(protectedRoomId, { + msgtype: "m.text", + body: "uwNd3q", + }); + await client.doRequest( + "POST", + `/_matrix/client/r0/rooms/${encodeURIComponent(protectedRoomId)}/report/${encodeURIComponent(eventId)}`, + "", + { + reason: testReportReason, + } + ); + }; + reportPoller.start({ from: 1 }); + for (let i = 0; i < 20; i++) { + await reportEvent(); + } + // wait for them to come down the poll. + await new Promise((resolve) => setTimeout(resolve, 3000)); + expect(reportsFound.size).toBe(20); + expect(duplicateReports.size).toBe(0); } as unknown as Mocha.AsyncFunc); }); diff --git a/test/nginx.conf b/test/nginx.conf index d88884b4..9692191a 100644 --- a/test/nginx.conf +++ b/test/nginx.conf @@ -11,6 +11,7 @@ http { listen [::]:8081 ipv6only=off; location ~ ^/_matrix/client/(r0|v3)/rooms/([^/]*)/report/(.*)$ { + mirror /report_mirror; # Abuse reports should be sent to Mjölnir. # The r0 endpoint is deprecated but still used by many clients. # As of this writing, the v3 endpoint is the up-to-date version. @@ -31,6 +32,10 @@ http { location / { # Everything else should be sent to Synapse. proxy_pass http://127.0.0.1:9999; - } } + location /report_mirror { + internal; + proxy_pass http://127.0.0.1:9999$request_uri; + } + } } diff --git a/yarn.lock b/yarn.lock index 3eb59358..b1dc848a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2677,17 +2677,17 @@ matrix-appservice@^2.0.0: request-promise "^4.2.6" sanitize-html "^2.11.0" -"matrix-protection-suite-for-matrix-bot-sdk@npm:@gnuxie/matrix-protection-suite-for-matrix-bot-sdk@2.3.2": - version "2.3.2" - resolved "https://registry.yarnpkg.com/@gnuxie/matrix-protection-suite-for-matrix-bot-sdk/-/matrix-protection-suite-for-matrix-bot-sdk-2.3.2.tgz#c16f72a0c05980ad54fab7fa53bfd5c5be54edc2" - integrity sha512-WbEJ2erZcZ8KgF5BBcIvC9EjahDYXz+De8B5o4+a8zOK7jXt2ky1rp9KEj9eAE7JisgbLNQnprarpMpX380prw== +"matrix-protection-suite-for-matrix-bot-sdk@npm:@gnuxie/matrix-protection-suite-for-matrix-bot-sdk@2.4.0": + version "2.4.0" + resolved "https://registry.yarnpkg.com/@gnuxie/matrix-protection-suite-for-matrix-bot-sdk/-/matrix-protection-suite-for-matrix-bot-sdk-2.4.0.tgz#5598bbd28ca7f94174049daf12d52c0a3388b3aa" + integrity sha512-p5G86TBW2TY0boiQhWPXchJBDpsWi70iYM2nJ8BthnXBx6Ck8ON2Km36EEctXks68ylXnFitV2FGvAnN4+mNvA== dependencies: "@gnuxie/typescript-result" "^1.0.0" -"matrix-protection-suite@npm:@gnuxie/matrix-protection-suite@2.3.0": - version "2.3.0" - resolved "https://registry.yarnpkg.com/@gnuxie/matrix-protection-suite/-/matrix-protection-suite-2.3.0.tgz#1a7346f1ed26f7e6459001ac0ff025ca11ab66d9" - integrity sha512-YpBrQxRiFg0DVd/ru16W7pcqHH7sZv7r+JFUn5ZIL8Ya0Vt+MpWo22ZFkU0WGf5+I1KfgYLzV/C2nBisS4ZskA== +"matrix-protection-suite@npm:@gnuxie/matrix-protection-suite@2.4.0": + version "2.4.0" + resolved "https://registry.yarnpkg.com/@gnuxie/matrix-protection-suite/-/matrix-protection-suite-2.4.0.tgz#60659023fd6e5aec4e1282f7726da3baab074ee5" + integrity sha512-oEA0Vi/VJsHWXNQ0RMiLUID+YpgTH05+OGnHGVyeSS/8HP5Ziij3ZGMJvemZ2pij8Uq6az0hVV/NZUOefCoI1A== dependencies: "@gnuxie/typescript-result" "^1.0.0" await-lock "^2.2.2"