Skip to content

Commit 5414c46

Browse files
authored
Ban Propagation protection (that is enabled by default) (#36)
* Experimental Protection to propagate room level bans to policies. - Needs an automated option - I really want this to be enabled by default - It needs to be easily configurable and very visible because it's a really useful feature. - Need to check that they are not already banned on a policy list. - Allow possibility to rely last message like a report behind spoiler text. * Use MatrixDataManager for enabled protections. This will allow us to create "enabled by default" protections via a schema migration. * Enable BanPropagationProtection by default * BanPropagation: only prompt when user is not already banned. * Test for BanPropagationProtection. * clearTimeout for prompt reactions if we got a reaction. * Allow renderMatrixAndSend to not need a reply. * document getFirstEventMatching
1 parent 0064546 commit 5414c46

File tree

8 files changed

+304
-28
lines changed

8 files changed

+304
-28
lines changed

src/commands/interface-manager/DeadDocumentMatrix.ts

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -76,10 +76,10 @@ export async function renderMatrix(node: DocumentNode, cb: SendMatrixEventCB): P
7676
* Render the document node to html+text `m.notice` events.
7777
* @param node The document node to render.
7878
* @param roomId The room to send the events to.
79-
* @param event An event to reply to.
79+
* @param event An event to reply to, if any.
8080
* @param client A MatrixClient to send the events with.
8181
*/
82-
export async function renderMatrixAndSend(node: DocumentNode, roomId: string, event: any, client: MatrixSendClient): Promise<string[]> {
82+
export async function renderMatrixAndSend(node: DocumentNode, roomId: string, event: any|undefined, client: MatrixSendClient): Promise<string[]> {
8383
const baseContent = (text: string, html: string) => {
8484
return {
8585
msgtype: "m.notice",
@@ -91,11 +91,14 @@ export async function renderMatrixAndSend(node: DocumentNode, roomId: string, ev
9191
const renderInitialReply = async (text: string, html: string) => {
9292
return await client.sendMessage(roomId, {
9393
...baseContent(text, html),
94-
"m.relates_to": {
95-
"m.in_reply_to": {
96-
"event_id": event['event_id']
94+
...event === undefined
95+
? {} // if they don't supply a reply just send a top level event.
96+
: { "m.relates_to": {
97+
"m.in_reply_to": {
98+
"event_id": event['event_id']
99+
}
100+
}
97101
}
98-
}
99102
})
100103
};
101104
const renderThreadReply = async (eventId: string, text: string, html: string) => {

src/commands/interface-manager/MatrixHelpRenderer.tsx

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,4 +105,9 @@ function renderCommandException(command: InterfaceCommand<BaseFunction>, error:
105105
Details can be found by providing the reference <code>{error.uuid}</code>
106106
to an administrator.
107107
</root>
108+
}
109+
110+
export function renderMentionPill(mxid: string, displayName: string): DocumentNode {
111+
const url = `https://matrix.to/#/${mxid}`;
112+
return <a href={url}>{displayName}</a>
108113
}

src/commands/interface-manager/MatrixPromptUX.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -106,14 +106,16 @@ class ReactionHandler {
106106
roomId: string, eventId: string, presentationByReaction: PresentationByReactionKey, timeout = 600_000 // ten minutes
107107
): Promise<CommandResult<T, CommandError>> {
108108
let record;
109+
let timeoutId;
109110
const presentationOrTimeout = await Promise.race([
110111
new Promise(resolve => {
111112
record = new ReactionPromptRecord(presentationByReaction, resolve);
112113
this.addPresentationsForEvent(eventId, record);
113114
this.addBaseReactionsToEvent(roomId, eventId, presentationByReaction);
114115
}),
115-
new Promise(resolve => setTimeout(resolve, timeout)),
116+
new Promise(resolve => timeoutId = setTimeout(resolve, timeout)),
116117
]);
118+
clearTimeout(timeoutId);
117119
if (presentationOrTimeout === undefined) {
118120
if (record !== undefined) {
119121
this.removePromptRecordForEvent(eventId, record);

src/protections/BanPropagation.tsx

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
/**
2+
* Copyright (C) 2022-2023 Gnuxie <[email protected]>
3+
* All rights reserved.
4+
*
5+
* This file is modified and is NOT licensed under the Apache License.
6+
* This modified file incorperates work from mjolnir
7+
* https://github.com/matrix-org/mjolnir
8+
* which included the following license notice:
9+
10+
Copyright 2019-2022 The Matrix.org Foundation C.I.C.
11+
12+
Licensed under the Apache License, Version 2.0 (the "License");
13+
you may not use this file except in compliance with the License.
14+
You may obtain a copy of the License at
15+
16+
http://www.apache.org/licenses/LICENSE-2.0
17+
18+
Unless required by applicable law or agreed to in writing, software
19+
distributed under the License is distributed on an "AS IS" BASIS,
20+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
21+
See the License for the specific language governing permissions and
22+
limitations under the License.
23+
*
24+
* However, this file is modified and the modifications in this file
25+
* are NOT distributed, contributed, committed, or licensed under the Apache License.
26+
*/
27+
28+
import { Protection } from "./IProtection";
29+
import { Mjolnir } from "../Mjolnir";
30+
import { LogService } from "matrix-bot-sdk";
31+
import { PromptResponseListener } from "../commands/interface-manager/MatrixPromptUX";
32+
import { JSXFactory } from "../commands/interface-manager/JSXFactory";
33+
import { renderMatrixAndSend } from "../commands/interface-manager/DeadDocumentMatrix";
34+
import { renderMentionPill } from "../commands/interface-manager/MatrixHelpRenderer";
35+
import { RULE_USER } from "../models/ListRule";
36+
37+
/**
38+
* Prompt the management room to propagate a user ban to a policy list of their choice.
39+
* @param mjolnir Mjolnir.
40+
* @param event The ban event.
41+
* @param roomId The room that the ban happened in.
42+
* @returns An event id which can be used by the `PromptResponseListener`.
43+
*/
44+
async function promptBanPropagation(
45+
mjolnir: Mjolnir,
46+
event: any,
47+
roomId: string
48+
): Promise</*event id*/string> {
49+
return (await renderMatrixAndSend(
50+
<root>The user {renderMentionPill(event["state_key"], event["content"]?.["displayname"] ?? event["state_key"])} was banned
51+
in <a href={`https://matrix.to/#/${roomId}`}>{roomId}</a> for <code>{event["content"]?.["reason"] ?? '<no reason supplied>'}</code>.<br/>
52+
Would you like to add the ban to a policy list?
53+
<ol>
54+
{mjolnir.policyListManager.lists.map(list => <li>{list}</li>)}
55+
</ol>
56+
</root>,
57+
mjolnir.managementRoomId,
58+
undefined,
59+
mjolnir.client
60+
)).at(0) as string;
61+
}
62+
63+
export class BanPropagation extends Protection {
64+
65+
settings = {};
66+
67+
public get name(): string {
68+
return 'BanPropagationProtection';
69+
}
70+
public get description(): string {
71+
return "When you ban a user in any protected room with a client, this protection\
72+
will turn the room level ban into a policy for a policy list of your choice.\
73+
This will then allow the bot to ban the user from all of your rooms.";
74+
}
75+
76+
public async handleEvent(mjolnir: Mjolnir, roomId: string, event: any): Promise<any> {
77+
if (event['type'] !== 'm.room.member' || event['content']?.['membership'] !== 'ban') {
78+
return;
79+
}
80+
if (mjolnir.policyListManager.lists.map(
81+
list => list.rulesMatchingEntity(event['state_key'], RULE_USER)
82+
).some(rules => rules.length > 0)
83+
) {
84+
return; // The user is already banned.
85+
}
86+
const promptListener = new PromptResponseListener(
87+
mjolnir.matrixEmitter,
88+
await mjolnir.client.getUserId(),
89+
mjolnir.client
90+
);
91+
// do not await, we don't want to block the protection manager
92+
promptListener.waitForPresentationList(
93+
mjolnir.policyListManager.lists,
94+
mjolnir.managementRoomId,
95+
promptBanPropagation(mjolnir, event, roomId)
96+
).then(listResult => {
97+
if (listResult.isOk()) {
98+
const list = listResult.ok;
99+
return list.banEntity(RULE_USER, event['state_key'], event['content']?.["reason"]);
100+
} else {
101+
LogService.warn("BanPropagation", "Timed out waiting for a response to a room level ban", listResult.err);
102+
return;
103+
}
104+
}).catch(e => {
105+
LogService.error('BanPropagation', "Encountered an error while prompting the user for instructions to propagate a room level ban", e);
106+
});
107+
}
108+
}

src/protections/ProtectionManager.ts

Lines changed: 86 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -41,9 +41,12 @@ import { Consequence } from "./consequence";
4141
import { htmlEscape } from "../utils";
4242
import { ERROR_KIND_FATAL, ERROR_KIND_PERMISSION } from "../ErrorCache";
4343
import { RoomUpdateError } from "../models/RoomUpdateError";
44+
import { BanPropagation } from "./BanPropagation";
45+
import { MatrixDataManager, RawSchemedData, SchemaMigration, SCHEMA_VERSION_KEY } from "../models/MatrixDataManager";
4446

4547
const PROTECTIONS: Protection[] = [
4648
new FirstMessageIsImage(),
49+
new BanPropagation(),
4750
new BasicFlooding(),
4851
new WordList(),
4952
new MessageIsVoice(),
@@ -54,26 +57,105 @@ const PROTECTIONS: Protection[] = [
5457
];
5558

5659
const ENABLED_PROTECTIONS_EVENT_TYPE = "org.matrix.mjolnir.enabled_protections";
60+
type EnabledProtectionsEvent = RawSchemedData & {
61+
enabled: string[],
62+
}
63+
64+
class EnabledProtectionsManager extends MatrixDataManager<EnabledProtectionsEvent> {
65+
protected readonly schema: SchemaMigration[] = [
66+
async function enableBanPropagationByDefault(input: EnabledProtectionsEvent) {
67+
const enabled = new Set(input.enabled);
68+
const banPropagationProtection = PROTECTIONS.find(p => p.name === 'BanPropagationProtection');
69+
if (banPropagationProtection === undefined) {
70+
throw new TypeError("Couldn't find the ban propagation protection");
71+
}
72+
enabled.add(banPropagationProtection.name)
73+
return {
74+
enabled: [...enabled],
75+
[SCHEMA_VERSION_KEY]: 1,
76+
}
77+
}
78+
];
79+
protected readonly isAllowedToInferNoVersionAsZero = true;
80+
private readonly enabledProtections = new Set</* protection name */string>();
81+
82+
constructor(
83+
private readonly mjolnir: Mjolnir
84+
) {
85+
super()
86+
}
87+
88+
protected async requestMatrixData(): Promise<unknown> {
89+
try {
90+
return await this.mjolnir.client.getAccountData(ENABLED_PROTECTIONS_EVENT_TYPE);
91+
} catch (e) {
92+
if (e.statusCode === 404) {
93+
LogService.warn('PolicyListManager', "Couldn't find account data for Draupnir's protections, assuming first start.", e);
94+
return this.createFirstData();
95+
} else {
96+
throw e;
97+
}
98+
}
99+
}
100+
101+
protected async storeMatixData(): Promise<void> {
102+
const data: EnabledProtectionsEvent = {
103+
enabled: [...this.enabledProtections],
104+
[SCHEMA_VERSION_KEY]: 1,
105+
}
106+
await this.mjolnir.client.setAccountData(ENABLED_PROTECTIONS_EVENT_TYPE, data);
107+
}
108+
109+
protected async createFirstData(): Promise<EnabledProtectionsEvent> {
110+
return { enabled: [], [SCHEMA_VERSION_KEY]: 0 };
111+
}
112+
113+
public isEnabled(protection: Protection): boolean {
114+
return this.enabledProtections.has(protection.name);
115+
}
116+
117+
public async enable(protection: Protection): Promise<void> {
118+
this.enabledProtections.add(protection.name);
119+
protection.enabled = true;
120+
await this.storeMatixData();
121+
}
122+
123+
public async disable(protection: Protection): Promise<void> {
124+
this.enabledProtections.delete(protection.name);
125+
protection.enabled = false;
126+
await this.storeMatixData();
127+
}
128+
129+
public async start(): Promise<void> {
130+
const data = await this.loadData();
131+
for (const protection of data.enabled) {
132+
this.enabledProtections.add(protection);
133+
}
134+
}
135+
}
136+
57137
const CONSEQUENCE_EVENT_DATA = "org.matrix.mjolnir.consequence";
58138

59139
/**
60140
* This is responsible for informing protections about relevant events and handle standard consequences.
61141
*/
62142
export class ProtectionManager {
143+
private enabledProtectionsManager: EnabledProtectionsManager;
63144
private _protections = new Map<string /* protection name */, Protection>();
64145
get protections(): Readonly<Map<string /* protection name */, Protection>> {
65146
return this._protections;
66147
}
67148

68-
69149
constructor(private readonly mjolnir: Mjolnir) {
150+
this.enabledProtectionsManager = new EnabledProtectionsManager(this.mjolnir);
70151
}
71152

72153
/*
73154
* Take all the builtin protections, register them to set their enabled (or not) state and
74155
* update their settings with any saved non-default values
75156
*/
76157
public async start() {
158+
await this.enabledProtectionsManager.start();
77159
this.mjolnir.reportManager.on("report.new", this.handleReport.bind(this));
78160
this.mjolnir.matrixEmitter.on("room.event", this.handleEvent.bind(this));
79161
for (const protection of PROTECTIONS) {
@@ -97,15 +179,7 @@ export class ProtectionManager {
97179
*/
98180
public async registerProtection(protection: Protection) {
99181
this._protections.set(protection.name, protection)
100-
101-
let enabledProtections: { enabled: string[] } | null = null;
102-
try {
103-
enabledProtections = await this.mjolnir.client.getAccountData(ENABLED_PROTECTIONS_EVENT_TYPE);
104-
} catch {
105-
// this setting either doesn't exist, or we failed to read it (bad network?)
106-
// TODO: retry on certain failures?
107-
}
108-
protection.enabled = enabledProtections?.enabled.includes(protection.name) ?? false;
182+
protection.enabled = this.enabledProtectionsManager.isEnabled(protection) ?? false;
109183

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

163-
/*
164-
* Make a list of the names of enabled protections and save them in a state event
165-
*/
166-
private async saveEnabledProtections() {
167-
const protections = this.enabledProtections.map(p => p.name);
168-
await this.mjolnir.client.setAccountData(ENABLED_PROTECTIONS_EVENT_TYPE, { enabled: protections });
169-
}
170237
/*
171238
* Enable a protection by name and persist its enable state in to a state event
172239
*
@@ -175,8 +242,7 @@ export class ProtectionManager {
175242
public async enableProtection(name: string) {
176243
const protection = this._protections.get(name);
177244
if (protection !== undefined) {
178-
protection.enabled = true;
179-
await this.saveEnabledProtections();
245+
await this.enabledProtectionsManager.enable(protection);
180246
}
181247
}
182248

@@ -203,8 +269,7 @@ export class ProtectionManager {
203269
public async disableProtection(name: string) {
204270
const protection = this._protections.get(name);
205271
if (protection !== undefined) {
206-
protection.enabled = false;
207-
await this.saveEnabledProtections();
272+
await this.enabledProtectionsManager.disable(protection);
208273
}
209274
}
210275

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
import expect from "expect";
2+
import { Mjolnir } from "../../src/Mjolnir";
3+
import { newTestUser } from "./clientHelper";
4+
import { getFirstEventMatching } from './commands/commandUtils';
5+
import { Permalinks } from "matrix-bot-sdk";
6+
import { RULE_USER } from "../../src/models/ListRule";
7+
8+
// We will need to disable this in tests that are banning people otherwise it will cause
9+
// mocha to hang for awhile until it times out waiting for a response to a prompt.
10+
describe("Ban propagation test", function() {
11+
it("Should be enabled by default", async function() {
12+
const mjolnir: Mjolnir = this.mjolnir
13+
expect(mjolnir.protectionManager.getProtection("BanPropagationProtection")?.enabled).toBeTruthy();
14+
})
15+
it("Should prompt to add bans to a policy list, then add the ban", async function() {
16+
const mjolnir: Mjolnir = this.mjolnir
17+
const mjolnirId = await mjolnir.client.getUserId();
18+
const moderator = await newTestUser(this.config.homeserverUrl, { name: { contains: "moderator" } });
19+
await moderator.joinRoom(mjolnir.managementRoomId);
20+
const protectedRooms = await Promise.all([...Array(5)].map(async _ => {
21+
const room = await moderator.createRoom({ invite: [mjolnirId] });
22+
await mjolnir.client.joinRoom(room);
23+
await moderator.setUserPowerLevel(mjolnirId, room, 100);
24+
await mjolnir.addProtectedRoom(room);
25+
return room;
26+
}));
27+
// create a policy list so that we can check it for a user rule later
28+
const policyListId = await moderator.createRoom({ invite: [mjolnirId] });
29+
await moderator.setUserPowerLevel(mjolnirId, policyListId, 100);
30+
await mjolnir.client.joinRoom(policyListId);
31+
await mjolnir.policyListManager.watchList(Permalinks.forRoom(policyListId));
32+
33+
// check for the prompt
34+
const promptEvent = await getFirstEventMatching({
35+
matrix: mjolnir.matrixEmitter,
36+
targetRoom: mjolnir.managementRoomId,
37+
lookAfterEvent: async function () {
38+
// ban a user in one of our protected rooms using the moderator
39+
await moderator.banUser('@test:example.com', protectedRooms[0], "spam");
40+
return undefined;
41+
},
42+
predicate: function (event: any): boolean {
43+
return (event['content']?.['body'] ?? '').startsWith('The user')
44+
}
45+
})
46+
// select the prompt
47+
await moderator.unstableApis.addReactionToEvent(
48+
mjolnir.managementRoomId, promptEvent['event_id'], '1.'
49+
);
50+
// check the policy list, after waiting a few seconds.
51+
await new Promise(resolve => setTimeout(resolve, 10000));
52+
const policyList = mjolnir.policyListManager.lists[0];
53+
const rules = policyList.rulesMatchingEntity('@test:example.com', RULE_USER);
54+
expect(rules.length).toBe(1);
55+
expect(rules[0].entity).toBe('@test:example.com');
56+
expect(rules[0].reason).toBe('spam');
57+
})
58+
})

0 commit comments

Comments
 (0)