Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 8 additions & 5 deletions src/commands/interface-manager/DeadDocumentMatrix.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@ export async function renderMatrix(node: DocumentNode, cb: SendMatrixEventCB): P
* @param event An event to reply to.
* @param client A MatrixClient to send the events with.
*/
export async function renderMatrixAndSend(node: DocumentNode, roomId: string, event: any, client: MatrixSendClient): Promise<string[]> {
export async function renderMatrixAndSend(node: DocumentNode, roomId: string, event: any|undefined, client: MatrixSendClient): Promise<string[]> {
const baseContent = (text: string, html: string) => {
return {
msgtype: "m.notice",
Expand All @@ -91,11 +91,14 @@ export async function renderMatrixAndSend(node: DocumentNode, roomId: string, ev
const renderInitialReply = async (text: string, html: string) => {
return await client.sendMessage(roomId, {
...baseContent(text, html),
"m.relates_to": {
"m.in_reply_to": {
"event_id": event['event_id']
...event === undefined
? {}
: { "m.relates_to": {
"m.in_reply_to": {
"event_id": event['event_id']
}
}
}
}
})
};
const renderThreadReply = async (eventId: string, text: string, html: string) => {
Expand Down
5 changes: 5 additions & 0 deletions src/commands/interface-manager/MatrixHelpRenderer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -105,4 +105,9 @@ function renderCommandException(command: InterfaceCommand<BaseFunction>, error:
Details can be found by providing the reference <code>{error.uuid}</code>
to an administrator.
</root>
}

export function renderMentionPill(mxid: string, displayName: string): DocumentNode {
const url = `https://matrix.to/#/${mxid}`;
return <a href={url}>{displayName}</a>
}
4 changes: 3 additions & 1 deletion src/commands/interface-manager/MatrixPromptUX.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,14 +106,16 @@ class ReactionHandler {
roomId: string, eventId: string, presentationByReaction: PresentationByReactionKey, timeout = 600_000 // ten minutes
): Promise<CommandResult<T, CommandError>> {
let record;
let timeoutId;
const presentationOrTimeout = await Promise.race([
new Promise(resolve => {
record = new ReactionPromptRecord(presentationByReaction, resolve);
this.addPresentationsForEvent(eventId, record);
this.addBaseReactionsToEvent(roomId, eventId, presentationByReaction);
}),
new Promise(resolve => setTimeout(resolve, timeout)),
new Promise(resolve => timeoutId = setTimeout(resolve, timeout)),
]);
clearTimeout(timeoutId);
if (presentationOrTimeout === undefined) {
if (record !== undefined) {
this.removePromptRecordForEvent(eventId, record);
Expand Down
108 changes: 108 additions & 0 deletions src/protections/BanPropagation.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
/**
* Copyright (C) 2022-2023 Gnuxie <[email protected]>
* All rights reserved.
*
* This file is modified and is NOT licensed under the Apache License.
* This modified file incorperates work from mjolnir
* https://github.com/matrix-org/mjolnir
* which included the following license notice:

Copyright 2019-2022 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.
*
* However, this file is modified and the modifications in this file
* are NOT distributed, contributed, committed, or licensed under the Apache License.
*/

import { Protection } from "./IProtection";
import { Mjolnir } from "../Mjolnir";
import { LogService } from "matrix-bot-sdk";
import { PromptResponseListener } from "../commands/interface-manager/MatrixPromptUX";
import { JSXFactory } from "../commands/interface-manager/JSXFactory";
import { renderMatrixAndSend } from "../commands/interface-manager/DeadDocumentMatrix";
import { renderMentionPill } from "../commands/interface-manager/MatrixHelpRenderer";
import { RULE_USER } from "../models/ListRule";

/**
* Prompt the management room to propagate a user ban to a policy list of their choice.
* @param mjolnir Mjolnir.
* @param event The ban event.
* @param roomId The room that the ban happened in.
* @returns An event id which can be used by the `PromptResponseListener`.
*/
async function promptBanPropagation(
mjolnir: Mjolnir,
event: any,
roomId: string
): Promise</*event id*/string> {
return (await renderMatrixAndSend(
<root>The user {renderMentionPill(event["state_key"], event["content"]?.["displayname"] ?? event["state_key"])} was banned
in <a href={`https://matrix.to/#/${roomId}`}>{roomId}</a> for <code>{event["content"]?.["reason"] ?? '<no reason supplied>'}</code>.<br/>
Would you like to add the ban to a policy list?
<ol>
{mjolnir.policyListManager.lists.map(list => <li>{list}</li>)}
</ol>
</root>,
mjolnir.managementRoomId,
undefined,
mjolnir.client
)).at(0) as string;
}

export class BanPropagation extends Protection {

settings = {};

public get name(): string {
return 'BanPropagationProtection';
}
public get description(): string {
return "When you ban a user in any protected room with a client, this protection\
will turn the room level ban into a policy for a policy list of your choice.\
This will then allow the bot to ban the user from all of your rooms.";
}

public async handleEvent(mjolnir: Mjolnir, roomId: string, event: any): Promise<any> {
if (event['type'] !== 'm.room.member' || event['content']?.['membership'] !== 'ban') {
return;
}
if (mjolnir.policyListManager.lists.map(
list => list.rulesMatchingEntity(event['state_key'], RULE_USER)
).some(rules => rules.length > 0)
) {
return; // The user is already banned.
}
const promptListener = new PromptResponseListener(
mjolnir.matrixEmitter,
await mjolnir.client.getUserId(),
mjolnir.client
);
// do not await, we don't want to block the protection manager
promptListener.waitForPresentationList(
mjolnir.policyListManager.lists,
mjolnir.managementRoomId,
promptBanPropagation(mjolnir, event, roomId)
).then(listResult => {
if (listResult.isOk()) {
const list = listResult.ok;
return list.banEntity(RULE_USER, event['state_key'], event['content']?.["reason"]);
} else {
LogService.warn("BanPropagation", "Timed out waiting for a response to a room level ban", listResult.err);
return;
}
}).catch(e => {
LogService.error('BanPropagation', "Encountered an error while prompting the user for instructions to propagate a room level ban", e);
});
}
}
107 changes: 86 additions & 21 deletions src/protections/ProtectionManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,9 +41,12 @@ import { Consequence } from "./consequence";
import { htmlEscape } from "../utils";
import { ERROR_KIND_FATAL, ERROR_KIND_PERMISSION } from "../ErrorCache";
import { RoomUpdateError } from "../models/RoomUpdateError";
import { BanPropagation } from "./BanPropagation";
import { MatrixDataManager, RawSchemedData, SchemaMigration, SCHEMA_VERSION_KEY } from "../models/MatrixDataManager";

const PROTECTIONS: Protection[] = [
new FirstMessageIsImage(),
new BanPropagation(),
new BasicFlooding(),
new WordList(),
new MessageIsVoice(),
Expand All @@ -54,26 +57,105 @@ const PROTECTIONS: Protection[] = [
];

const ENABLED_PROTECTIONS_EVENT_TYPE = "org.matrix.mjolnir.enabled_protections";
type EnabledProtectionsEvent = RawSchemedData & {
enabled: string[],
}

class EnabledProtectionsManager extends MatrixDataManager<EnabledProtectionsEvent> {
protected readonly schema: SchemaMigration[] = [
async function enableBanPropagationByDefault(input: EnabledProtectionsEvent) {
const enabled = new Set(input.enabled);
const banPropagationProtection = PROTECTIONS.find(p => p.name === 'BanPropagationProtection');
if (banPropagationProtection === undefined) {
throw new TypeError("Couldn't find the ban propagation protection");
}
enabled.add(banPropagationProtection.name)
return {
enabled: [...enabled],
[SCHEMA_VERSION_KEY]: 1,
}
}
];
protected readonly isAllowedToInferNoVersionAsZero = true;
private readonly enabledProtections = new Set</* protection name */string>();

constructor(
private readonly mjolnir: Mjolnir
) {
super()
}

protected async requestMatrixData(): Promise<unknown> {
try {
return await this.mjolnir.client.getAccountData(ENABLED_PROTECTIONS_EVENT_TYPE);
} catch (e) {
if (e.statusCode === 404) {
LogService.warn('PolicyListManager', "Couldn't find account data for Draupnir's protections, assuming first start.", e);
return this.createFirstData();
} else {
throw e;
}
}
}

protected async storeMatixData(): Promise<void> {
const data: EnabledProtectionsEvent = {
enabled: [...this.enabledProtections],
[SCHEMA_VERSION_KEY]: 1,
}
await this.mjolnir.client.setAccountData(ENABLED_PROTECTIONS_EVENT_TYPE, data);
}

protected async createFirstData(): Promise<EnabledProtectionsEvent> {
return { enabled: [], [SCHEMA_VERSION_KEY]: 0 };
}

public isEnabled(protection: Protection): boolean {
return this.enabledProtections.has(protection.name);
}

public async enable(protection: Protection): Promise<void> {
this.enabledProtections.add(protection.name);
protection.enabled = true;
await this.storeMatixData();
}

public async disable(protection: Protection): Promise<void> {
this.enabledProtections.delete(protection.name);
protection.enabled = false;
await this.storeMatixData();
}

public async start(): Promise<void> {
const data = await this.loadData();
for (const protection of data.enabled) {
this.enabledProtections.add(protection);
}
}
}

const CONSEQUENCE_EVENT_DATA = "org.matrix.mjolnir.consequence";

/**
* This is responsible for informing protections about relevant events and handle standard consequences.
*/
export class ProtectionManager {
private enabledProtectionsManager: EnabledProtectionsManager;
private _protections = new Map<string /* protection name */, Protection>();
get protections(): Readonly<Map<string /* protection name */, Protection>> {
return this._protections;
}


constructor(private readonly mjolnir: Mjolnir) {
this.enabledProtectionsManager = new EnabledProtectionsManager(this.mjolnir);
}

/*
* Take all the builtin protections, register them to set their enabled (or not) state and
* update their settings with any saved non-default values
*/
public async start() {
await this.enabledProtectionsManager.start();
this.mjolnir.reportManager.on("report.new", this.handleReport.bind(this));
this.mjolnir.matrixEmitter.on("room.event", this.handleEvent.bind(this));
for (const protection of PROTECTIONS) {
Expand All @@ -97,15 +179,7 @@ export class ProtectionManager {
*/
public async registerProtection(protection: Protection) {
this._protections.set(protection.name, protection)

let enabledProtections: { enabled: string[] } | null = null;
try {
enabledProtections = await this.mjolnir.client.getAccountData(ENABLED_PROTECTIONS_EVENT_TYPE);
} catch {
// this setting either doesn't exist, or we failed to read it (bad network?)
// TODO: retry on certain failures?
}
protection.enabled = enabledProtections?.enabled.includes(protection.name) ?? false;
protection.enabled = this.enabledProtectionsManager.isEnabled(protection) ?? false;

const savedSettings = await this.getProtectionSettings(protection.name);
for (let [key, value] of Object.entries(savedSettings)) {
Expand Down Expand Up @@ -160,13 +234,6 @@ export class ProtectionManager {
);
}

/*
* Make a list of the names of enabled protections and save them in a state event
*/
private async saveEnabledProtections() {
const protections = this.enabledProtections.map(p => p.name);
await this.mjolnir.client.setAccountData(ENABLED_PROTECTIONS_EVENT_TYPE, { enabled: protections });
}
/*
* Enable a protection by name and persist its enable state in to a state event
*
Expand All @@ -175,8 +242,7 @@ export class ProtectionManager {
public async enableProtection(name: string) {
const protection = this._protections.get(name);
if (protection !== undefined) {
protection.enabled = true;
await this.saveEnabledProtections();
await this.enabledProtectionsManager.enable(protection);
}
}

Expand All @@ -203,8 +269,7 @@ export class ProtectionManager {
public async disableProtection(name: string) {
const protection = this._protections.get(name);
if (protection !== undefined) {
protection.enabled = false;
await this.saveEnabledProtections();
await this.enabledProtectionsManager.disable(protection);
}
}

Expand Down
58 changes: 58 additions & 0 deletions test/integration/banPropagationTest.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import expect from "expect";
import { Mjolnir } from "../../src/Mjolnir";
import { newTestUser } from "./clientHelper";
import { getFirstEventMatching } from './commands/commandUtils';
import { Permalinks } from "matrix-bot-sdk";
import { RULE_USER } from "../../src/models/ListRule";

// We will need to disable this in tests that are banning people otherwise it will cause
// mocha to hang for awhile until it times out waiting for a response to a prompt.
describe("Ban propagation test", function() {
it("Should be enabled by default", async function() {
const mjolnir: Mjolnir = this.mjolnir
expect(mjolnir.protectionManager.getProtection("BanPropagationProtection")?.enabled).toBeTruthy();
})
it("Should prompt to add bans to a policy list, then add the ban", async function() {
const mjolnir: Mjolnir = this.mjolnir
const mjolnirId = await mjolnir.client.getUserId();
const moderator = await newTestUser(this.config.homeserverUrl, { name: { contains: "moderator" } });
await moderator.joinRoom(mjolnir.managementRoomId);
const protectedRooms = await Promise.all([...Array(5)].map(async _ => {
const room = await moderator.createRoom({ invite: [mjolnirId] });
await mjolnir.client.joinRoom(room);
await moderator.setUserPowerLevel(mjolnirId, room, 100);
await mjolnir.addProtectedRoom(room);
return room;
}));
// create a policy list so that we can check it for a user rule later
const policyListId = await moderator.createRoom({ invite: [mjolnirId] });
await moderator.setUserPowerLevel(mjolnirId, policyListId, 100);
await mjolnir.client.joinRoom(policyListId);
await mjolnir.policyListManager.watchList(Permalinks.forRoom(policyListId));

// check for the prompt
const promptEvent = await getFirstEventMatching({
matrix: mjolnir.matrixEmitter,
targetRoom: mjolnir.managementRoomId,
lookAfterEvent: async function () {
// ban a user in one of our protected rooms using the moderator
await moderator.banUser('@test:example.com', protectedRooms[0], "spam");
return undefined;
},
predicate: function (event: any): boolean {
return (event['content']?.['body'] ?? '').startsWith('The user')
}
})
// select the prompt
await moderator.unstableApis.addReactionToEvent(
mjolnir.managementRoomId, promptEvent['event_id'], '1.'
);
// check the policy list, after waiting a few seconds.
await new Promise(resolve => setTimeout(resolve, 10000));
const policyList = mjolnir.policyListManager.lists[0];
const rules = policyList.rulesMatchingEntity('@test:example.com', RULE_USER);
expect(rules.length).toBe(1);
expect(rules[0].entity).toBe('@test:example.com');
expect(rules[0].reason).toBe('spam');
})
})
Loading