mirror of
https://github.com/matrix-org/mjolnir.git
synced 2024-10-01 01:36:06 -04:00
Give the ability to moderators to react quickly to /report abuse reports. (#137)
This commit is contained in:
parent
e7195678d4
commit
a21415a04c
25
.github/workflows/mjolnir.yml
vendored
Normal file
25
.github/workflows/mjolnir.yml
vendored
Normal file
@ -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
|
@ -1,8 +1,5 @@
|
||||
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
|
||||
@ -10,60 +7,8 @@ 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
|
||||
|
@ -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"
|
||||
|
@ -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[] {
|
||||
|
908
src/report/ReportManager.ts
Normal file
908
src/report/ReportManager.ts
Normal file
@ -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 || "<unknown error>",
|
||||
"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<boolean>;
|
||||
|
||||
/**
|
||||
* A human-readable title to display for the end-user.
|
||||
*
|
||||
* @param report Details on the abuse report.
|
||||
*/
|
||||
title(manager: ReportManager, report: IReport): Promise<string>;
|
||||
|
||||
/**
|
||||
* Attempt to execute the action.
|
||||
*/
|
||||
execute(manager: ReportManager, report: IReport, moderationRoomId: string, displayManager: DisplayManager): Promise<string | undefined>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<boolean> {
|
||||
return true;
|
||||
}
|
||||
public async title(_manager: ReportManager, _report: IReport): Promise<string> {
|
||||
return "Ignore bad report";
|
||||
}
|
||||
public async execute(manager: ReportManager, report: IReportWithAction): Promise<string | undefined> {
|
||||
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<boolean> {
|
||||
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<string> {
|
||||
return `Redact event ${report.event_id}`;
|
||||
}
|
||||
public async execute(manager: ReportManager, report: IReport, _moderationRoomId: string): Promise<string | undefined> {
|
||||
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<boolean> {
|
||||
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<string> {
|
||||
return `Kick ${report.accused_id} from room ${report.room_alias_or_id}`;
|
||||
}
|
||||
public async execute(manager: ReportManager, report: IReport): Promise<string | undefined> {
|
||||
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<boolean> {
|
||||
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<string> {
|
||||
return `Mute ${report.accused_id} in room ${report.room_alias_or_id}`;
|
||||
}
|
||||
public async execute(manager: ReportManager, report: IReport): Promise<string | undefined> {
|
||||
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<boolean> {
|
||||
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<string> {
|
||||
return `Ban ${report.accused_id} from room ${report.room_alias_or_id}`;
|
||||
}
|
||||
public async execute(manager: ReportManager, report: IReport): Promise<string | undefined> {
|
||||
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<boolean> {
|
||||
return true;
|
||||
}
|
||||
public async title(_manager: ReportManager, _report: IReport): Promise<string> {
|
||||
return "Help";
|
||||
}
|
||||
public async execute(manager: ReportManager, report: IReport): Promise<string | undefined> {
|
||||
// Produce a html list of actions, in the order specified by ACTION_LIST.
|
||||
let list: string[] = [];
|
||||
for (let action of ACTION_LIST) {
|
||||
list.push(`<li>${action.emoji} ${await action.title(manager, report)}</li>`);
|
||||
}
|
||||
let body = `<ul>${list.join("\n")}</ul>`;
|
||||
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<boolean> {
|
||||
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<string> {
|
||||
return `Escalate report to ${getHomeserver(await manager.mjolnir.client.getUserId())} server moderators`;
|
||||
}
|
||||
public async execute(manager: ReportManager, report: IReport, _moderationRoomId: string, displayManager: DisplayManager): Promise<string | undefined> {
|
||||
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: "<encrypted content>" };
|
||||
} 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: `<Cannot extract event. Please verify that Mjölnir has been invited to room ${roomAliasOrId} and made room moderator or administrator>.` };
|
||||
}
|
||||
|
||||
let accusedId = event["sender"];
|
||||
|
||||
let reporterDisplayName: string, accusedDisplayName: string;
|
||||
try {
|
||||
reporterDisplayName = await this.owner.mjolnir.client.getUserProfile(reporterId)["displayname"] || reporterId;
|
||||
} catch (ex) {
|
||||
reporterDisplayName = "<Error: Cannot extract reporter display name>";
|
||||
}
|
||||
try {
|
||||
accusedDisplayName = await this.owner.mjolnir.client.getUserProfile(accusedId)["displayname"] || accusedId;
|
||||
} catch (ex) {
|
||||
accusedDisplayName = "<Error: Cannot extract accused display name>";
|
||||
}
|
||||
|
||||
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 = `<Cannot extract event. Please verify that Mjölnir has been invited to room ${roomAliasOrId} and made room moderator or administrator>.`;
|
||||
}
|
||||
|
||||
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(`
|
||||
<body>
|
||||
<div>
|
||||
<b><span id="title"></span></b>
|
||||
</div>
|
||||
<div>
|
||||
<b>Filed by</b> <span id='reporter-display-name'></span> (<code id='reporter-id'></code>)
|
||||
</div>
|
||||
<b>Against</b> <span id='accused-display-name'></span> (<code id='accused-id'></code>)
|
||||
<div>
|
||||
<b>Nature</b> <span id='nature-display'></span> (<code id='nature-source'></code>)
|
||||
</div>
|
||||
<div>
|
||||
<b>Room</b> <a id='room-shortcut'><span id='room-alias-or-id'></span></a>
|
||||
</div>
|
||||
<hr />
|
||||
<div id='details-or-error'>
|
||||
<details>
|
||||
<summary>Event details</summary>
|
||||
<div>
|
||||
<b>Event</b> <span id='event-id'></span> <a id='event-shortcut'>Go to event</a>
|
||||
</div>
|
||||
<div>
|
||||
<b>When</b> <span id='event-timestamp'></span>
|
||||
</div>
|
||||
<div>
|
||||
<b>Content</b> <span id='event-container'><code id='event-content'></code><span>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
<hr />
|
||||
<details>
|
||||
<summary>Comments</summary>
|
||||
<b>Comments</b> <code id='reason-content'></code></div>
|
||||
</details>
|
||||
</body>`).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 || "<no reason given>"],
|
||||
['nature-display', readableNature],
|
||||
['nature-source', nature || "<no nature provided>"],
|
||||
['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 `<div>`, `<li>` 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
|
||||
}
|
@ -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 = "<encrypted content>";
|
||||
} 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(
|
||||
"<body>" +
|
||||
"User <code id='reporter-display-name'></code> (<code id='reporter-id'></code>) " +
|
||||
"reported <a id='event-shortcut'>event <span id='event-id'></span></a> " +
|
||||
"sent by user <b><span id='accused-display-name'></span> (<span id='accused-id'></span>)</b> " +
|
||||
"in <a id='room-shortcut'>room <span id='room-alias-or-id'></span></a>." +
|
||||
"<div>Event content <span id='event-container'><code id='event-content'></code><span></div>" +
|
||||
"<div>Reporter commented: <code id='reason-content'></code></div>" +
|
||||
"</body>")
|
||||
.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 || "<no reason given>"]
|
||||
]) {
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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 (?<reporterDisplay>[^ ]*) \((?<reporterId>[^ ]*)\)/,
|
||||
accused: /Against (?<accusedDisplay>[^ ]*) \((?<accusedId>[^ ]*)\)/,
|
||||
room: /Room (?<roomAliasOrId>[^ ]*)/,
|
||||
event: /Event (?<eventId>[^ ]*) Go to event/,
|
||||
content: /Content (?<eventContent>.*)/,
|
||||
comments: /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 = `<b>BAD</b>: ${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");
|
||||
});
|
||||
});
|
||||
|
@ -6,5 +6,5 @@ import { makeMjolnir } from "./mjolnirSetupUtils";
|
||||
|
||||
(async () => {
|
||||
let mjolnir = await makeMjolnir();
|
||||
await mjolnir.start()
|
||||
await mjolnir.start();
|
||||
})();
|
||||
|
@ -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;
|
||||
};
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
75
yarn.lock
75
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"
|
||||
|
Loading…
Reference in New Issue
Block a user