diff --git a/.github/workflows/mjolnir.yml b/.github/workflows/mjolnir.yml new file mode 100644 index 00000000..354f38b2 --- /dev/null +++ b/.github/workflows/mjolnir.yml @@ -0,0 +1,25 @@ +name: Mjolnir + +on: + push: + branches: [ main ] + pull_request: + branches: [ main ] + +env: + CARGO_TERM_COLOR: always + +jobs: + build: + name: Integration tests + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + - name: install mx-tester + run: cargo install mx-tester + - name: Setup image + run: RUST_LOG=debug mx-tester build up + - name: Setup dependencies + run: yarn install + - name: Run tests + run: RUST_LOG=debug mx-tester run diff --git a/mx-tester.yml b/mx-tester.yml index d5f1129e..6178eeec 100644 --- a/mx-tester.yml +++ b/mx-tester.yml @@ -1,69 +1,14 @@ -name: Mjolnir Testing -modules: - - name: synapse_antispam - build: - - cp -r synapse_antispam $MX_TEST_SYNAPSE_DIR +name: mjolnir + up: # Launch the reverse proxy, listening for connections *only* on the local host. - - docker run --rm --network host --name mjolnir-test-reverse-proxy -p 127.0.0.1:8081:80 -v $MX_TEST_CWD/test/nginx.conf:/etc/nginx/nginx.conf:ro -d nginx + - docker run --rm --network host --name mjolnir-test-reverse-proxy -p 127.0.0.1:8081:80 -v $MX_TEST_CWD/test/nginx.conf:/etc/nginx/nginx.conf:ro -d nginx run: - yarn test:integration down: finally: - - docker stop mjolnir-test-reverse-proxy -homeserver_config: + - docker stop mjolnir-test-reverse-proxy || true +homeserver: server_name: localhost:9999 - pid_file: /data/homeserver.pid public_baseurl: http://localhost:9999 - listeners: - - port: 9999 - tls: false - type: http - x_forwarded: true - resources: - - names: [client, federation] - compress: false - database: - name: sqlite3 - args: - database: /data/homeserver.db - media_store_path: "/data/media_store" - enable_registration: true - report_stats: false - registration_shared_secret: "REGISTRATION_SHARED_SECRET" - macaroon_secret_key: "MACROON_SECRET_KEY" - signing_key_path: "/data/localhost:9999.signing.key" - trusted_key_servers: - - server_name: "matrix.org" - suppress_key_server_warning: true - - rc_message: - per_second: 10000 - burst_count: 10000 - - rc_registration: - per_second: 10000 - burst_count: 10000 - - rc_login: - address: - per_second: 10000 - burst_count: 10000 - account: - per_second: 10000 - burst_count: 10000 - failed_attempts: - per_second: 10000 - burst_count: 10000 - - rc_admin_redaction: - per_second: 10000 - burst_count: 10000 - - rc_joins: - local: - per_second: 10000 - burst_count: 10000 - remote: - per_second: 10000 - burst_count: 10000 + registration_shared_secret: REGISTRATION_SHARED_SECRET diff --git a/package.json b/package.json index 27ba6893..25c4e12b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "mjolnir", - "version": "1.2", + "version": "1.2.1", "description": "A moderation tool for Matrix", "main": "lib/index.js", "repository": "git@github.com:matrix-org/mjolnir.git", @@ -35,6 +35,7 @@ "config": "^3.3.6", "escape-html": "^1.0.3", "express": "^4.17", + "html-to-text": "^8.0.0", "js-yaml": "^4.1.0", "jsdom": "^16.6.0", "matrix-bot-sdk": "^0.5.19" diff --git a/src/Mjolnir.ts b/src/Mjolnir.ts index c43d8193..bf6ce93f 100644 --- a/src/Mjolnir.ts +++ b/src/Mjolnir.ts @@ -40,6 +40,7 @@ import { UnlistedUserRedactionQueue } from "./queues/UnlistedUserRedactionQueue" import { Healthz } from "./health/healthz"; import { EventRedactionQueue, RedactUserInRoom } from "./queues/EventRedactionQueue"; import * as htmlEscape from "escape-html"; +import { ReportManager } from "./report/ReportManager"; import { WebAPIs } from "./webapis/WebAPIs"; export const STATE_NOT_STARTED = "not_started"; @@ -221,7 +222,7 @@ export class Mjolnir { // Setup Web APIs console.log("Creating Web APIs"); - this.webapis = new WebAPIs(this.client); + this.webapis = new WebAPIs(new ReportManager(this)); } public get lists(): BanList[] { diff --git a/src/report/ReportManager.ts b/src/report/ReportManager.ts new file mode 100644 index 00000000..5b6c6a5f --- /dev/null +++ b/src/report/ReportManager.ts @@ -0,0 +1,908 @@ +/* +Copyright 2021 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 { PowerLevelAction } from "matrix-bot-sdk/lib/models/PowerLevelAction"; +import { LogService, UserID } from "matrix-bot-sdk"; +import { htmlToText } from "html-to-text"; +import { JSDOM } from 'jsdom'; + +import config from "../config"; +import { Mjolnir } from "../Mjolnir"; + +/// Regexp, used to extract the action label from an action reaction +/// such as `⚽ Kick user @foobar:localhost from room [kick-user]`. +const REACTION_ACTION = /\[([a-z-]*)\]$/; + +/// Regexp, used to extract the action label from a confirmation reaction +/// such as `🆗 ⚽ Kick user @foobar:localhost from room? [kick-user][confirm]`. +const REACTION_CONFIRMATION = /\[([a-z-]*)\]\[([a-z-]*)\]$/; + +/// The hardcoded `confirm` string, as embedded in confirmation reactions. +const CONFIRM = "confirm"; +/// The hardcoded `cancel` string, as embedded in confirmation reactions. +const CANCEL = "cancel"; + +/// Custom field embedded as part of notifications to embed abuse reports +/// (see `IReport` for the content). +export const ABUSE_REPORT_KEY = "org.matrix.mjolnir.abuse.report"; + +/// Custom field embedded as part of confirmation reactions to embed abuse +/// reports (see `IReportWithAction` for the content). +export const ABUSE_ACTION_CONFIRMATION_KEY = "org.matrix.mjolnir.abuse.action.confirmation"; + +const NATURE_DESCRIPTIONS_LIST: [string, string][] = [ + ["org.matrix.msc3215.abuse.nature.disagreement", "disagreement"], + ["org.matrix.msc3215.abuse.nature.harassment", "harassment/bullying"], + ["org.matrix.msc3215.abuse.nature.csam", "child sexual abuse material [likely illegal, consider warning authorities]"], + ["org.matrix.msc3215.abuse.nature.hate_speech", "spam"], + ["org.matrix.msc3215.abuse.nature.spam", "impersonation"], + ["org.matrix.msc3215.abuse.nature.impersonation", "impersonation"], + ["org.matrix.msc3215.abuse.nature.doxxing", "non-consensual sharing of identifiable private information of a third party (doxxing)"], + ["org.matrix.msc3215.abuse.nature.violence", "threats of violence or death, either to self or others"], + ["org.matrix.msc3215.abuse.nature.terrorism", "terrorism [likely illegal, consider warning authorities]"], + ["org.matrix.msc3215.abuse.nature.unwanted_sexual_advances", "unwanted sexual advances, sextortion, ... [possibly illegal, consider warning authorities]"], + ["org.matrix.msc3215.abuse.nature.ncii", "non consensual intimate imagery, including revenge porn"], + ["org.matrix.msc3215.abuse.nature.nsfw", "NSFW content (pornography, gore...) in a SFW room"], + ["org.matrix.msc3215.abuse.nature.disinformation", "disinformation"], +]; +const NATURE_DESCRIPTIONS = new Map(NATURE_DESCRIPTIONS_LIST); + +enum Kind { + //! A MSC3215-style moderation request + MODERATION_REQUEST, + //! An abuse report, as per https://matrix.org/docs/spec/client_server/r0.6.1#post-matrix-client-r0-rooms-roomid-report-eventid + SERVER_ABUSE_REPORT, + //! Mjölnir encountered a problem while attempting to handle a moderation request or abuse report + ERROR, + //! A moderation request or server abuse report escalated by the server/room moderators. + ESCALATED_REPORT, +} + +/** + * A class designed to respond to abuse reports. + */ +export class ReportManager { + private displayManager: DisplayManager; + constructor(public mjolnir: Mjolnir) { + // Configure bot interactions. + mjolnir.client.on("room.event", async (roomId, event) => { + try { + switch (event["type"]) { + case "m.reaction": { + await this.handleReaction({ roomId, event }); + break; + } + } + } catch (ex) { + LogService.error("ReportManager", "Uncaught error while handling an event", ex); + } + }); + this.displayManager = new DisplayManager(this); + } + + /** + * Display an incoming abuse report received, e.g. from the /report Matrix API. + * + * # Pre-requisites + * + * The following MUST hold true: + * - the reporter's id is `reporterId`; + * - the reporter is a member of `roomId`; + * - `eventId` did take place in room `roomId`; + * - the reporter could witness event `eventId` in room `roomId`; + * - the event being reported is `event`; + * + * @param roomId The room in which the abuse took place. + * @param eventId The ID of the event reported as abuse. + * @param reporterId The user who reported the event. + * @param event The event being reported. + * @param reason A reason provided by the reporter. + */ + public async handleServerAbuseReport({ reporterId, event, reason }: { roomId: string, eventId: string, reporterId: string, event: any, reason?: string }) { + return this.displayManager.displayReportAndUI({ kind: Kind.SERVER_ABUSE_REPORT, event, reporterId, reason, moderationRoomId: config.managementRoom }); + } + + /** + * Handle a reaction to an abuse report. + * + * @param roomId The room in which the reaction took place. + * @param event The reaction. + */ + public async handleReaction({ roomId, event }: { roomId: string, event: any }) { + if (event.sender === await this.mjolnir.client.getUserId()) { + // Let's not react to our own reactions. + return; + } + + if (roomId !== config.managementRoom) { + // Let's not accept commands in rooms other than the management room. + return; + } + let relation; + try { + relation = event["content"]["m.relates_to"]!; + } catch (ex) { + return; + } + + // Get the original event. + let initialNoticeReport: IReport | undefined, confirmationReport: IReportWithAction | undefined; + try { + let originalEvent = await this.mjolnir.client.getEvent(roomId, relation.event_id); + if (!("content" in originalEvent)) { + return; + } + let content = originalEvent["content"]; + if (ABUSE_REPORT_KEY in content) { + initialNoticeReport = content[ABUSE_REPORT_KEY]!; + } else if (ABUSE_ACTION_CONFIRMATION_KEY in content) { + confirmationReport = content[ABUSE_ACTION_CONFIRMATION_KEY]!; + } + } catch (ex) { + return; + } + if (!initialNoticeReport && !confirmationReport) { + return; + } + + /* + At this point, we know that: + + - We're in the management room; + - Either + - `initialNoticeReport` is defined and we're reacting to one of our reports; or + - `confirmationReport` is defined and we're reacting to a confirmation request. + */ + + if (confirmationReport) { + // Extract the action and the decision. + let matches = relation.key.match(REACTION_CONFIRMATION); + if (!matches) { + // Invalid key. + return; + } + + // Is it a yes or a no? + let decision; + switch (matches[2]) { + case CONFIRM: + decision = true; + break; + case CANCEL: + decision = false; + break; + default: + LogService.debug("ReportManager::handleReaction", "Unknown decision", matches[2]); + return; + } + if (decision) { + LogService.info("ReportManager::handleReaction", "User", event["sender"], "confirmed action", matches[1]); + await this.executeAction({ + label: matches[1], + report: confirmationReport, + successEventId: confirmationReport.notification_event_id, + failureEventId: relation.event_id, + onSuccessRemoveEventId: relation.event_id, + moderationRoomId: roomId + }) + } else { + LogService.info("ReportManager::handleReaction", "User", event["sender"], "cancelled action", matches[1]); + this.mjolnir.client.redactEvent(config.managementRoom, relation.event_id, "Action cancelled"); + } + + return; + } else if (initialNoticeReport) { + let matches = relation.key.match(REACTION_ACTION); + if (!matches) { + // Invalid key. + return; + } + + let label: string = matches[1]!; + let action: IUIAction | undefined = ACTIONS.get(label); + if (!action) { + return; + } + confirmationReport = { + action: label, + notification_event_id: relation.event_id, + ...initialNoticeReport + }; + LogService.info("ReportManager::handleReaction", "User", event["sender"], "picked action", label, initialNoticeReport); + if (action.needsConfirmation) { + // Send a confirmation request. + let confirmation = { + msgtype: "m.notice", + body: `${action.emoji} ${await action.title(this, initialNoticeReport)}?`, + "m.relationship": { + "rel_type": "m.reference", + "event_id": relation.event_id, + } + }; + confirmation[ABUSE_ACTION_CONFIRMATION_KEY] = confirmationReport; + + let requestConfirmationEventId = await this.mjolnir.client.sendMessage(config.managementRoom, confirmation); + await this.mjolnir.client.sendEvent(config.managementRoom, "m.reaction", { + "m.relates_to": { + "rel_type": "m.annotation", + "event_id": requestConfirmationEventId, + "key": `🆗 ${action.emoji} ${await action.title(this, initialNoticeReport)} [${action.label}][${CONFIRM}]` + } + }); + await this.mjolnir.client.sendEvent(config.managementRoom, "m.reaction", { + "m.relates_to": { + "rel_type": "m.annotation", + "event_id": requestConfirmationEventId, + "key": `⬛ Cancel [${action.label}][${CANCEL}]` + } + }); + } else { + // Execute immediately. + LogService.info("ReportManager::handleReaction", "User", event["sender"], "executed (no confirmation needed) action", matches[1]); + this.executeAction({ + label, + report: confirmationReport, + successEventId: relation.event_id, + failureEventId: relation.eventId, + moderationRoomId: roomId + }) + } + } + } + + + /** + * Execute a report-specific action. + * + * This is executed when the user clicks on an action to execute (if the action + * does not need confirmation) or when the user clicks on "confirm" in a confirmation + * (otherwise). + * + * @param label The type of action to execute, e.g. `kick-user`. + * @param report The abuse report on which to take action. + * @param successEventId The event to annotate with a "OK" in case of success. + * @param failureEventId The event to annotate with a "FAIL" in case of failure. + * @param onSuccessRemoveEventId Optionally, an event to remove in case of success (e.g. the confirmation dialog). + */ + private async executeAction({ label, report, successEventId, failureEventId, onSuccessRemoveEventId, moderationRoomId }: { label: string, report: IReportWithAction, successEventId: string, failureEventId: string, onSuccessRemoveEventId?: string, moderationRoomId: string }) { + let action: IUIAction | undefined = ACTIONS.get(label); + if (!action) { + return; + } + let error: any = null; + let response; + try { + // Check security. + if (moderationRoomId === config.managementRoom) { + // Always accept actions executed from the management room. + } else { + throw new Error("Security error: Cannot execute this action."); + } + response = await action.execute(this, report, moderationRoomId, this.displayManager); + } catch (ex) { + error = ex; + } + if (error) { + this.mjolnir.client.sendEvent(config.managementRoom, "m.reaction", { + "m.relates_to": { + "rel_type": "m.annotation", + "event_id": failureEventId, + "key": `${action.emoji} ❌` + } + }); + this.mjolnir.client.sendEvent(config.managementRoom, "m.notice", { + "body": error.message || "", + "m.relationship": { + "rel_type": "m.reference", + "event_id": failureEventId, + } + }) + } else { + this.mjolnir.client.sendEvent(config.managementRoom, "m.reaction", { + "m.relates_to": { + "rel_type": "m.annotation", + "event_id": successEventId, + "key": `${action.emoji} ✅` + } + }); + if (onSuccessRemoveEventId) { + this.mjolnir.client.redactEvent(config.managementRoom, onSuccessRemoveEventId, "Action complete"); + } + if (response) { + this.mjolnir.client.sendMessage(config.managementRoom, { + msgtype: "m.notice", + "formatted_body": response, + format: "org.matrix.custom.html", + "body": htmlToText(response), + "m.relationship": { + "rel_type": "m.reference", + "event_id": successEventId + } + }) + } + } + } +} + +/** + * An abuse report received from a user. + * + * Note: These reports end up embedded in Matrix messages, behind key `ABUSE_REPORT_KEY`, + * so we're using Matrix naming conventions rather than JS/TS naming conventions. + */ +interface IReport { + /** + * The user who sent the abuse report. + */ + readonly accused_id: string, + + /** + * The user who sent the message reported as abuse. + */ + readonly reporter_id: string, + + /** + * The room in which `eventId` took place. + */ + readonly room_id: string, + readonly room_alias_or_id: string, + + /** + * The event reported as abuse. + */ + readonly event_id: string, +} + +/** + * An abuse report, extended with the information we need for a confirmation report. + * + * Note: These reports end up embedded in Matrix messages, behind key `ABUSE_ACTION_CONFIRMATION_KEY`, + * so we're using Matrix naming conventions rather than JS/TS naming conventions. +*/ +interface IReportWithAction extends IReport { + /** + * The label of the action we're confirming, e.g. `kick-user`. + */ + readonly action: string, + + /** + * The event in which we originally notified of the abuse. + */ + readonly notification_event_id: string, +} + +/** + * A user action displayed in the UI as a Matrix reaction. + */ +interface IUIAction { + /** + * A unique label. + * + * Used by Mjölnir to differentiate the actions, e.g. `kick-user`. + */ + readonly label: string; + + /** + * A unique Emoji. + * + * Used to help users avoid making errors when clicking on a button. + */ + readonly emoji: string; + + /** + * If `true`, this is an action that needs confirmation. Otherwise, the + * action may be executed immediately. + */ + readonly needsConfirmation: boolean; + + /** + * Detect whether the action may be executed, e.g. whether Mjölnir has + * sufficient powerlevel to execute this action. + * + * **Security caveat** This assumes that the security policy on whether + * the operation can be executed is: + * + * > *Anyone* in the moderation room and who isn't muted can execute + * > an operation iff Mjölnir has the rights to execute it. + * + * @param report Details on the abuse report. + */ + canExecute(manager: ReportManager, report: IReport, moderationRoomId: string): Promise; + + /** + * A human-readable title to display for the end-user. + * + * @param report Details on the abuse report. + */ + title(manager: ReportManager, report: IReport): Promise; + + /** + * Attempt to execute the action. + */ + execute(manager: ReportManager, report: IReport, moderationRoomId: string, displayManager: DisplayManager): Promise; +} + +/** + * UI action: Ignore bad report + */ +class IgnoreBadReport implements IUIAction { + public label = "bad-report"; + public emoji = "🚯"; + public needsConfirmation = true; + public async canExecute(_manager: ReportManager, _report: IReport): Promise { + return true; + } + public async title(_manager: ReportManager, _report: IReport): Promise { + return "Ignore bad report"; + } + public async execute(manager: ReportManager, report: IReportWithAction): Promise { + await manager.mjolnir.client.sendEvent(config.managementRoom, "m.room.message", + { + msgtype: "m.notice", + body: "Report classified as invalid", + "m.new_content": { + "body": `Report by user ${report.reporter_id} has been classified as invalid`, + "msgtype": "m.text" + }, + "m.relates_to": { + "rel_type": "m.replace", + "event_id": report.notification_event_id + } + } + ); + return; + } +} + +/** + * UI action: Redact reported message. + */ +class RedactMessage implements IUIAction { + public label = "redact-message"; + public emoji = "🗍"; + public needsConfirmation = true; + public async canExecute(manager: ReportManager, report: IReport): Promise { + try { + return await manager.mjolnir.client.userHasPowerLevelForAction(await manager.mjolnir.client.getUserId(), report.room_id, PowerLevelAction.RedactEvents); + } catch (ex) { + return false; + } + } + public async title(_manager: ReportManager, report: IReport): Promise { + return `Redact event ${report.event_id}`; + } + public async execute(manager: ReportManager, report: IReport, _moderationRoomId: string): Promise { + await manager.mjolnir.client.redactEvent(report.room_id, report.event_id); + return; + } +} + +/** + * UI action: Kick accused user. + */ +class KickAccused implements IUIAction { + public label = "kick-accused"; + public emoji = "⚽"; + public needsConfirmation = true; + public async canExecute(manager: ReportManager, report: IReport): Promise { + try { + return await manager.mjolnir.client.userHasPowerLevelForAction(await manager.mjolnir.client.getUserId(), report.room_id, PowerLevelAction.Kick); + } catch (ex) { + return false; + } + } + public async title(_manager: ReportManager, report: IReport): Promise { + return `Kick ${report.accused_id} from room ${report.room_alias_or_id}`; + } + public async execute(manager: ReportManager, report: IReport): Promise { + await manager.mjolnir.client.kickUser(report.accused_id, report.room_id); + return; + } +} + +/** + * UI action: Mute accused user. + */ +class MuteAccused implements IUIAction { + public label = "mute-accused"; + public emoji = "🤐"; + public needsConfirmation = true; + public async canExecute(manager: ReportManager, report: IReport): Promise { + try { + return await manager.mjolnir.client.userHasPowerLevelFor(await manager.mjolnir.client.getUserId(), report.room_id, "m.room.power_levels", true); + } catch (ex) { + return false; + } + } + public async title(_manager: ReportManager, report: IReport): Promise { + return `Mute ${report.accused_id} in room ${report.room_alias_or_id}`; + } + public async execute(manager: ReportManager, report: IReport): Promise { + await manager.mjolnir.client.setUserPowerLevel(report.accused_id, report.room_id, -1); + return; + } +} + +/** + * UI action: Ban accused. + */ +class BanAccused implements IUIAction { + public label = "ban-accused"; + public emoji = "🚫"; + public needsConfirmation = true; + public async canExecute(manager: ReportManager, report: IReport): Promise { + try { + return await manager.mjolnir.client.userHasPowerLevelForAction(await manager.mjolnir.client.getUserId(), report.room_id, PowerLevelAction.Ban); + } catch (ex) { + return false; + } + } + public async title(_manager: ReportManager, report: IReport): Promise { + return `Ban ${report.accused_id} from room ${report.room_alias_or_id}`; + } + public async execute(manager: ReportManager, report: IReport): Promise { + await manager.mjolnir.client.banUser(report.accused_id, report.room_id); + return; + } +} + +/** + * UI action: Help. + */ +class Help implements IUIAction { + public label = "help"; + public emoji = "❓"; + public needsConfirmation = false; + public async canExecute(_manager: ReportManager, _report: IReport): Promise { + return true; + } + public async title(_manager: ReportManager, _report: IReport): Promise { + return "Help"; + } + public async execute(manager: ReportManager, report: IReport): Promise { + // Produce a html list of actions, in the order specified by ACTION_LIST. + let list: string[] = []; + for (let action of ACTION_LIST) { + list.push(`
  • ${action.emoji} ${await action.title(manager, report)}
  • `); + } + let body = `
      ${list.join("\n")}
    `; + return body; + } +} + +/** + * Escalate to the moderation room of this instance of Mjölnir. + */ +class EscalateToServerModerationRoom implements IUIAction { + public label = "escalate-to-server-moderation"; + public emoji = "⏫"; + public needsConfirmation = true; + public async canExecute(manager: ReportManager, report: IReport, moderationRoomId: string): Promise { + if (moderationRoomId === config.managementRoom) { + // We're already at the top of the chain. + return false; + } + try { + await manager.mjolnir.client.getEvent(report.room_id, report.event_id); + } catch (ex) { + // We can't fetch the event. + return false; + } + return true; + } + public async title(manager: ReportManager, _report: IReport): Promise { + return `Escalate report to ${getHomeserver(await manager.mjolnir.client.getUserId())} server moderators`; + } + public async execute(manager: ReportManager, report: IReport, _moderationRoomId: string, displayManager: DisplayManager): Promise { + let event = await manager.mjolnir.client.getEvent(report.room_id, report.event_id); + + // Display the report and UI directly in the management room, as if it had been + // received from /report. + // + // Security: + // - `kind`: statically known good; + // - `moderationRoomId`: statically known good; + // - `reporterId`: we trust `report`, could be forged by a moderator, low impact; + // - `event`: checked just before. + await displayManager.displayReportAndUI({ kind: Kind.ESCALATED_REPORT, reporterId: report.reporter_id, moderationRoomId: config.managementRoom, event }); + return; + } +} + +class DisplayManager { + + constructor(private owner: ReportManager) { + + } + + /** + * Display the report and any UI button. + * + * + * # Security + * + * This method DOES NOT PERFORM ANY SECURITY CHECKS. + * + * @param kind The kind of report (server-wide abuse report / room moderation request). Low security. + * @param event The offending event. The fact that it's the offending event MUST be checked. No assumptions are made on the content. + * @param reporterId The user who reported the event. MUST be checked. + * @param reason A user-provided comment. Low-security. + * @param moderationRoomId The room in which the report and ui will be displayed. MUST be checked. + */ + public async displayReportAndUI(args: { kind: Kind, event: any, reporterId: string, reason?: string, nature?: string, moderationRoomId: string, error?: string }) { + let { kind, event, reporterId, reason, nature, moderationRoomId, error } = args; + + let roomId = event["room_id"]!; + let eventId = event["event_id"]!; + + let roomAliasOrId = roomId; + try { + roomAliasOrId = await this.owner.mjolnir.client.getPublishedAlias(roomId) || roomId; + } catch (ex) { + // Ignore. + } + + let eventContent; + try { + if (event["type"] === "m.room.encrypted") { + eventContent = { msg: "" }; + } else if ("content" in event) { + const MAX_EVENT_CONTENT_LENGTH = 2048; + const MAX_NEWLINES = 64; + if ("formatted_body" in event.content) { + eventContent = { html: this.limitLength(event.content.formatted_body, MAX_EVENT_CONTENT_LENGTH, MAX_NEWLINES) }; + } else if ("body" in event.content) { + eventContent = { text: this.limitLength(event.content.body, MAX_EVENT_CONTENT_LENGTH, MAX_NEWLINES) }; + } else { + eventContent = { text: this.limitLength(JSON.stringify(event["content"], null, 2), MAX_EVENT_CONTENT_LENGTH, MAX_NEWLINES) }; + } + } + } catch (ex) { + eventContent = { msg: `.` }; + } + + let accusedId = event["sender"]; + + let reporterDisplayName: string, accusedDisplayName: string; + try { + reporterDisplayName = await this.owner.mjolnir.client.getUserProfile(reporterId)["displayname"] || reporterId; + } catch (ex) { + reporterDisplayName = ""; + } + try { + accusedDisplayName = await this.owner.mjolnir.client.getUserProfile(accusedId)["displayname"] || accusedId; + } catch (ex) { + accusedDisplayName = ""; + } + + let eventShortcut = `https://matrix.to/#/${encodeURIComponent(roomId)}/${encodeURIComponent(eventId)}`; + let roomShortcut = `https://matrix.to/#/${encodeURIComponent(roomAliasOrId)}`; + + let eventTimestamp; + try { + eventTimestamp = new Date(event["origin_server_ts"]).toUTCString(); + } catch (ex) { + eventTimestamp = `.`; + } + + let title; + switch (kind) { + case Kind.MODERATION_REQUEST: + title = "Moderation request"; + break; + case Kind.SERVER_ABUSE_REPORT: + title = "Abuse report"; + break; + case Kind.ESCALATED_REPORT: + title = "Moderation request escalated by moderators"; + break; + case Kind.ERROR: + title = "Error"; + break; + } + + let readableNature = "unspecified"; + if (nature) { + readableNature = NATURE_DESCRIPTIONS.get(nature) || readableNature; + } + + // We need to send the report as html to be able to use spoiler markings. + // We build this as dom to be absolutely certain that we're not introducing + // any kind of injection within the report. + + // Please do NOT insert any `${}` in the following backticks, to avoid + // any XSS attack. + const document = new JSDOM(` + +
    + +
    +
    + Filed by () +
    + Against () +
    + Nature () +
    +
    + Room +
    +
    +
    +
    + Event details +
    + Event Go to event +
    +
    + When +
    +
    + Content +
    +
    +
    +
    +
    + Comments + Comments +
    + `).window.document; + + // ...insert text content + for (let [key, value] of [ + ['title', title], + ['reporter-display-name', reporterDisplayName], + ['reporter-id', reporterId], + ['accused-display-name', accusedDisplayName], + ['accused-id', accusedId], + ['event-id', eventId], + ['room-alias-or-id', roomAliasOrId], + ['reason-content', reason || ""], + ['nature-display', readableNature], + ['nature-source', nature || ""], + ['event-timestamp', eventTimestamp], + ['details-or-error', kind === Kind.ERROR ? error : null] + ]) { + let node = document.getElementById(key); + if (node && value) { + node.textContent = value; + } + } + // ...insert links + for (let [key, value] of [ + ['event-shortcut', eventShortcut], + ['room-shortcut', roomShortcut], + ]) { + let node = document.getElementById(key) as HTMLAnchorElement; + if (node) { + node.href = value; + } + } + + // ...insert HTML content + for (let [key, value] of [ + ['event-content', eventContent], + ]) { + let node = document.getElementById(key); + if (node) { + if ("msg" in value) { + node.textContent = value.msg; + } else if ("text" in value) { + node.textContent = value.text; + } else if ("html" in value) { + node.innerHTML = value.html; + } + } + } + + // ...set presentation + if (!("msg" in eventContent)) { + // If there's some event content, mark it as a spoiler. + document.getElementById('event-container')!. + setAttribute("data-mx-spoiler", ""); + } + + // Embed additional information in the notice, for use by the + // action buttons. + let report: IReport = { + accused_id: accusedId, + reporter_id: reporterId, + event_id: eventId, + room_id: roomId, + room_alias_or_id: roomAliasOrId, + }; + let notice = { + msgtype: "m.notice", + body: htmlToText(document.body.outerHTML, { wordwrap: false }), + format: "org.matrix.custom.html", + formatted_body: document.body.outerHTML, + }; + notice[ABUSE_REPORT_KEY] = report; + + let noticeEventId = await this.owner.mjolnir.client.sendMessage(config.managementRoom, notice); + if (kind !== Kind.ERROR) { + // Now let's display buttons. + for (let [label, action] of ACTIONS) { + // Display buttons for actions that can be executed. + if (!await action.canExecute(this.owner, report, moderationRoomId)) { + continue; + } + await this.owner.mjolnir.client.sendEvent(config.managementRoom, "m.reaction", { + "m.relates_to": { + "rel_type": "m.annotation", + "event_id": noticeEventId, + "key": `${action.emoji} ${await action.title(this.owner, report)} [${label}]` + } + }); + } + } + } + + private limitLength(text: string, maxLength: number, maxNewlines: number): string { + let originalLength = text.length + // Shorten text if it is too long. + if (text.length > maxLength) { + text = text.substring(0, maxLength); + } + // Shorten text if there are too many newlines. + // Note: This only looks for text newlines, not `
    `, `
  • ` or any other HTML box. + let index = -1; + let newLines = 0; + while (true) { + index = text.indexOf("\n", index); + if (index === -1) { + break; + } + index += 1; + newLines += 1; + if (newLines > maxNewlines) { + text = text.substring(0, index); + break; + } + }; + if (text.length < originalLength) { + return `${text}... [total: ${originalLength} characters]`; + } else { + return text; + } + } +} + +/** + * The actions we may be able to undertake in reaction to a report. + * + * As a list, ordered for displayed when users click on "Help". + */ +const ACTION_LIST = [ + new KickAccused(), + new RedactMessage(), + new MuteAccused(), + new BanAccused(), + new EscalateToServerModerationRoom(), + new IgnoreBadReport(), + new Help() +]; +/** + * The actions we may be able to undertake in reaction to a report. + * + * As a map of labels => actions. + */ +const ACTIONS = new Map(ACTION_LIST.map(action => [action.label, action])); + +function getHomeserver(userId: string): string { + return new UserID(userId).domain +} diff --git a/src/webapis/WebAPIs.ts b/src/webapis/WebAPIs.ts index 1512c178..9d7487a5 100644 --- a/src/webapis/WebAPIs.ts +++ b/src/webapis/WebAPIs.ts @@ -17,11 +17,10 @@ limitations under the License. import { Server } from "http"; import * as express from "express"; -import { JSDOM } from 'jsdom'; import { MatrixClient } from "matrix-bot-sdk"; import config from "../config"; - +import { ReportManager } from "../report/ReportManager"; /** * A common prefix for all web-exposed APIs. @@ -34,7 +33,7 @@ export class WebAPIs { private webController: express.Express = express(); private httpServer?: Server; - constructor(private client: MatrixClient) { + constructor(private reportManager: ReportManager) { // Setup JSON parsing. this.webController.use(express.json()); } @@ -153,91 +152,15 @@ export class WebAPIs { // with all Matrix homeservers, rather than just Synapse. event = await reporterClient.getEvent(roomId, eventId); } - let accusedId: string = event["sender"]; - - /* - Past this point, the following invariants hold: - - - The reporter is a member of `roomId`. - - Event `eventId` did take place in room `roomId`. - - The reporter could witness event `eventId` in room `roomId`. - - Event `eventId` was reported by user `accusedId`. - */ - - let { displayname: reporterDisplayName }: { displayname: string } = await this.client.getUserProfile(reporterId); - let { displayname: accusedDisplayName }: { displayname: string } = await this.client.getUserProfile(accusedId); - let roomAliasOrID = roomId; - try { - roomAliasOrID = await this.client.getPublishedAlias(roomId); - } catch (ex) { - // Ignore. - } - let eventShortcut = `https://matrix.to/#/${encodeURIComponent(roomId)}/${encodeURIComponent(eventId)}`; - let roomShortcut = `https://matrix.to/#/${encodeURIComponent(roomAliasOrID)}`; - let eventContent; - if (event["type"] === "m.room.encrypted") { - eventContent = ""; - } else { - eventContent = JSON.stringify(event["content"], null, 2); - } let reason = request.body["reason"]; - - // We now have all the information we need to produce an abuse report. - - // We need to send the report as html to be able to use spoiler markings. - // We build this as dom to be absolutely certain that we're not introducing - // any kind of injection within the report. - const document = new JSDOM( - "" + - "User () " + - "reported event " + - "sent by user () " + - "in room ." + - "
    Event content
    " + - "
    Reporter commented:
    " + - "") - .window - .document; - // ...insert text content - for (let [key, value] of [ - ['reporter-display-name', reporterDisplayName], - ['reporter-id', reporterId], - ['accused-display-name', accusedDisplayName], - ['accused-id', accusedId], - ['event-id', eventId], - ['room-alias-or-id', roomAliasOrID], - ['event-content', eventContent], - ['reason-content', reason || ""] - ]) { - document.getElementById(key)!.textContent = value; - } - // ...insert attributes - for (let [key, value] of [ - ['event-shortcut', eventShortcut], - ['room-shortcut', roomShortcut], - ]) { - (document.getElementById(key)! as HTMLAnchorElement).href = value; - } - // ...set presentation - if (event["type"] !== "m.room.encrypted") { - // If there's some event content, mark it as a spoiler. - document.getElementById('event-container')!. - setAttribute("data-mx-spoiler", ""); - } - - // Possible evolutions: in future versions, we could add the ability to one-click discard, kick, ban. - - // Send the report and we're done! - // We MUST send this report with the regular Mjölnir client. - await this.client.sendHtmlNotice(config.managementRoom, document.body.outerHTML); - - console.debug("Formatted abuse report sent"); + await this.reportManager.handleServerAbuseReport({ roomId, eventId, reporterId, event, reason }); // Match the spec behavior of `/report`: return 200 and an empty JSON. response.status(200).json({}); } catch (ex) { console.warn("Error responding to an abuse report", roomId, eventId, ex); + response.status(503); } } } diff --git a/test/integration/abuseReportTest.ts b/test/integration/abuseReportTest.ts index 48710bbc..6839b1f0 100644 --- a/test/integration/abuseReportTest.ts +++ b/test/integration/abuseReportTest.ts @@ -3,11 +3,22 @@ import { strict as assert } from "assert"; import config from "../../src/config"; import { matrixClient, mjolnir } from "./mjolnirSetupUtils"; import { newTestUser } from "./clientHelper"; +import { ReportManager, ABUSE_ACTION_CONFIRMATION_KEY, ABUSE_REPORT_KEY } from "../../src/report/ReportManager"; /** * Test the ability to turn abuse reports into room messages. */ +const REPORT_NOTICE_REGEXPS = { + reporter: /Filed by (?[^ ]*) \((?[^ ]*)\)/, + accused: /Against (?[^ ]*) \((?[^ ]*)\)/, + room: /Room (?[^ ]*)/, + event: /Event (?[^ ]*) Go to event/, + content: /Content (?.*)/, + comments: /Comments Comments (?.*)/ +}; + + describe("Test: Reporting abuse", async () => { it('Mjölnir intercepts abuse reports', async function() { this.timeout(10000); @@ -35,9 +46,15 @@ describe("Test: Reporting abuse", async () => { let goodText = `GOOD: ${Math.random()}`; // Will NOT be reported. let badText = `BAD: ${Math.random()}`; // Will be reported as abuse. let badText2 = `BAD: ${Math.random()}`; // Will be reported as abuse. + let badText3 = `BAD: ${Math.random()}`; // Will be reported as abuse. + let badText4 = [...Array(1024)].map(_ => `${Math.random()}`).join(""); // Text is too long. + let badText5 = [...Array(1024)].map(_ => "ABC").join("\n"); // Text has too many lines. let goodEventId = await goodUser.sendText(roomId, goodText); let badEventId = await badUser.sendText(roomId, badText); let badEventId2 = await badUser.sendText(roomId, badText2); + let badEventId3 = await badUser.sendText(roomId, badText3); + let badEventId4 = await badUser.sendText(roomId, badText4); + let badEventId5 = await badUser.sendText(roomId, badText5); let badEvent2Comment = `COMMENT: ${Math.random()}`; console.log("Test: Reporting abuse - send reports"); @@ -73,39 +90,264 @@ describe("Test: Reporting abuse", async () => { console.error("Could not send second report", e.body || e); throw e; } - // FIXME: Also test with embedded HTML. + + try { + await goodUser.doRequest("POST", `/_matrix/client/r0/rooms/${encodeURIComponent(roomId)}/report/${encodeURIComponent(badEventId3)}`, ""); + reportsToFind.push({ + reporterId: goodUserId, + accusedId: badUserId, + eventId: badEventId3, + text: badText3, + comment: null, + }); + } catch (e) { + console.error("Could not send third report", e.body || e); + throw e; + } + + try { + await goodUser.doRequest("POST", `/_matrix/client/r0/rooms/${encodeURIComponent(roomId)}/report/${encodeURIComponent(badEventId4)}`, ""); + reportsToFind.push({ + reporterId: goodUserId, + accusedId: badUserId, + eventId: badEventId4, + text: null, + textPrefix: badText4.substring(0, 256), + comment: null, + }); + } catch (e) { + console.error("Could not send fourth report", e.body || e); + throw e; + } + + try { + await goodUser.doRequest("POST", `/_matrix/client/r0/rooms/${encodeURIComponent(roomId)}/report/${encodeURIComponent(badEventId5)}`, ""); + reportsToFind.push({ + reporterId: goodUserId, + accusedId: badUserId, + eventId: badEventId5, + text: null, + textPrefix: badText5.substring(0, 256).split("\n").join(" "), + comment: null, + }); + } catch (e) { + console.error("Could not send fifth report", e.body || e); + throw e; + } console.log("Test: Reporting abuse - wait"); await new Promise(resolve => setTimeout(resolve, 1000)); let found = []; - let regexp = /^User ([^ ]*) \(([^ ]*)\) reported event ([^ ]*).*sent by user ([^ ]*) \(([^ ]*)\).*\n.*\nReporter commented: (.*)/m; for (let toFind of reportsToFind) { for (let event of notices) { if ("content" in event && "body" in event.content) { + if (!(ABUSE_REPORT_KEY in event.content) || event.content[ABUSE_REPORT_KEY].event_id != toFind.eventId) { + // Not a report or not our report. + continue; + } + let report = event.content[ABUSE_REPORT_KEY]; let body = event.content.body as string; - let match = body.match(regexp); - if (!match) { + let matches = new Map(); + for (let key of Object.keys(REPORT_NOTICE_REGEXPS)) { + let match = body.match(REPORT_NOTICE_REGEXPS[key]); + if (match) { + console.debug("We have a match", key, REPORT_NOTICE_REGEXPS[key], match.groups); + } else { + console.debug("Not a match", key, REPORT_NOTICE_REGEXPS[key]); + // Not a report, skipping. + matches = null; + break; + } + matches.set(key, match); + } + if (!matches) { // Not a report, skipping. continue; } - let [, reporterDisplay, reporterId, eventId, accusedDisplay, accusedId, reason] = match; - if (eventId != toFind.eventId) { - // Different event id, skipping. - continue; + + assert(body.length < 3000, `The report shouldn't be too long ${body.length}`); + assert(body.split("\n").length < 200, "The report shouldn't have too many newlines."); + + assert.equal(matches.get("event")!.groups.eventId, toFind.eventId, "The report should specify the correct event id");; + + assert.equal(matches.get("reporter")!.groups.reporterId, toFind.reporterId, "The report should specify the correct reporter"); + assert.equal(report.reporter_id, toFind.reporterId, "The embedded report should specify the correct reporter"); + assert.ok(toFind.reporterId.includes(matches.get("reporter")!.groups.reporterDisplay), "The report should display the correct reporter"); + + assert.equal(matches.get("accused")!.groups.accusedId, toFind.accusedId, "The report should specify the correct accused"); + assert.equal(report.accused_id, toFind.accusedId, "The embedded report should specify the correct accused"); + assert.ok(toFind.accusedId.includes(matches.get("accused")!.groups.accusedDisplay), "The report should display the correct reporter"); + + if (toFind.text) { + assert.equal(matches.get("content")!.groups.eventContent, toFind.text, "The report should contain the text we inserted in the event"); + } + if (toFind.textPrefix) { + assert.ok(matches.get("content")!.groups.eventContent.startsWith(toFind.textPrefix), `The report should contain a prefix of the long text we inserted in the event: ${toFind.textPrefix} in? ${matches.get("content")!.groups.eventContent}`); } - assert.equal(reporterId, toFind.reporterId, "The report should specify the correct reporter"); - assert.ok(toFind.reporterId.includes(reporterDisplay), "The report should display the correct reporter"); - assert.equal(accusedId, toFind.accusedId, "The report should specify the correct accused"); - assert.ok(toFind.accusedId.includes(accusedDisplay), "The report should display the correct reporter"); - assert.ok(body.includes(toFind.text), "The report should contain the text we inserted in the event"); if (toFind.comment) { - assert.equal(reason, toFind.comment, "The report should contain the comment we added"); + assert.equal(matches.get("comments")!.groups.comments, toFind.comment, "The report should contain the comment we added"); } + assert.equal(matches.get("room")!.groups.roomAliasOrId, roomId, "The report should specify the correct room"); + assert.equal(report.room_id, roomId, "The embedded report should specify the correct room"); found.push(toFind); break; } } } - assert.deepEqual(reportsToFind, found); - }) + assert.deepEqual(found, reportsToFind); + + // Since Mjölnir is not a member of the room, the only buttons we should find + // are `help` and `ignore`. + for (let event of notices) { + if (event.content && event.content["m.relates_to"] && event.content["m.relates_to"]["key"]) { + let regexp = /\/([[^]]*)\]/; + let matches = event.content["m.relates_to"]["key"].match(regexp); + if (!matches) { + continue; + } + switch (matches[1]) { + case "bad-report": + case "help": + continue; + default: + throw new Error(`Didn't expect label ${matches[1]}`); + } + } + } + }); + it('The redact action works', async function() { + this.timeout(10000); + + // Listen for any notices that show up. + let notices = []; + matrixClient().on("room.event", (roomId, event) => { + if (roomId = config.managementRoom) { + notices.push(event); + } + }); + + // Create a moderator. + let moderatorUser = await newTestUser(false, "reacting-abuse-moderator-user"); + matrixClient().inviteUser(await moderatorUser.getUserId(), config.managementRoom); + await moderatorUser.joinRoom(config.managementRoom); + + // Create a few users and a room. + let goodUser = await newTestUser(false, "reacting-abuse-good-user"); + let badUser = await newTestUser(false, "reacting-abuse-bad-user"); + let goodUserId = await goodUser.getUserId(); + let badUserId = await badUser.getUserId(); + + let roomId = await moderatorUser.createRoom({ invite: [await badUser.getUserId()] }); + await moderatorUser.inviteUser(await goodUser.getUserId(), roomId); + await moderatorUser.inviteUser(await badUser.getUserId(), roomId); + await badUser.joinRoom(roomId); + await goodUser.joinRoom(roomId); + + // Setup Mjölnir as moderator for our room. + await moderatorUser.inviteUser(await matrixClient().getUserId(), roomId); + await moderatorUser.setUserPowerLevel(await matrixClient().getUserId(), roomId, 100); + + console.log("Test: Reporting abuse - send messages"); + // Exchange a few messages. + let goodText = `GOOD: ${Math.random()}`; // Will NOT be reported. + let badText = `BAD: ${Math.random()}`; // Will be reported as abuse. + let goodEventId = await goodUser.sendText(roomId, goodText); + let badEventId = await badUser.sendText(roomId, badText); + let goodEventId2 = await goodUser.sendText(roomId, goodText); + + console.log("Test: Reporting abuse - send reports"); + + // Time to report. + let reportToFind = { + reporterId: goodUserId, + accusedId: badUserId, + eventId: badEventId, + text: badText, + comment: null, + }; + try { + await goodUser.doRequest("POST", `/_matrix/client/r0/rooms/${encodeURIComponent(roomId)}/report/${encodeURIComponent(badEventId)}`); + } catch (e) { + console.error("Could not send first report", e.body || e); + throw e; + } + + console.log("Test: Reporting abuse - wait"); + await new Promise(resolve => setTimeout(resolve, 1000)); + + let mjolnirRooms = new Set(await matrixClient().getJoinedRooms()); + assert.ok(mjolnirRooms.has(roomId), "Mjölnir should be a member of the room"); + + // Find the notice + let noticeId; + for (let event of notices) { + if ("content" in event && ABUSE_REPORT_KEY in event.content) { + if (!(ABUSE_REPORT_KEY in event.content) || event.content[ABUSE_REPORT_KEY].event_id != badEventId) { + // Not a report or not our report. + continue; + } + noticeId = event.event_id; + break; + } + } + assert.ok(noticeId, "We should have found our notice"); + + // Find the buttons. + let buttons = []; + for (let event of notices) { + if (event["type"] != "m.reaction") { + continue; + } + if (event["content"]["m.relates_to"]["rel_type"] != "m.annotation") { + continue; + } + if (event["content"]["m.relates_to"]["event_id"] != noticeId) { + continue; + } + buttons.push(event); + } + + // Find the redact button... and click it. + let redactButtonId = null; + for (let button of buttons) { + if (button["content"]["m.relates_to"]["key"].includes("[redact-message]")) { + redactButtonId = button["event_id"]; + await moderatorUser.sendEvent(config.managementRoom, "m.reaction", button["content"]); + break; + } + } + assert.ok(redactButtonId, "We should have found the redact button"); + + await new Promise(resolve => setTimeout(resolve, 1000)); + + // This should have triggered a confirmation request, with more buttons! + let confirmEventId = null; + for (let event of notices) { + console.debug("Is this the confirm button?", event); + if (!event["content"]["m.relates_to"]) { + console.debug("Not a reaction"); + continue; + } + if (!event["content"]["m.relates_to"]["key"].includes("[confirm]")) { + console.debug("Not confirm"); + continue; + } + if (!event["content"]["m.relates_to"]["event_id"] == redactButtonId) { + console.debug("Not reaction to redact button"); + continue; + } + + // It's the confirm button, click it! + confirmEventId = event["event_id"]; + await moderatorUser.sendEvent(config.managementRoom, "m.reaction", event["content"]); + break; + } + assert.ok(confirmEventId, "We should have found the confirm button"); + + await new Promise(resolve => setTimeout(resolve, 1000)); + + // This should have redacted the message. + let newBadEvent = await matrixClient().getEvent(roomId, badEventId); + assert.deepEqual(Object.keys(newBadEvent.content), [], "Redaction should have removed the content of the offending event"); + }); }); diff --git a/test/integration/clientHelper.ts b/test/integration/clientHelper.ts index d34bc681..b83d4133 100644 --- a/test/integration/clientHelper.ts +++ b/test/integration/clientHelper.ts @@ -7,7 +7,7 @@ import config from "../../src/config"; * Register a user using the synapse admin api that requires the use of a registration secret rather than an admin user. * This should only be used by test code and should not be included from any file in the source directory * either by explicit imports or copy pasting. - * + * * @param username The username to give the user. * @param displayname The displayname to give the user. * @param password The password to use. @@ -55,7 +55,7 @@ export async function registerNewTestUser(isAdmin: boolean, label: string = "") }) } while (!isUserValid); return username; -} +} /** * Registers a unique test user and returns a `MatrixClient` logged in and ready to use. @@ -81,5 +81,5 @@ export function noticeListener(targetRoomdId: string, cb) { if (roomId !== targetRoomdId) return; if (event?.content?.msgtype !== "m.notice") return; cb(event); - } + } } diff --git a/test/integration/manualLaunchScript.ts b/test/integration/manualLaunchScript.ts index ccc6aab0..aff44407 100644 --- a/test/integration/manualLaunchScript.ts +++ b/test/integration/manualLaunchScript.ts @@ -6,5 +6,5 @@ import { makeMjolnir } from "./mjolnirSetupUtils"; (async () => { let mjolnir = await makeMjolnir(); - await mjolnir.start() + await mjolnir.start(); })(); diff --git a/test/integration/mjolnirSetupUtils.ts b/test/integration/mjolnirSetupUtils.ts index 6b8219b1..2bdc8564 100644 --- a/test/integration/mjolnirSetupUtils.ts +++ b/test/integration/mjolnirSetupUtils.ts @@ -36,7 +36,9 @@ export async function ensureAliasedRoomExists(client: MatrixClient, alias: strin .catch(async e => { if (e?.body?.errcode === 'M_NOT_FOUND') { console.info(`${alias} hasn't been created yet, so we're making it now.`) - let roomId = await client.createRoom(); + let roomId = await client.createRoom({ + visibility: "public", + }); await client.createRoomAlias(config.managementRoom, roomId); return roomId } @@ -49,11 +51,11 @@ async function configureMjolnir() { await registerUser('mjolnir', 'mjolnir', 'mjolnir', true) } catch (e) { if (e.isAxiosError) { - console.log('Received error while registering', e); if (e.response.data && e.response.data.errcode === 'M_USER_IN_USE') { console.log('mjolnir already registered, skipping'); return; } + console.log('Received error while registering', e); } throw e; }; diff --git a/test/nginx.conf b/test/nginx.conf index 8bd1a5a6..9b730911 100644 --- a/test/nginx.conf +++ b/test/nginx.conf @@ -5,7 +5,6 @@ events { http { server { listen 8081; - server_name localhost; location ~ ^/_matrix/client/r0/rooms/([^/]*)/report/(.*)$ { # Abuse reports should be sent to Mjölnir. @@ -25,7 +24,7 @@ http { } location / { # Everything else should be sent to Synapse. - proxy_pass http://localhost:9999; + proxy_pass http://127.0.0.1:9999; } } } diff --git a/yarn.lock b/yarn.lock index 5683437d..53957ed9 100644 --- a/yarn.lock +++ b/yarn.lock @@ -70,6 +70,14 @@ "@types/yargs" "^16.0.0" chalk "^4.0.0" +"@selderee/plugin-htmlparser2@^0.6.0": + version "0.6.0" + resolved "https://registry.yarnpkg.com/@selderee/plugin-htmlparser2/-/plugin-htmlparser2-0.6.0.tgz#27e994afd1c2cb647ceb5406a185a5574188069d" + integrity sha512-J3jpy002TyBjd4N/p6s+s90eX42H2eRhK3SbsZuvTDv977/E8p2U3zikdiehyJja66do7FlxLomZLPlvl2/xaA== + dependencies: + domhandler "^4.2.0" + selderee "^0.6.0" + "@tootallnate/once@1": version "1.1.2" resolved "https://registry.yarnpkg.com/@tootallnate/once/-/once-1.1.2.tgz#ccb91445360179a04e7fe6aff78c00ffc1eeaf82" @@ -727,6 +735,11 @@ diff@^4.0.1: resolved "https://registry.yarnpkg.com/diff/-/diff-4.0.2.tgz#60f3aecb89d5fae520c11aa19efc2bb982aade7d" integrity sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A== +discontinuous-range@1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/discontinuous-range/-/discontinuous-range-1.0.0.tgz#e38331f0844bba49b9a9cb71c771585aab1bc65a" + integrity sha1-44Mx8IRLukm5qctxx3FYWqsbxlo= + doctrine@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/doctrine/-/doctrine-3.0.0.tgz#addebead72a6574db783639dc87a121773973961" @@ -1294,6 +1307,18 @@ html-to-text@^6.0.0: lodash "^4.17.20" minimist "^1.2.5" +html-to-text@^8.0.0: + version "8.0.0" + resolved "https://registry.yarnpkg.com/html-to-text/-/html-to-text-8.0.0.tgz#5848681a5a38d657a7bb58cf5006d1c29fe64ce3" + integrity sha512-fEtul1OerF2aMEV+Wpy+Ue20tug134jOY1GIudtdqZi7D0uTudB2tVJBKfVhTL03dtqeJoF8gk8EPX9SyMEvLg== + dependencies: + "@selderee/plugin-htmlparser2" "^0.6.0" + deepmerge "^4.2.2" + he "^1.2.0" + htmlparser2 "^6.1.0" + minimist "^1.2.5" + selderee "^0.6.0" + htmlencode@^0.0.4: version "0.0.4" resolved "https://registry.yarnpkg.com/htmlencode/-/htmlencode-0.0.4.tgz#f7e2d6afbe18a87a78e63ba3308e753766740e3f" @@ -1309,7 +1334,7 @@ htmlparser2@^4.1.0: domutils "^2.0.0" entities "^2.0.0" -htmlparser2@^6.0.0: +htmlparser2@^6.0.0, htmlparser2@^6.1.0: version "6.1.0" resolved "https://registry.yarnpkg.com/htmlparser2/-/htmlparser2-6.1.0.tgz#c4d762b6c3371a05dbe65e94ae43a9f845fb8fb7" integrity sha512-gyyPk6rgonLFEDGoeRgQNaEUvdJ4ktTmmUh/h2t7s+M8oPpIPxgNACWa+6ESR57kXstwqPiCut0V8NRpcwgU7A== @@ -1849,6 +1874,11 @@ mocha@^9.0.1: yargs-parser "20.2.4" yargs-unparser "2.0.0" +moo@^0.5.0, moo@^0.5.1: + version "0.5.1" + resolved "https://registry.yarnpkg.com/moo/-/moo-0.5.1.tgz#7aae7f384b9b09f620b6abf6f74ebbcd1b65dbc4" + integrity sha512-I1mnb5xn4fO80BH9BLcF0yLypy2UKl+Cb01Fu0hJRkJjlCRtxZMWkTdAtDd5ZqCOxtCkhmRwyI57vWT+1iZ67w== + morgan@^1.10.0: version "1.10.0" resolved "https://registry.yarnpkg.com/morgan/-/morgan-1.10.0.tgz#091778abc1fc47cd3509824653dae1faab6b17d7" @@ -1900,6 +1930,16 @@ natural-compare@^1.4.0: resolved "https://registry.yarnpkg.com/natural-compare/-/natural-compare-1.4.0.tgz#4abebfeed7541f2c27acfb29bdbbd15c8d5ba4f7" integrity sha1-Sr6/7tdUHywnrPspvbvRXI1bpPc= +nearley@^2.20.1: + version "2.20.1" + resolved "https://registry.yarnpkg.com/nearley/-/nearley-2.20.1.tgz#246cd33eff0d012faf197ff6774d7ac78acdd474" + integrity sha512-+Mc8UaAebFzgV+KpI5n7DasuuQCHA89dmwm7JXw3TV43ukfNQ9DnBH3Mdb2g/I4Fdxc26pwimBWvjIw0UAILSQ== + dependencies: + commander "^2.19.0" + moo "^0.5.0" + railroad-diagrams "^1.0.0" + randexp "0.4.6" + negotiator@0.6.2: version "0.6.2" resolved "https://registry.yarnpkg.com/negotiator/-/negotiator-0.6.2.tgz#feacf7ccf525a77ae9634436a64883ffeca346fb" @@ -1994,6 +2034,14 @@ parse5@6.0.1: resolved "https://registry.yarnpkg.com/parse5/-/parse5-6.0.1.tgz#e1a1c085c569b3dc08321184f19a39cc27f7c30b" integrity sha512-Ofn/CTFzRGTTxwpNEs9PP93gXShHcTq255nzRYSKe8AkVpZY7e1fpmTfOyoIvjP5HG7Z2ZM7VS9PPhQGW2pOpw== +parseley@^0.7.0: + version "0.7.0" + resolved "https://registry.yarnpkg.com/parseley/-/parseley-0.7.0.tgz#9949e3a0ed05c5072adb04f013c2810cf49171a8" + integrity sha512-xyOytsdDu077M3/46Am+2cGXEKM9U9QclBDv7fimY7e+BBlxh2JcBp2mgNsmkyA9uvgyTjVzDi7cP1v4hcFxbw== + dependencies: + moo "^0.5.1" + nearley "^2.20.1" + parseurl@~1.3.3: version "1.3.3" resolved "https://registry.yarnpkg.com/parseurl/-/parseurl-1.3.3.tgz#9da19e7bee8d12dff0513ed5b76957793bc2e8d4" @@ -2106,6 +2154,19 @@ qs@~6.5.2: resolved "https://registry.yarnpkg.com/qs/-/qs-6.5.2.tgz#cb3ae806e8740444584ef154ce8ee98d403f3e36" integrity sha512-N5ZAX4/LxJmF+7wN74pUD6qAh9/wnvdQcjq9TZjevvXzSUo7bfmw91saqMjzGS2xq91/odN2dW/WOl7qQHNDGA== +railroad-diagrams@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/railroad-diagrams/-/railroad-diagrams-1.0.0.tgz#eb7e6267548ddedfb899c1b90e57374559cddb7e" + integrity sha1-635iZ1SN3t+4mcG5Dlc3RVnN234= + +randexp@0.4.6: + version "0.4.6" + resolved "https://registry.yarnpkg.com/randexp/-/randexp-0.4.6.tgz#e986ad5e5e31dae13ddd6f7b3019aa7c87f60ca3" + integrity sha512-80WNmd9DA0tmZrw9qQa62GPPWfuXJknrmVmLcxvq4uZBdYqb1wYoKTmnlGUchvVWe0XiLupYkBoXVOxz3C8DYQ== + dependencies: + discontinuous-range "1.0.0" + ret "~0.1.10" + randombytes@^2.1.0: version "2.1.0" resolved "https://registry.yarnpkg.com/randombytes/-/randombytes-2.1.0.tgz#df6f84372f0270dc65cdf6291349ab7a473d4f2a" @@ -2211,6 +2272,11 @@ resolve@^1.3.2: is-core-module "^2.2.0" path-parse "^1.0.6" +ret@~0.1.10: + version "0.1.15" + resolved "https://registry.yarnpkg.com/ret/-/ret-0.1.15.tgz#b8a4825d5bdb1fc3f6f53c2bc33f81388681c7bc" + integrity sha512-TTlYpa+OL+vMMNG24xSlQGEJ3B/RzEfUlLct7b5G/ytav+wPrplCpVMFuwzXbkecJrb6IYo1iFb0S9v37754mg== + rimraf@^3.0.2: version "3.0.2" resolved "https://registry.yarnpkg.com/rimraf/-/rimraf-3.0.2.tgz#f1a5402ba6220ad52cc1282bac1ae3aa49fd061a" @@ -2253,6 +2319,13 @@ saxes@^5.0.1: dependencies: xmlchars "^2.2.0" +selderee@^0.6.0: + version "0.6.0" + resolved "https://registry.yarnpkg.com/selderee/-/selderee-0.6.0.tgz#f3bee66cfebcb6f33df98e4a1df77388b42a96f7" + integrity sha512-ibqWGV5aChDvfVdqNYuaJP/HnVBhlRGSRrlbttmlMpHcLuTqqbMH36QkSs9GEgj5M88JDYLI8eyP94JaQ8xRlg== + dependencies: + parseley "^0.7.0" + semver@^5.3.0, semver@^5.6.0: version "5.7.1" resolved "https://registry.yarnpkg.com/semver/-/semver-5.7.1.tgz#a954f931aeba508d307bbf069eff0c01c96116f7"