mirror of
https://github.com/matrix-org/mjolnir.git
synced 2024-10-01 01:36:06 -04:00
Attempt to factor out protected rooms from Mjolnir.
This is useful to the appservice because it means we don't have to wrap a Mjolnir that is designed to sync. It's also useful if we later on want to have specific settings per space. It's also just a nice seperation between Mjolnir's needs while syncing via client-server and the behaviour of syncing policy rooms. ### Things that have changed - `ErrorCache` no longer a static class (phew), gets used by `ProtectedRooms`. - `ManagementRoomOutput` class gets created to handle logging back to the management room. - Responsibilities for syncing member bans and server ACL are handled by `ProtectedRooms`. - Responsibilities for watched lists should be moved to `ProtectedRooms` if they haven't been. - `EventRedactionQueue` is moved to `ProtectedRooms` since this needs to happen after member bans. - ApplyServerAcls moved to `ProtectedRooms` - ApplyMemberBans move to `ProtectedRooms` - `logMessage` and `replaceRoomIdsWithPills` moved to `ManagementRoomOutput`. - `resyncJoinedRooms` has been made a little more clear, though I am concerned about how often it does run because it does seem expensive. ### Things to change just plugging in all the gaps until it "works". Maybe figuring out the dependency between "verifyPermissions" and protections. I didn't want to include protections themselves into `ProtectedRooms` since they have a lot of machinery. Maybe if they are split into their own class that could be delegated to via `ProtectedRooms` it could work. I think that is it
This commit is contained in:
parent
8bafa16495
commit
927f2bd70f
@ -23,24 +23,24 @@ const TRIGGER_INTERVALS: { [key: string]: number } = {
|
||||
};
|
||||
|
||||
export default class ErrorCache {
|
||||
private static roomsToErrors: { [roomId: string]: { [kind: string]: number } } = {};
|
||||
private roomsToErrors: { [roomId: string]: { [kind: string]: number } } = {};
|
||||
|
||||
private constructor() {
|
||||
constructor() {
|
||||
}
|
||||
|
||||
public static resetError(roomId: string, kind: string) {
|
||||
if (!ErrorCache.roomsToErrors[roomId]) {
|
||||
ErrorCache.roomsToErrors[roomId] = {};
|
||||
public resetError(roomId: string, kind: string) {
|
||||
if (!this.roomsToErrors[roomId]) {
|
||||
this.roomsToErrors[roomId] = {};
|
||||
}
|
||||
ErrorCache.roomsToErrors[roomId][kind] = 0;
|
||||
this.roomsToErrors[roomId][kind] = 0;
|
||||
}
|
||||
|
||||
public static triggerError(roomId: string, kind: string): boolean {
|
||||
if (!ErrorCache.roomsToErrors[roomId]) {
|
||||
ErrorCache.roomsToErrors[roomId] = {};
|
||||
public triggerError(roomId: string, kind: string): boolean {
|
||||
if (!this.roomsToErrors[roomId]) {
|
||||
this.roomsToErrors[roomId] = {};
|
||||
}
|
||||
|
||||
const triggers = ErrorCache.roomsToErrors[roomId];
|
||||
const triggers = this.roomsToErrors[roomId];
|
||||
if (!triggers[kind]) {
|
||||
triggers[kind] = 0;
|
||||
}
|
||||
|
115
src/ManagementRoom.ts
Normal file
115
src/ManagementRoom.ts
Normal file
@ -0,0 +1,115 @@
|
||||
/*
|
||||
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.
|
||||
*/
|
||||
|
||||
import { extractRequestError, LogLevel, LogService, MatrixClient, MessageType, Permalinks, TextualMessageEventContent, UserID } from "matrix-bot-sdk";
|
||||
import { IConfig } from "./config";
|
||||
import ErrorCache from "./ErrorCache";
|
||||
import { RoomUpdateError } from "./models/RoomUpdateError";
|
||||
import { htmlEscape } from "./utils";
|
||||
|
||||
const levelToFn = {
|
||||
[LogLevel.DEBUG.toString()]: LogService.debug,
|
||||
[LogLevel.INFO.toString()]: LogService.info,
|
||||
[LogLevel.WARN.toString()]: LogService.warn,
|
||||
[LogLevel.ERROR.toString()]: LogService.error,
|
||||
};
|
||||
|
||||
export default class ManagementRoomOutput {
|
||||
|
||||
|
||||
constructor(
|
||||
private readonly managementRoomId: string,
|
||||
private readonly client: MatrixClient,
|
||||
private readonly config: IConfig,
|
||||
) {
|
||||
|
||||
}
|
||||
|
||||
/*
|
||||
* Take an arbitrary string and a set of room IDs, and return a
|
||||
* TextualMessageEventContent whose plaintext component replaces those room
|
||||
* IDs with their canonical aliases, and whose html component replaces those
|
||||
* room IDs with their matrix.to room pills.
|
||||
*
|
||||
* @param client The matrix client on which to query for room aliases
|
||||
* @param text An arbitrary string to rewrite with room aliases and pills
|
||||
* @param roomIds A set of room IDs to find and replace in `text`
|
||||
* @param msgtype The desired message type of the returned TextualMessageEventContent
|
||||
* @returns A TextualMessageEventContent with replaced room IDs
|
||||
*/
|
||||
private async replaceRoomIdsWithPills(text: string, roomIds: Set<string>, msgtype: MessageType = "m.text"): Promise<TextualMessageEventContent> {
|
||||
const content: TextualMessageEventContent = {
|
||||
body: text,
|
||||
formatted_body: htmlEscape(text),
|
||||
msgtype: msgtype,
|
||||
format: "org.matrix.custom.html",
|
||||
};
|
||||
|
||||
const escapeRegex = (v: string): string => {
|
||||
return v.replace(/[-\/\\^$*+?.()|[\]{}]/g, '\\$&');
|
||||
};
|
||||
|
||||
const viaServers = [(new UserID(await this.client.getUserId())).domain];
|
||||
for (const roomId of roomIds) {
|
||||
let alias = roomId;
|
||||
try {
|
||||
alias = (await this.client.getPublishedAlias(roomId)) || roomId;
|
||||
} catch (e) {
|
||||
// This is a recursive call, so tell the function not to try and call us
|
||||
await this.logMessage(LogLevel.WARN, "utils", `Failed to resolve room alias for ${roomId} - see console for details`, null, true);
|
||||
LogService.warn("utils", extractRequestError(e));
|
||||
}
|
||||
const regexRoomId = new RegExp(escapeRegex(roomId), "g");
|
||||
content.body = content.body.replace(regexRoomId, alias);
|
||||
if (content.formatted_body) {
|
||||
const permalink = Permalinks.forRoom(alias, alias !== roomId ? [] : viaServers);
|
||||
content.formatted_body = content.formatted_body.replace(regexRoomId, `<a href="${permalink}">${alias}</a>`);
|
||||
}
|
||||
}
|
||||
|
||||
return content;
|
||||
}
|
||||
|
||||
public async logMessage(level: LogLevel, module: string, message: string | any, additionalRoomIds: string[] | string | null = null, isRecursive = false): Promise<any> {
|
||||
if (!additionalRoomIds) additionalRoomIds = [];
|
||||
if (!Array.isArray(additionalRoomIds)) additionalRoomIds = [additionalRoomIds];
|
||||
|
||||
if (this.config.verboseLogging || LogLevel.INFO.includes(level)) {
|
||||
let clientMessage = message;
|
||||
if (level === LogLevel.WARN) clientMessage = `⚠ | ${message}`;
|
||||
if (level === LogLevel.ERROR) clientMessage = `‼ | ${message}`;
|
||||
|
||||
const client = this.client;
|
||||
const roomIds = [this.managementRoomId, ...additionalRoomIds];
|
||||
|
||||
let evContent: TextualMessageEventContent = {
|
||||
body: message,
|
||||
formatted_body: htmlEscape(message),
|
||||
msgtype: "m.notice",
|
||||
format: "org.matrix.custom.html",
|
||||
};
|
||||
if (!isRecursive) {
|
||||
evContent = await this.replaceRoomIdsWithPills(this, clientMessage, new Set(roomIds), "m.notice");
|
||||
}
|
||||
|
||||
await client.sendMessage(this.managementRoomId, evContent);
|
||||
}
|
||||
|
||||
levelToFn[level.toString()](module, message);
|
||||
}
|
||||
|
||||
|
||||
}
|
406
src/Mjolnir.ts
406
src/Mjolnir.ts
@ -43,20 +43,14 @@ import { htmlEscape } from "./utils";
|
||||
import { ReportManager } from "./report/ReportManager";
|
||||
import { ReportPoller } from "./report/ReportPoller";
|
||||
import { WebAPIs } from "./webapis/WebAPIs";
|
||||
import { replaceRoomIdsWithPills } from "./utils";
|
||||
import RuleServer from "./models/RuleServer";
|
||||
import { RoomMemberManager } from "./RoomMembers";
|
||||
import { ProtectedRoomActivityTracker } from "./queues/ProtectedRoomActivityTracker";
|
||||
import { ThrottlingQueue } from "./queues/ThrottlingQueue";
|
||||
import { IConfig } from "./config";
|
||||
import PolicyList, { ListRuleChange } from "./models/PolicyList";
|
||||
|
||||
const levelToFn = {
|
||||
[LogLevel.DEBUG.toString()]: LogService.debug,
|
||||
[LogLevel.INFO.toString()]: LogService.info,
|
||||
[LogLevel.WARN.toString()]: LogService.warn,
|
||||
[LogLevel.ERROR.toString()]: LogService.error,
|
||||
};
|
||||
import { ProtectedRooms } from "./ProtectedRooms";
|
||||
import ManagementRoomOutput from "./ManagementRoom";
|
||||
|
||||
export const STATE_NOT_STARTED = "not_started";
|
||||
export const STATE_CHECKING_PERMISSIONS = "checking_permissions";
|
||||
@ -85,33 +79,19 @@ export class Mjolnir {
|
||||
* but have been flagged by the automatic spam detection as suispicous
|
||||
*/
|
||||
private unlistedUserRedactionQueue = new UnlistedUserRedactionQueue();
|
||||
/**
|
||||
* This is a queue for redactions to process after mjolnir
|
||||
* has finished applying ACL and bans when syncing.
|
||||
*/
|
||||
private eventRedactionQueue = new EventRedactionQueue();
|
||||
private automaticRedactionReasons: MatrixGlob[] = [];
|
||||
/**
|
||||
* Every room that we are joined to except the management room. Used to implement `config.protectAllJoinedRooms`.
|
||||
*/
|
||||
private protectedJoinedRoomIds: string[] = [];
|
||||
private protectedRoomsTracker: ProtectedRooms;
|
||||
/**
|
||||
* These are rooms that were explicitly said to be protected either in the config, or by what is present in the account data for `org.matrix.mjolnir.protected_rooms`.
|
||||
*/
|
||||
private explicitlyProtectedRoomIds: string[] = [];
|
||||
private unprotectedWatchedListRooms: string[] = [];
|
||||
private webapis: WebAPIs;
|
||||
private protectedRoomActivityTracker: ProtectedRoomActivityTracker;
|
||||
public taskQueue: ThrottlingQueue;
|
||||
/**
|
||||
* Used to provide mutual exclusion when synchronizing rooms with the state of a policy list.
|
||||
* This is because requests operating with rules from an older version of the list that are slow
|
||||
* could race & give the room an inconsistent state. An example is if we add multiple m.policy.rule.server rules,
|
||||
* which would cause several requests to a room to send a new m.room.server_acl event.
|
||||
* These requests could finish in any order, which has left rooms with an inconsistent server_acl event
|
||||
* until Mjolnir synchronises the room with its policy lists again, which can be in the region of hours.
|
||||
*/
|
||||
public aclChain: Promise<void> = Promise.resolve();
|
||||
private managementRoom: ManagementRoomOutput;
|
||||
/*
|
||||
* Config-enabled polling of reports in Synapse, so Mjolnir can react to reports
|
||||
*/
|
||||
@ -278,9 +258,6 @@ export class Mjolnir {
|
||||
}
|
||||
});
|
||||
|
||||
// Setup room activity watcher
|
||||
this.protectedRoomActivityTracker = new ProtectedRoomActivityTracker(client);
|
||||
|
||||
// Setup Web APIs
|
||||
console.log("Creating Web APIs");
|
||||
const reportManager = new ReportManager(this);
|
||||
@ -315,10 +292,6 @@ export class Mjolnir {
|
||||
return this.unlistedUserRedactionQueue;
|
||||
}
|
||||
|
||||
public get automaticRedactGlobs(): MatrixGlob[] {
|
||||
return this.automaticRedactionReasons;
|
||||
}
|
||||
|
||||
/**
|
||||
* Start Mjölnir.
|
||||
*/
|
||||
@ -339,7 +312,7 @@ export class Mjolnir {
|
||||
if (err.body?.errcode !== "M_NOT_FOUND") {
|
||||
throw err;
|
||||
} else {
|
||||
this.logMessage(LogLevel.INFO, "Mjolnir@startup", "report poll setting does not exist yet");
|
||||
this.managementRoom.logMessage(LogLevel.INFO, "Mjolnir@startup", "report poll setting does not exist yet");
|
||||
}
|
||||
}
|
||||
this.reportPoller.start(reportPollSetting.from);
|
||||
@ -348,7 +321,7 @@ export class Mjolnir {
|
||||
// Load the state.
|
||||
this.currentState = STATE_CHECKING_PERMISSIONS;
|
||||
|
||||
await this.logMessage(LogLevel.DEBUG, "Mjolnir@startup", "Loading protected rooms...");
|
||||
await this.managementRoom.logMessage(LogLevel.DEBUG, "Mjolnir@startup", "Loading protected rooms...");
|
||||
await this.resyncJoinedRooms(false);
|
||||
try {
|
||||
const data: { rooms?: string[] } | null = await this.client.getAccountData(PROTECTED_ROOMS_EVENT_TYPE);
|
||||
@ -366,25 +339,25 @@ export class Mjolnir {
|
||||
this.applyUnprotectedRooms();
|
||||
|
||||
if (this.config.verifyPermissionsOnStartup) {
|
||||
await this.logMessage(LogLevel.INFO, "Mjolnir@startup", "Checking permissions...");
|
||||
await this.managementRoom.logMessage(LogLevel.INFO, "Mjolnir@startup", "Checking permissions...");
|
||||
await this.verifyPermissions(this.config.verboseLogging);
|
||||
}
|
||||
|
||||
this.currentState = STATE_SYNCING;
|
||||
if (this.config.syncOnStartup) {
|
||||
await this.logMessage(LogLevel.INFO, "Mjolnir@startup", "Syncing lists...");
|
||||
await this.managementRoom.logMessage(LogLevel.INFO, "Mjolnir@startup", "Syncing lists...");
|
||||
await this.syncLists(this.config.verboseLogging);
|
||||
await this.registerProtections();
|
||||
}
|
||||
|
||||
this.currentState = STATE_RUNNING;
|
||||
await this.logMessage(LogLevel.INFO, "Mjolnir@startup", "Startup complete. Now monitoring rooms.");
|
||||
await this.managementRoom.logMessage(LogLevel.INFO, "Mjolnir@startup", "Startup complete. Now monitoring rooms.");
|
||||
} catch (err) {
|
||||
try {
|
||||
LogService.error("Mjolnir", "Error during startup:");
|
||||
LogService.error("Mjolnir", extractRequestError(err));
|
||||
this.stop();
|
||||
await this.logMessage(LogLevel.ERROR, "Mjolnir@startup", "Startup failed due to error - see console");
|
||||
await this.managementRoom.logMessage(LogLevel.ERROR, "Mjolnir@startup", "Startup failed due to error - see console");
|
||||
throw err;
|
||||
} catch (e) {
|
||||
LogService.error("Mjolnir", `Failed to report startup error to the management room: ${e}`);
|
||||
@ -403,39 +376,9 @@ export class Mjolnir {
|
||||
this.reportPoller?.stop();
|
||||
}
|
||||
|
||||
public async logMessage(level: LogLevel, module: string, message: string | any, additionalRoomIds: string[] | string | null = null, isRecursive = false): Promise<any> {
|
||||
if (!additionalRoomIds) additionalRoomIds = [];
|
||||
if (!Array.isArray(additionalRoomIds)) additionalRoomIds = [additionalRoomIds];
|
||||
|
||||
if (this.config.verboseLogging || LogLevel.INFO.includes(level)) {
|
||||
let clientMessage = message;
|
||||
if (level === LogLevel.WARN) clientMessage = `⚠ | ${message}`;
|
||||
if (level === LogLevel.ERROR) clientMessage = `‼ | ${message}`;
|
||||
|
||||
const client = this.client;
|
||||
const roomIds = [this.managementRoomId, ...additionalRoomIds];
|
||||
|
||||
let evContent: TextualMessageEventContent = {
|
||||
body: message,
|
||||
formatted_body: htmlEscape(message),
|
||||
msgtype: "m.notice",
|
||||
format: "org.matrix.custom.html",
|
||||
};
|
||||
if (!isRecursive) {
|
||||
evContent = await replaceRoomIdsWithPills(this, clientMessage, new Set(roomIds), "m.notice");
|
||||
}
|
||||
|
||||
await client.sendMessage(this.managementRoomId, evContent);
|
||||
}
|
||||
|
||||
levelToFn[level.toString()](module, message);
|
||||
}
|
||||
|
||||
|
||||
public async addProtectedRoom(roomId: string) {
|
||||
this.protectedRooms[roomId] = Permalinks.forRoom(roomId);
|
||||
this.roomJoins.addRoom(roomId);
|
||||
this.protectedRoomActivityTracker.addProtectedRoom(roomId);
|
||||
|
||||
const unprotectedIdx = this.unprotectedWatchedListRooms.indexOf(roomId);
|
||||
if (unprotectedIdx >= 0) this.unprotectedWatchedListRooms.splice(unprotectedIdx, 1);
|
||||
@ -450,13 +393,11 @@ export class Mjolnir {
|
||||
const rooms = (additionalProtectedRooms?.rooms ?? []);
|
||||
rooms.push(roomId);
|
||||
await this.client.setAccountData(PROTECTED_ROOMS_EVENT_TYPE, { rooms: rooms });
|
||||
await this.syncLists(this.config.verboseLogging);
|
||||
}
|
||||
|
||||
public async removeProtectedRoom(roomId: string) {
|
||||
delete this.protectedRooms[roomId];
|
||||
this.roomJoins.removeRoom(roomId);
|
||||
this.protectedRoomActivityTracker.removeProtectedRoom(roomId);
|
||||
|
||||
const idx = this.explicitlyProtectedRoomIds.indexOf(roomId);
|
||||
if (idx >= 0) this.explicitlyProtectedRoomIds.splice(idx, 1);
|
||||
@ -471,27 +412,33 @@ export class Mjolnir {
|
||||
await this.client.setAccountData(PROTECTED_ROOMS_EVENT_TYPE, additionalProtectedRooms);
|
||||
}
|
||||
|
||||
// need to brewritten to add/remove from a ProtectedRooms instance.
|
||||
private async resyncJoinedRooms(withSync = true) {
|
||||
// this is really terrible!
|
||||
// what the fuck does it do???
|
||||
// just fix it bloody hell mate.
|
||||
if (!this.config.protectAllJoinedRooms) return;
|
||||
|
||||
const joinedRoomIds = (await this.client.getJoinedRooms()).filter(r => r !== this.managementRoomId);
|
||||
const joinedRoomIds = (await this.client.getJoinedRooms())
|
||||
.filter(r => r !== this.managementRoomId && !this.unprotectedWatchedListRooms.includes(r));
|
||||
const oldRoomIdsSet = new Set(this.protectedJoinedRoomIds);
|
||||
const joinedRoomIdsSet = new Set(joinedRoomIds);
|
||||
// Remove every room id that we have joined from `this.protectedRooms`.
|
||||
for (const roomId of this.protectedJoinedRoomIds) {
|
||||
delete this.protectedRooms[roomId];
|
||||
this.protectedRoomActivityTracker.removeProtectedRoom(roomId);
|
||||
// find every room that we have left (since last time)
|
||||
for (const roomId of oldRoomIdsSet.keys()) {
|
||||
if (!joinedRoomIdsSet.has(roomId)) {
|
||||
// Then we have left this room.
|
||||
delete this.protectedRooms[roomId];
|
||||
this.protectedRoomsTracker.removeProtectedRoom(roomId);
|
||||
this.roomJoins.removeRoom(roomId);
|
||||
}
|
||||
}
|
||||
this.protectedJoinedRoomIds = joinedRoomIds;
|
||||
// Add all joined rooms back to the permalink object
|
||||
for (const roomId of joinedRoomIds) {
|
||||
this.protectedRooms[roomId] = Permalinks.forRoom(roomId);
|
||||
this.protectedRoomActivityTracker.addProtectedRoom(roomId);
|
||||
// find every room that we have joined (since last time).
|
||||
for (const roomId of joinedRoomIdsSet.keys()) {
|
||||
if (!oldRoomIdsSet.has(roomId)) {
|
||||
// Then we have joined this room
|
||||
this.roomJoins.addRoom(roomId);
|
||||
this.protectedRooms[roomId] = Permalinks.forRoom(roomId);
|
||||
await this.protectedRoomsTracker.addProtectedRoom(roomId);
|
||||
}
|
||||
}
|
||||
|
||||
@ -580,7 +527,7 @@ export class Mjolnir {
|
||||
) {
|
||||
validatedSettings[key] = value;
|
||||
} else {
|
||||
await this.logMessage(
|
||||
await this.managementRoom.logMessage(
|
||||
LogLevel.WARN,
|
||||
"getProtectionSetting",
|
||||
`Tried to read ${protectionName}.${key} and got invalid value ${value}`
|
||||
@ -741,7 +688,7 @@ export class Mjolnir {
|
||||
// Ignore - probably haven't warned about it yet
|
||||
}
|
||||
|
||||
await this.logMessage(LogLevel.WARN, "Mjolnir", `Not protecting ${roomId} - it is a ban list that this bot did not create. Add the room as protected if it is supposed to be protected. This warning will not appear again.`, roomId);
|
||||
await this.managementRoom.logMessage(LogLevel.WARN, "Mjolnir", `Not protecting ${roomId} - it is a ban list that this bot did not create. Add the room as protected if it is supposed to be protected. This warning will not appear again.`, roomId);
|
||||
await this.client.setAccountData(WARN_UNPROTECTED_ROOM_EVENT_PREFIX + roomId, { warned: true });
|
||||
}
|
||||
|
||||
@ -777,188 +724,12 @@ export class Mjolnir {
|
||||
}
|
||||
}
|
||||
|
||||
public async verifyPermissions(verbose = true, printRegardless = false) {
|
||||
const errors: RoomUpdateError[] = [];
|
||||
for (const roomId of Object.keys(this.protectedRooms)) {
|
||||
errors.push(...(await this.verifyPermissionsIn(roomId)));
|
||||
}
|
||||
|
||||
const hadErrors = await this.printActionResult(errors, "Permission errors in protected rooms:", printRegardless);
|
||||
if (!hadErrors && verbose) {
|
||||
const html = `<font color="#00cc00">All permissions look OK.</font>`;
|
||||
const text = "All permissions look OK.";
|
||||
await this.client.sendMessage(this.managementRoomId, {
|
||||
msgtype: "m.notice",
|
||||
body: text,
|
||||
format: "org.matrix.custom.html",
|
||||
formatted_body: html,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private async verifyPermissionsIn(roomId: string): Promise<RoomUpdateError[]> {
|
||||
const errors: RoomUpdateError[] = [];
|
||||
const additionalPermissions = this.requiredProtectionPermissions();
|
||||
|
||||
try {
|
||||
const ownUserId = await this.client.getUserId();
|
||||
|
||||
const powerLevels = await this.client.getRoomStateEvent(roomId, "m.room.power_levels", "");
|
||||
if (!powerLevels) {
|
||||
// noinspection ExceptionCaughtLocallyJS
|
||||
throw new Error("Missing power levels state event");
|
||||
}
|
||||
|
||||
function plDefault(val: number | undefined | null, def: number): number {
|
||||
if (!val && val !== 0) return def;
|
||||
return val;
|
||||
}
|
||||
|
||||
const users = powerLevels['users'] || {};
|
||||
const events = powerLevels['events'] || {};
|
||||
const usersDefault = plDefault(powerLevels['users_default'], 0);
|
||||
const stateDefault = plDefault(powerLevels['state_default'], 50);
|
||||
const ban = plDefault(powerLevels['ban'], 50);
|
||||
const kick = plDefault(powerLevels['kick'], 50);
|
||||
const redact = plDefault(powerLevels['redact'], 50);
|
||||
|
||||
const userLevel = plDefault(users[ownUserId], usersDefault);
|
||||
const aclLevel = plDefault(events["m.room.server_acl"], stateDefault);
|
||||
|
||||
// Wants: ban, kick, redact, m.room.server_acl
|
||||
|
||||
if (userLevel < ban) {
|
||||
errors.push({
|
||||
roomId,
|
||||
errorMessage: `Missing power level for bans: ${userLevel} < ${ban}`,
|
||||
errorKind: ERROR_KIND_PERMISSION,
|
||||
});
|
||||
}
|
||||
if (userLevel < kick) {
|
||||
errors.push({
|
||||
roomId,
|
||||
errorMessage: `Missing power level for kicks: ${userLevel} < ${kick}`,
|
||||
errorKind: ERROR_KIND_PERMISSION,
|
||||
});
|
||||
}
|
||||
if (userLevel < redact) {
|
||||
errors.push({
|
||||
roomId,
|
||||
errorMessage: `Missing power level for redactions: ${userLevel} < ${redact}`,
|
||||
errorKind: ERROR_KIND_PERMISSION,
|
||||
});
|
||||
}
|
||||
if (userLevel < aclLevel) {
|
||||
errors.push({
|
||||
roomId,
|
||||
errorMessage: `Missing power level for server ACLs: ${userLevel} < ${aclLevel}`,
|
||||
errorKind: ERROR_KIND_PERMISSION,
|
||||
});
|
||||
}
|
||||
|
||||
// Wants: Additional permissions
|
||||
|
||||
for (const additionalPermission of additionalPermissions) {
|
||||
const permLevel = plDefault(events[additionalPermission], stateDefault);
|
||||
|
||||
if (userLevel < permLevel) {
|
||||
errors.push({
|
||||
roomId,
|
||||
errorMessage: `Missing power level for "${additionalPermission}" state events: ${userLevel} < ${permLevel}`,
|
||||
errorKind: ERROR_KIND_PERMISSION,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Otherwise OK
|
||||
} catch (e) {
|
||||
LogService.error("Mjolnir", extractRequestError(e));
|
||||
errors.push({
|
||||
roomId,
|
||||
errorMessage: e.message || (e.body ? e.body.error : '<no message>'),
|
||||
errorKind: ERROR_KIND_FATAL,
|
||||
});
|
||||
}
|
||||
|
||||
return errors;
|
||||
}
|
||||
|
||||
private requiredProtectionPermissions(): Set<string> {
|
||||
return new Set(this.enabledProtections.map((p) => p.requiredStatePermissions).flat())
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns The protected rooms ordered by the most recently active first.
|
||||
*/
|
||||
public protectedRoomsByActivity(): string[] {
|
||||
return this.protectedRoomActivityTracker.protectedRoomsByActivity();
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync all the rooms with all the watched lists, banning and applying any changed ACLS.
|
||||
* @param verbose Whether to report any errors to the management room.
|
||||
*/
|
||||
public async syncLists(verbose = true) {
|
||||
for (const list of this.policyLists) {
|
||||
const changes = await list.updateList();
|
||||
await this.printBanlistChanges(changes, list, true);
|
||||
}
|
||||
|
||||
let hadErrors = false;
|
||||
const [aclErrors, banErrors] = await Promise.all([
|
||||
applyServerAcls(this.policyLists, this.protectedRoomsByActivity(), this),
|
||||
applyUserBans(this.policyLists, this.protectedRoomsByActivity(), this)
|
||||
]);
|
||||
const redactionErrors = await this.processRedactionQueue();
|
||||
hadErrors = hadErrors || await this.printActionResult(aclErrors, "Errors updating server ACLs:");
|
||||
hadErrors = hadErrors || await this.printActionResult(banErrors, "Errors updating member bans:");
|
||||
hadErrors = hadErrors || await this.printActionResult(redactionErrors, "Error updating redactions:");
|
||||
|
||||
if (!hadErrors && verbose) {
|
||||
const html = `<font color="#00cc00">Done updating rooms - no errors</font>`;
|
||||
const text = "Done updating rooms - no errors";
|
||||
await this.client.sendMessage(this.managementRoomId, {
|
||||
msgtype: "m.notice",
|
||||
body: text,
|
||||
format: "org.matrix.custom.html",
|
||||
formatted_body: html,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Pulls any changes to the rules that are in a policy room and updates all protected rooms
|
||||
* with those changes. Does not fail if there are errors updating the room, these are reported to the management room.
|
||||
* @param policyList The `PolicyList` which we will check for changes and apply them to all protected rooms.
|
||||
* @returns When all of the protected rooms have been updated.
|
||||
*/
|
||||
private async syncWithPolicyList(policyList: PolicyList): Promise<void> {
|
||||
const changes = await policyList.updateList();
|
||||
|
||||
let hadErrors = false;
|
||||
const [aclErrors, banErrors] = await Promise.all([
|
||||
applyServerAcls(this.policyLists, this.protectedRoomsByActivity(), this),
|
||||
applyUserBans(this.policyLists, this.protectedRoomsByActivity(), this)
|
||||
]);
|
||||
const redactionErrors = await this.processRedactionQueue();
|
||||
hadErrors = hadErrors || await this.printActionResult(aclErrors, "Errors updating server ACLs:");
|
||||
hadErrors = hadErrors || await this.printActionResult(banErrors, "Errors updating member bans:");
|
||||
hadErrors = hadErrors || await this.printActionResult(redactionErrors, "Error updating redactions:");
|
||||
|
||||
if (!hadErrors) {
|
||||
const html = `<font color="#00cc00"><b>Done updating rooms - no errors</b></font>`;
|
||||
const text = "Done updating rooms - no errors";
|
||||
await this.client.sendMessage(this.managementRoomId, {
|
||||
msgtype: "m.notice",
|
||||
body: text,
|
||||
format: "org.matrix.custom.html",
|
||||
formatted_body: html,
|
||||
});
|
||||
}
|
||||
// This can fail if the change is very large and it is much less important than applying bans, so do it last.
|
||||
await this.printBanlistChanges(changes, policyList, true);
|
||||
}
|
||||
|
||||
private async handleConsequence(protection: Protection, roomId: string, eventId: string, sender: string, consequence: Consequence) {
|
||||
switch (consequence.type) {
|
||||
case ConsequenceType.alert:
|
||||
@ -1039,113 +810,10 @@ export class Mjolnir {
|
||||
// can flag the event for redaction.
|
||||
await this.unlistedUserRedactionHandler.handleEvent(roomId, event, this);
|
||||
|
||||
if (event['type'] === 'm.room.power_levels' && event['state_key'] === '') {
|
||||
// power levels were updated - recheck permissions
|
||||
ErrorCache.resetError(roomId, ERROR_KIND_PERMISSION);
|
||||
await this.logMessage(LogLevel.DEBUG, "Mjolnir", `Power levels changed in ${roomId} - checking permissions...`, roomId);
|
||||
const errors = await this.verifyPermissionsIn(roomId);
|
||||
const hadErrors = await this.printActionResult(errors);
|
||||
if (!hadErrors) {
|
||||
await this.logMessage(LogLevel.DEBUG, "Mjolnir", `All permissions look OK.`);
|
||||
}
|
||||
return;
|
||||
} else if (event['type'] === "m.room.member") {
|
||||
// The reason we have to apply bans on each member change is because
|
||||
// we cannot eagerly ban users (that is to ban them when they have never been a member)
|
||||
// as they can be force joined to a room they might not have known existed.
|
||||
// Only apply bans and then redactions in the room we are currently looking at.
|
||||
const banErrors = await applyUserBans(this.policyLists, [roomId], this);
|
||||
const redactionErrors = await this.processRedactionQueue(roomId);
|
||||
await this.printActionResult(banErrors);
|
||||
await this.printActionResult(redactionErrors);
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Print the changes to a banlist to the management room.
|
||||
* @param changes A list of changes that have been made to a particular ban list.
|
||||
* @param ignoreSelf Whether to exclude changes that have been made by Mjolnir.
|
||||
* @returns true if the message was sent, false if it wasn't (because there there were no changes to report).
|
||||
*/
|
||||
private async printBanlistChanges(changes: ListRuleChange[], list: PolicyList, ignoreSelf = false): Promise<boolean> {
|
||||
if (ignoreSelf) {
|
||||
const sender = await this.client.getUserId();
|
||||
changes = changes.filter(change => change.sender !== sender);
|
||||
}
|
||||
if (changes.length <= 0) return false;
|
||||
|
||||
let html = "";
|
||||
let text = "";
|
||||
|
||||
const changesInfo = `updated with ${changes.length} ` + (changes.length === 1 ? 'change:' : 'changes:');
|
||||
const shortcodeInfo = list.listShortcode ? ` (shortcode: ${htmlEscape(list.listShortcode)})` : '';
|
||||
|
||||
html += `<a href="${htmlEscape(list.roomRef)}">${htmlEscape(list.roomId)}</a>${shortcodeInfo} ${changesInfo}<br/><ul>`;
|
||||
text += `${list.roomRef}${shortcodeInfo} ${changesInfo}:\n`;
|
||||
|
||||
for (const change of changes) {
|
||||
const rule = change.rule;
|
||||
let ruleKind: string = rule.kind;
|
||||
if (ruleKind === RULE_USER) {
|
||||
ruleKind = 'user';
|
||||
} else if (ruleKind === RULE_SERVER) {
|
||||
ruleKind = 'server';
|
||||
} else if (ruleKind === RULE_ROOM) {
|
||||
ruleKind = 'room';
|
||||
}
|
||||
html += `<li>${change.changeType} ${htmlEscape(ruleKind)} (<code>${htmlEscape(rule.recommendation ?? "")}</code>): <code>${htmlEscape(rule.entity)}</code> (${htmlEscape(rule.reason)})</li>`;
|
||||
text += `* ${change.changeType} ${ruleKind} (${rule.recommendation}): ${rule.entity} (${rule.reason})\n`;
|
||||
}
|
||||
|
||||
const message = {
|
||||
msgtype: "m.notice",
|
||||
body: text,
|
||||
format: "org.matrix.custom.html",
|
||||
formatted_body: html,
|
||||
};
|
||||
await this.client.sendMessage(this.managementRoomId, message);
|
||||
return true;
|
||||
}
|
||||
|
||||
private async printActionResult(errors: RoomUpdateError[], title: string | null = null, logAnyways = false) {
|
||||
if (errors.length <= 0) return false;
|
||||
|
||||
if (!logAnyways) {
|
||||
errors = errors.filter(e => ErrorCache.triggerError(e.roomId, e.errorKind));
|
||||
if (errors.length <= 0) {
|
||||
LogService.warn("Mjolnir", "Multiple errors are happening, however they are muted. Please check the management room.");
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
let html = "";
|
||||
let text = "";
|
||||
|
||||
const htmlTitle = title ? `${title}<br />` : '';
|
||||
const textTitle = title ? `${title}\n` : '';
|
||||
|
||||
html += `<font color="#ff0000"><b>${htmlTitle}${errors.length} errors updating protected rooms!</b></font><br /><ul>`;
|
||||
text += `${textTitle}${errors.length} errors updating protected rooms!\n`;
|
||||
const viaServers = [(new UserID(await this.client.getUserId())).domain];
|
||||
for (const error of errors) {
|
||||
const alias = (await this.client.getPublishedAlias(error.roomId)) || error.roomId;
|
||||
const url = Permalinks.forRoom(alias, viaServers);
|
||||
html += `<li><a href="${url}">${alias}</a> - ${error.errorMessage}</li>`;
|
||||
text += `${url} - ${error.errorMessage}\n`;
|
||||
}
|
||||
html += "</ul>";
|
||||
|
||||
const message = {
|
||||
msgtype: "m.notice",
|
||||
body: text,
|
||||
format: "org.matrix.custom.html",
|
||||
formatted_body: html,
|
||||
};
|
||||
await this.client.sendMessage(this.managementRoomId, message);
|
||||
return true;
|
||||
}
|
||||
|
||||
public async isSynapseAdmin(): Promise<boolean> {
|
||||
try {
|
||||
const endpoint = `/_synapse/admin/v1/users/${await this.client.getUserId()}/admin`;
|
||||
@ -1189,22 +857,6 @@ export class Mjolnir {
|
||||
}
|
||||
}
|
||||
|
||||
public queueRedactUserMessagesIn(userId: string, roomId: string) {
|
||||
this.eventRedactionQueue.add(new RedactUserInRoom(userId, roomId));
|
||||
}
|
||||
|
||||
/**
|
||||
* Process all queued redactions, this is usually called at the end of the sync process,
|
||||
* after all users have been banned and ACLs applied.
|
||||
* If a redaction cannot be processed, the redaction is skipped and removed from the queue.
|
||||
* We then carry on processing the next redactions.
|
||||
* @param roomId Limit processing to one room only, otherwise process redactions for all rooms.
|
||||
* @returns The list of errors encountered, for reporting to the management room.
|
||||
*/
|
||||
public async processRedactionQueue(roomId?: string): Promise<RoomUpdateError[]> {
|
||||
return await this.eventRedactionQueue.process(this, roomId);
|
||||
}
|
||||
|
||||
private async handleReport({ roomId, reporterId, event, reason }: { roomId: string, reporterId: string, event: any, reason?: string }) {
|
||||
for (const protection of this.enabledProtections) {
|
||||
await protection.handleReport(this, roomId, reporterId, event, reason);
|
||||
|
551
src/ProtectedRooms.ts
Normal file
551
src/ProtectedRooms.ts
Normal file
@ -0,0 +1,551 @@
|
||||
/*
|
||||
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.
|
||||
*/
|
||||
|
||||
import { extractRequestError, LogLevel, LogService, MatrixClient, MatrixGlob, Permalinks, UserID } from "matrix-bot-sdk";
|
||||
import { IConfig } from "./config";
|
||||
import ErrorCache, { ERROR_KIND_FATAL, ERROR_KIND_PERMISSION } from "./ErrorCache";
|
||||
import ManagementRoomOutput from "./ManagementRoom";
|
||||
import { RULE_ROOM, RULE_SERVER, RULE_USER } from "./models/ListRule";
|
||||
import PolicyList, { ListRuleChange } from "./models/PolicyList";
|
||||
import { RoomUpdateError } from "./models/RoomUpdateError";
|
||||
import { ServerAcl } from "./models/ServerAcl";
|
||||
import { EventRedactionQueue, RedactUserInRoom } from "./queues/EventRedactionQueue";
|
||||
import { ProtectedRoomActivityTracker } from "./queues/ProtectedRoomActivityTracker";
|
||||
import { htmlEscape } from "./utils";
|
||||
|
||||
/**
|
||||
* When you consider spaces https://github.com/matrix-org/mjolnir/issues/283
|
||||
* rather than indexing rooms via some collection, you instead have rooms
|
||||
* and then you find out which lists apply to them.
|
||||
* This is important because right now we have a collection of rooms
|
||||
* and implicitly a bunch of lists.
|
||||
*
|
||||
* It's important not to tie this to the one group of rooms that a mjolnir may watch too much
|
||||
* as in future we might want to borrow this class to represent a space.
|
||||
*/
|
||||
export class ProtectedRooms {
|
||||
|
||||
private protectedRooms = new Set</* room id */string>();
|
||||
|
||||
private policyLists: PolicyList[];
|
||||
|
||||
private protectedRoomActivityTracker: ProtectedRoomActivityTracker;
|
||||
|
||||
/**
|
||||
* This is a queue for redactions to process after mjolnir
|
||||
* has finished applying ACL and bans when syncing.
|
||||
*/
|
||||
private readonly eventRedactionQueue = new EventRedactionQueue();
|
||||
|
||||
private readonly errorCache = new ErrorCache();
|
||||
|
||||
private automaticRedactionReasons: MatrixGlob[] = [];
|
||||
|
||||
/**
|
||||
* Used to provide mutual exclusion when synchronizing rooms with the state of a policy list.
|
||||
* This is because requests operating with rules from an older version of the list that are slow
|
||||
* could race & give the room an inconsistent state. An example is if we add multiple m.policy.rule.server rules,
|
||||
* which would cause several requests to a room to send a new m.room.server_acl event.
|
||||
* These requests could finish in any order, which has left rooms with an inconsistent server_acl event
|
||||
* until Mjolnir synchronises the room with its policy lists again, which can be in the region of hours.
|
||||
*/
|
||||
public aclChain: Promise<void> = Promise.resolve();
|
||||
|
||||
constructor(
|
||||
private readonly client: MatrixClient,
|
||||
private readonly clientUserId: string,
|
||||
private readonly managementRoomId: string,
|
||||
private readonly managementRoom: ManagementRoomOutput,
|
||||
private readonly config: IConfig,
|
||||
) {
|
||||
// Setup room activity watcher
|
||||
this.protectedRoomActivityTracker = new ProtectedRoomActivityTracker(client);
|
||||
}
|
||||
|
||||
public queueRedactUserMessagesIn(userId: string, roomId: string) {
|
||||
this.eventRedactionQueue.add(new RedactUserInRoom(userId, roomId));
|
||||
}
|
||||
|
||||
public get automaticRedactGlobs(): MatrixGlob[] {
|
||||
return this.automaticRedactionReasons;
|
||||
}
|
||||
|
||||
/**
|
||||
* Process all queued redactions, this is usually called at the end of the sync process,
|
||||
* after all users have been banned and ACLs applied.
|
||||
* If a redaction cannot be processed, the redaction is skipped and removed from the queue.
|
||||
* We then carry on processing the next redactions.
|
||||
* @param roomId Limit processing to one room only, otherwise process redactions for all rooms.
|
||||
* @returns The list of errors encountered, for reporting to the management room.
|
||||
*/
|
||||
public async processRedactionQueue(roomId?: string): Promise<RoomUpdateError[]> {
|
||||
return await this.eventRedactionQueue.process(this.client, this.managementRoom, roomId);
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns The protected rooms ordered by the most recently active first.
|
||||
*/
|
||||
public protectedRoomsByActivity(): string[] {
|
||||
return this.protectedRoomActivityTracker.protectedRoomsByActivity();
|
||||
}
|
||||
|
||||
public async handleEvent(roomId: string, event: any) {
|
||||
if (event['sender'] === this.clientUserId) {
|
||||
throw new TypeError("`ProtectedRooms::handleEvent` should not be used to inform about events sent by mjolnir.");
|
||||
}
|
||||
if (event['type'] === 'm.room.power_levels' && event['state_key'] === '') {
|
||||
// power levels were updated - recheck permissions
|
||||
this.errorCache.resetError(roomId, ERROR_KIND_PERMISSION);
|
||||
await this.managementRoom.logMessage(LogLevel.DEBUG, "Mjolnir", `Power levels changed in ${roomId} - checking permissions...`, roomId);
|
||||
const errors = await this.verifyPermissionsIn(roomId);
|
||||
const hadErrors = await this.printActionResult(errors);
|
||||
if (!hadErrors) {
|
||||
await this.managementRoom.logMessage(LogLevel.DEBUG, "Mjolnir", `All permissions look OK.`);
|
||||
}
|
||||
return;
|
||||
} else if (event['type'] === "m.room.member") {
|
||||
// The reason we have to apply bans on each member change is because
|
||||
// we cannot eagerly ban users (that is to ban them when they have never been a member)
|
||||
// as they can be force joined to a room they might not have known existed.
|
||||
// Only apply bans and then redactions in the room we are currently looking at.
|
||||
const banErrors = await this.applyUserBans(this.policyLists, [roomId]);
|
||||
const redactionErrors = await this.processRedactionQueue(roomId);
|
||||
await this.printActionResult(banErrors);
|
||||
await this.printActionResult(redactionErrors);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync all the rooms with all the watched lists, banning and applying any changed ACLS.
|
||||
* @param verbose Whether to report any errors to the management room.
|
||||
*/
|
||||
public async syncLists(verbose = true) {
|
||||
for (const list of this.policyLists) {
|
||||
const changes = await list.updateList();
|
||||
await this.printBanlistChanges(changes, list, true);
|
||||
}
|
||||
|
||||
let hadErrors = false;
|
||||
const [aclErrors, banErrors] = await Promise.all([
|
||||
this.applyServerAcls(this.policyLists, this.protectedRoomsByActivity()),
|
||||
this.applyUserBans(this.policyLists, this.protectedRoomsByActivity())
|
||||
]);
|
||||
const redactionErrors = await this.processRedactionQueue();
|
||||
hadErrors = hadErrors || await this.printActionResult(aclErrors, "Errors updating server ACLs:");
|
||||
hadErrors = hadErrors || await this.printActionResult(banErrors, "Errors updating member bans:");
|
||||
hadErrors = hadErrors || await this.printActionResult(redactionErrors, "Error updating redactions:");
|
||||
|
||||
if (!hadErrors && verbose) {
|
||||
const html = `<font color="#00cc00">Done updating rooms - no errors</font>`;
|
||||
const text = "Done updating rooms - no errors";
|
||||
await this.client.sendMessage(this.managementRoomId, {
|
||||
msgtype: "m.notice",
|
||||
body: text,
|
||||
format: "org.matrix.custom.html",
|
||||
formatted_body: html,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
public async addProtectedRoom(roomId: string): Promise<void> {
|
||||
if (this.protectedRooms.has(roomId)) {
|
||||
// we need to protect ourselves form syncing all the lists unnecessarily
|
||||
// as Mjolnir does call this method repeatedly.
|
||||
return;
|
||||
}
|
||||
this.protectedRooms.add(roomId);
|
||||
this.protectedRoomActivityTracker.addProtectedRoom(roomId);
|
||||
await this.syncLists(this.config.verboseLogging);
|
||||
}
|
||||
|
||||
public removeProtectedRoom(roomId: string): void {
|
||||
this.protectedRoomActivityTracker.removeProtectedRoom(roomId);
|
||||
this.protectedRooms.delete(roomId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Pulls any changes to the rules that are in a policy room and updates all protected rooms
|
||||
* with those changes. Does not fail if there are errors updating the room, these are reported to the management room.
|
||||
* @param policyList The `PolicyList` which we will check for changes and apply them to all protected rooms.
|
||||
* @returns When all of the protected rooms have been updated.
|
||||
*/
|
||||
private async syncWithPolicyList(policyList: PolicyList): Promise<void> {
|
||||
// this bit can move away into a listener.
|
||||
const changes = await policyList.updateList();
|
||||
|
||||
let hadErrors = false;
|
||||
const [aclErrors, banErrors] = await Promise.all([
|
||||
this.applyServerAcls(this.policyLists, this.protectedRoomsByActivity()),
|
||||
this.applyUserBans(this.policyLists, this.protectedRoomsByActivity())
|
||||
]);
|
||||
const redactionErrors = await this.processRedactionQueue();
|
||||
hadErrors = hadErrors || await this.printActionResult(aclErrors, "Errors updating server ACLs:");
|
||||
hadErrors = hadErrors || await this.printActionResult(banErrors, "Errors updating member bans:");
|
||||
hadErrors = hadErrors || await this.printActionResult(redactionErrors, "Error updating redactions:");
|
||||
|
||||
if (!hadErrors) {
|
||||
const html = `<font color="#00cc00"><b>Done updating rooms - no errors</b></font>`;
|
||||
const text = "Done updating rooms - no errors";
|
||||
await this.client.sendMessage(this.managementRoomId, {
|
||||
msgtype: "m.notice",
|
||||
body: text,
|
||||
format: "org.matrix.custom.html",
|
||||
formatted_body: html,
|
||||
});
|
||||
}
|
||||
// This can fail if the change is very large and it is much less important than applying bans, so do it last.
|
||||
await this.printBanlistChanges(changes, policyList, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies the server ACLs represented by the ban lists to the provided rooms, returning the
|
||||
* room IDs that could not be updated and their error.
|
||||
* Does not update the banLists before taking their rules to build the server ACL.
|
||||
* @param {PolicyList[]} lists The lists to construct ACLs from.
|
||||
* @param {string[]} roomIds The room IDs to apply the ACLs in.
|
||||
* @param {Mjolnir} mjolnir The Mjolnir client to apply the ACLs with.
|
||||
*/
|
||||
private async applyServerAcls(lists: PolicyList[], roomIds: string[]): Promise<RoomUpdateError[]> {
|
||||
// we need to provide mutual exclusion so that we do not have requests updating the m.room.server_acl event
|
||||
// finish out of order and therefore leave the room out of sync with the policy lists.
|
||||
return new Promise((resolve, reject) => {
|
||||
this.aclChain = this.aclChain
|
||||
.then(() => this._applyServerAcls(lists, roomIds))
|
||||
.then(resolve, reject);
|
||||
});
|
||||
}
|
||||
|
||||
private async _applyServerAcls(lists: PolicyList[], roomIds: string[]): Promise<RoomUpdateError[]> {
|
||||
const serverName: string = new UserID(await this.client.getUserId()).domain;
|
||||
|
||||
// Construct a server ACL first
|
||||
const acl = new ServerAcl(serverName).denyIpAddresses().allowServer("*");
|
||||
for (const list of lists) {
|
||||
for (const rule of list.serverRules) {
|
||||
acl.denyServer(rule.entity);
|
||||
}
|
||||
}
|
||||
|
||||
const finalAcl = acl.safeAclContent();
|
||||
|
||||
if (finalAcl.deny.length !== acl.literalAclContent().deny.length) {
|
||||
this.managementRoom.logMessage(LogLevel.WARN, "ApplyAcl", `Mjölnir has detected and removed an ACL that would exclude itself. Please check the ACL lists.`);
|
||||
}
|
||||
|
||||
if (this.config.verboseLogging) {
|
||||
// We specifically use sendNotice to avoid having to escape HTML
|
||||
await this.client.sendNotice(this.managementRoomId, `Constructed server ACL:\n${JSON.stringify(finalAcl, null, 2)}`);
|
||||
}
|
||||
|
||||
const errors: RoomUpdateError[] = [];
|
||||
for (const roomId of roomIds) {
|
||||
try {
|
||||
await this.managementRoom.logMessage(LogLevel.DEBUG, "ApplyAcl", `Checking ACLs for ${roomId}`, roomId);
|
||||
|
||||
try {
|
||||
const currentAcl = await this.client.getRoomStateEvent(roomId, "m.room.server_acl", "");
|
||||
if (acl.matches(currentAcl)) {
|
||||
await this.managementRoom.logMessage(LogLevel.DEBUG, "ApplyAcl", `Skipping ACLs for ${roomId} because they are already the right ones`, roomId);
|
||||
continue;
|
||||
}
|
||||
} catch (e) {
|
||||
// ignore - assume no ACL
|
||||
}
|
||||
|
||||
// We specifically use sendNotice to avoid having to escape HTML
|
||||
await this.managementRoom.logMessage(LogLevel.DEBUG, "ApplyAcl", `Applying ACL in ${roomId}`, roomId);
|
||||
|
||||
if (!this.config.noop) {
|
||||
await this.client.sendStateEvent(roomId, "m.room.server_acl", "", finalAcl);
|
||||
} else {
|
||||
await this.managementRoom.logMessage(LogLevel.WARN, "ApplyAcl", `Tried to apply ACL in ${roomId} but Mjolnir is running in no-op mode`, roomId);
|
||||
}
|
||||
} catch (e) {
|
||||
const message = e.message || (e.body ? e.body.error : '<no message>');
|
||||
const kind = message && message.includes("You don't have permission to post that to the room") ? ERROR_KIND_PERMISSION : ERROR_KIND_FATAL;
|
||||
errors.push({ roomId, errorMessage: message, errorKind: kind });
|
||||
}
|
||||
}
|
||||
|
||||
return errors;
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies the member bans represented by the ban lists to the provided rooms, returning the
|
||||
* room IDs that could not be updated and their error.
|
||||
* @param {PolicyList[]} lists The lists to determine bans from.
|
||||
* @param {string[]} roomIds The room IDs to apply the bans in.
|
||||
* @param {Mjolnir} mjolnir The Mjolnir client to apply the bans with.
|
||||
*/
|
||||
private async applyUserBans(lists: PolicyList[], roomIds: string[]): Promise<RoomUpdateError[]> {
|
||||
// We can only ban people who are not already banned, and who match the rules.
|
||||
const errors: RoomUpdateError[] = [];
|
||||
for (const roomId of roomIds) {
|
||||
try {
|
||||
// We specifically use sendNotice to avoid having to escape HTML
|
||||
await this.managementRoom.logMessage(LogLevel.DEBUG, "ApplyBan", `Updating member bans in ${roomId}`, roomId);
|
||||
|
||||
let members: { userId: string, membership: string }[];
|
||||
|
||||
if (this.config.fasterMembershipChecks) {
|
||||
const memberIds = await this.client.getJoinedRoomMembers(roomId);
|
||||
members = memberIds.map(u => {
|
||||
return { userId: u, membership: "join" };
|
||||
});
|
||||
} else {
|
||||
const state = await this.client.getRoomState(roomId);
|
||||
members = state.filter(s => s['type'] === 'm.room.member' && !!s['state_key']).map(s => {
|
||||
return { userId: s['state_key'], membership: s['content'] ? s['content']['membership'] : 'leave' };
|
||||
});
|
||||
}
|
||||
|
||||
for (const member of members) {
|
||||
if (member.membership === 'ban') {
|
||||
continue; // user already banned
|
||||
}
|
||||
|
||||
let banned = false;
|
||||
for (const list of lists) {
|
||||
for (const userRule of list.userRules) {
|
||||
if (userRule.isMatch(member.userId)) {
|
||||
// User needs to be banned
|
||||
|
||||
// We specifically use sendNotice to avoid having to escape HTML
|
||||
await this.managementRoom.logMessage(LogLevel.INFO, "ApplyBan", `Banning ${member.userId} in ${roomId} for: ${userRule.reason}`, roomId);
|
||||
|
||||
if (!this.config.noop) {
|
||||
await this.client.banUser(member.userId, roomId, userRule.reason);
|
||||
if (this.automaticRedactGlobs.find(g => g.test(userRule.reason.toLowerCase()))) {
|
||||
this.queueRedactUserMessagesIn(member.userId, roomId);
|
||||
}
|
||||
} else {
|
||||
await this.managementRoom.logMessage(LogLevel.WARN, "ApplyBan", `Tried to ban ${member.userId} in ${roomId} but Mjolnir is running in no-op mode`, roomId);
|
||||
}
|
||||
|
||||
banned = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (banned) break;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
const message = e.message || (e.body ? e.body.error : '<no message>');
|
||||
errors.push({
|
||||
roomId,
|
||||
errorMessage: message,
|
||||
errorKind: message && message.includes("You don't have permission to ban") ? ERROR_KIND_PERMISSION : ERROR_KIND_FATAL,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return errors;
|
||||
}
|
||||
|
||||
/**
|
||||
* Print the changes to a banlist to the management room.
|
||||
* @param changes A list of changes that have been made to a particular ban list.
|
||||
* @param ignoreSelf Whether to exclude changes that have been made by Mjolnir.
|
||||
* @returns true if the message was sent, false if it wasn't (because there there were no changes to report).
|
||||
*/
|
||||
private async printBanlistChanges(changes: ListRuleChange[], list: PolicyList, ignoreSelf = false): Promise<boolean> {
|
||||
if (ignoreSelf) {
|
||||
const sender = await this.client.getUserId();
|
||||
changes = changes.filter(change => change.sender !== sender);
|
||||
}
|
||||
if (changes.length <= 0) return false;
|
||||
|
||||
let html = "";
|
||||
let text = "";
|
||||
|
||||
const changesInfo = `updated with ${changes.length} ` + (changes.length === 1 ? 'change:' : 'changes:');
|
||||
const shortcodeInfo = list.listShortcode ? ` (shortcode: ${htmlEscape(list.listShortcode)})` : '';
|
||||
|
||||
html += `<a href="${htmlEscape(list.roomRef)}">${htmlEscape(list.roomId)}</a>${shortcodeInfo} ${changesInfo}<br/><ul>`;
|
||||
text += `${list.roomRef}${shortcodeInfo} ${changesInfo}:\n`;
|
||||
|
||||
for (const change of changes) {
|
||||
const rule = change.rule;
|
||||
let ruleKind: string = rule.kind;
|
||||
if (ruleKind === RULE_USER) {
|
||||
ruleKind = 'user';
|
||||
} else if (ruleKind === RULE_SERVER) {
|
||||
ruleKind = 'server';
|
||||
} else if (ruleKind === RULE_ROOM) {
|
||||
ruleKind = 'room';
|
||||
}
|
||||
html += `<li>${change.changeType} ${htmlEscape(ruleKind)} (<code>${htmlEscape(rule.recommendation ?? "")}</code>): <code>${htmlEscape(rule.entity)}</code> (${htmlEscape(rule.reason)})</li>`;
|
||||
text += `* ${change.changeType} ${ruleKind} (${rule.recommendation}): ${rule.entity} (${rule.reason})\n`;
|
||||
}
|
||||
|
||||
const message = {
|
||||
msgtype: "m.notice",
|
||||
body: text,
|
||||
format: "org.matrix.custom.html",
|
||||
formatted_body: html,
|
||||
};
|
||||
await this.client.sendMessage(this.managementRoomId, message);
|
||||
return true;
|
||||
}
|
||||
|
||||
private async printActionResult(errors: RoomUpdateError[], title: string | null = null, logAnyways = false) {
|
||||
if (errors.length <= 0) return false;
|
||||
|
||||
if (!logAnyways) {
|
||||
errors = errors.filter(e => this.errorCache.triggerError(e.roomId, e.errorKind));
|
||||
if (errors.length <= 0) {
|
||||
LogService.warn("Mjolnir", "Multiple errors are happening, however they are muted. Please check the management room.");
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
let html = "";
|
||||
let text = "";
|
||||
|
||||
const htmlTitle = title ? `${title}<br />` : '';
|
||||
const textTitle = title ? `${title}\n` : '';
|
||||
|
||||
html += `<font color="#ff0000"><b>${htmlTitle}${errors.length} errors updating protected rooms!</b></font><br /><ul>`;
|
||||
text += `${textTitle}${errors.length} errors updating protected rooms!\n`;
|
||||
const viaServers = [(new UserID(await this.client.getUserId())).domain];
|
||||
for (const error of errors) {
|
||||
const alias = (await this.client.getPublishedAlias(error.roomId)) || error.roomId;
|
||||
const url = Permalinks.forRoom(alias, viaServers);
|
||||
html += `<li><a href="${url}">${alias}</a> - ${error.errorMessage}</li>`;
|
||||
text += `${url} - ${error.errorMessage}\n`;
|
||||
}
|
||||
html += "</ul>";
|
||||
|
||||
const message = {
|
||||
msgtype: "m.notice",
|
||||
body: text,
|
||||
format: "org.matrix.custom.html",
|
||||
formatted_body: html,
|
||||
};
|
||||
await this.client.sendMessage(this.managementRoomId, message);
|
||||
return true;
|
||||
}
|
||||
|
||||
public requiredProtectionPermissions() {
|
||||
throw new TypeError("Unimplemented, need to put protections into here too.")
|
||||
}
|
||||
|
||||
public async verifyPermissions(verbose = true, printRegardless = false) {
|
||||
const errors: RoomUpdateError[] = [];
|
||||
for (const roomId of Object.keys(this.protectedRooms)) {
|
||||
errors.push(...(await this.verifyPermissionsIn(roomId)));
|
||||
}
|
||||
|
||||
const hadErrors = await this.printActionResult(errors, "Permission errors in protected rooms:", printRegardless);
|
||||
if (!hadErrors && verbose) {
|
||||
const html = `<font color="#00cc00">All permissions look OK.</font>`;
|
||||
const text = "All permissions look OK.";
|
||||
await this.client.sendMessage(this.managementRoomId, {
|
||||
msgtype: "m.notice",
|
||||
body: text,
|
||||
format: "org.matrix.custom.html",
|
||||
formatted_body: html,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private async verifyPermissionsIn(roomId: string): Promise<RoomUpdateError[]> {
|
||||
const errors: RoomUpdateError[] = [];
|
||||
const additionalPermissions = this.requiredProtectionPermissions();
|
||||
|
||||
try {
|
||||
const ownUserId = await this.client.getUserId();
|
||||
|
||||
const powerLevels = await this.client.getRoomStateEvent(roomId, "m.room.power_levels", "");
|
||||
if (!powerLevels) {
|
||||
// noinspection ExceptionCaughtLocallyJS
|
||||
throw new Error("Missing power levels state event");
|
||||
}
|
||||
|
||||
function plDefault(val: number | undefined | null, def: number): number {
|
||||
if (!val && val !== 0) return def;
|
||||
return val;
|
||||
}
|
||||
|
||||
const users = powerLevels['users'] || {};
|
||||
const events = powerLevels['events'] || {};
|
||||
const usersDefault = plDefault(powerLevels['users_default'], 0);
|
||||
const stateDefault = plDefault(powerLevels['state_default'], 50);
|
||||
const ban = plDefault(powerLevels['ban'], 50);
|
||||
const kick = plDefault(powerLevels['kick'], 50);
|
||||
const redact = plDefault(powerLevels['redact'], 50);
|
||||
|
||||
const userLevel = plDefault(users[ownUserId], usersDefault);
|
||||
const aclLevel = plDefault(events["m.room.server_acl"], stateDefault);
|
||||
|
||||
// Wants: ban, kick, redact, m.room.server_acl
|
||||
|
||||
if (userLevel < ban) {
|
||||
errors.push({
|
||||
roomId,
|
||||
errorMessage: `Missing power level for bans: ${userLevel} < ${ban}`,
|
||||
errorKind: ERROR_KIND_PERMISSION,
|
||||
});
|
||||
}
|
||||
if (userLevel < kick) {
|
||||
errors.push({
|
||||
roomId,
|
||||
errorMessage: `Missing power level for kicks: ${userLevel} < ${kick}`,
|
||||
errorKind: ERROR_KIND_PERMISSION,
|
||||
});
|
||||
}
|
||||
if (userLevel < redact) {
|
||||
errors.push({
|
||||
roomId,
|
||||
errorMessage: `Missing power level for redactions: ${userLevel} < ${redact}`,
|
||||
errorKind: ERROR_KIND_PERMISSION,
|
||||
});
|
||||
}
|
||||
if (userLevel < aclLevel) {
|
||||
errors.push({
|
||||
roomId,
|
||||
errorMessage: `Missing power level for server ACLs: ${userLevel} < ${aclLevel}`,
|
||||
errorKind: ERROR_KIND_PERMISSION,
|
||||
});
|
||||
}
|
||||
|
||||
// Wants: Additional permissions
|
||||
|
||||
for (const additionalPermission of additionalPermissions) {
|
||||
const permLevel = plDefault(events[additionalPermission], stateDefault);
|
||||
|
||||
if (userLevel < permLevel) {
|
||||
errors.push({
|
||||
roomId,
|
||||
errorMessage: `Missing power level for "${additionalPermission}" state events: ${userLevel} < ${permLevel}`,
|
||||
errorKind: ERROR_KIND_PERMISSION,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Otherwise OK
|
||||
} catch (e) {
|
||||
LogService.error("Mjolnir", extractRequestError(e));
|
||||
errors.push({
|
||||
roomId,
|
||||
errorMessage: e.message || (e.body ? e.body.error : '<no message>'),
|
||||
errorKind: ERROR_KIND_FATAL,
|
||||
});
|
||||
}
|
||||
|
||||
return errors;
|
||||
}
|
||||
}
|
@ -1,95 +0,0 @@
|
||||
/*
|
||||
Copyright 2019, 2020 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 PolicyList from "../models/PolicyList";
|
||||
import { ServerAcl } from "../models/ServerAcl";
|
||||
import { RoomUpdateError } from "../models/RoomUpdateError";
|
||||
import { Mjolnir } from "../Mjolnir";
|
||||
import { LogLevel, UserID } from "matrix-bot-sdk";
|
||||
import { ERROR_KIND_FATAL, ERROR_KIND_PERMISSION } from "../ErrorCache";
|
||||
|
||||
/**
|
||||
* Applies the server ACLs represented by the ban lists to the provided rooms, returning the
|
||||
* room IDs that could not be updated and their error.
|
||||
* Does not update the banLists before taking their rules to build the server ACL.
|
||||
* @param {PolicyList[]} lists The lists to construct ACLs from.
|
||||
* @param {string[]} roomIds The room IDs to apply the ACLs in.
|
||||
* @param {Mjolnir} mjolnir The Mjolnir client to apply the ACLs with.
|
||||
*/
|
||||
export async function applyServerAcls(lists: PolicyList[], roomIds: string[], mjolnir: Mjolnir): Promise<RoomUpdateError[]> {
|
||||
// we need to provide mutual exclusion so that we do not have requests updating the m.room.server_acl event
|
||||
// finish out of order and therefore leave the room out of sync with the policy lists.
|
||||
return new Promise((resolve, reject) => {
|
||||
mjolnir.aclChain = mjolnir.aclChain
|
||||
.then(() => _applyServerAcls(lists, roomIds, mjolnir))
|
||||
.then(resolve, reject);
|
||||
});
|
||||
}
|
||||
|
||||
async function _applyServerAcls(lists: PolicyList[], roomIds: string[], mjolnir: Mjolnir): Promise<RoomUpdateError[]> {
|
||||
const serverName: string = new UserID(await mjolnir.client.getUserId()).domain;
|
||||
|
||||
// Construct a server ACL first
|
||||
const acl = new ServerAcl(serverName).denyIpAddresses().allowServer("*");
|
||||
for (const list of lists) {
|
||||
for (const rule of list.serverRules) {
|
||||
acl.denyServer(rule.entity);
|
||||
}
|
||||
}
|
||||
|
||||
const finalAcl = acl.safeAclContent();
|
||||
|
||||
if (finalAcl.deny.length !== acl.literalAclContent().deny.length) {
|
||||
mjolnir.logMessage(LogLevel.WARN, "ApplyAcl", `Mjölnir has detected and removed an ACL that would exclude itself. Please check the ACL lists.`);
|
||||
}
|
||||
|
||||
if (mjolnir.config.verboseLogging) {
|
||||
// We specifically use sendNotice to avoid having to escape HTML
|
||||
await mjolnir.client.sendNotice(mjolnir.managementRoomId, `Constructed server ACL:\n${JSON.stringify(finalAcl, null, 2)}`);
|
||||
}
|
||||
|
||||
const errors: RoomUpdateError[] = [];
|
||||
for (const roomId of roomIds) {
|
||||
try {
|
||||
await mjolnir.logMessage(LogLevel.DEBUG, "ApplyAcl", `Checking ACLs for ${roomId}`, roomId);
|
||||
|
||||
try {
|
||||
const currentAcl = await mjolnir.client.getRoomStateEvent(roomId, "m.room.server_acl", "");
|
||||
if (acl.matches(currentAcl)) {
|
||||
await mjolnir.logMessage(LogLevel.DEBUG, "ApplyAcl", `Skipping ACLs for ${roomId} because they are already the right ones`, roomId);
|
||||
continue;
|
||||
}
|
||||
} catch (e) {
|
||||
// ignore - assume no ACL
|
||||
}
|
||||
|
||||
// We specifically use sendNotice to avoid having to escape HTML
|
||||
await mjolnir.logMessage(LogLevel.DEBUG, "ApplyAcl", `Applying ACL in ${roomId}`, roomId);
|
||||
|
||||
if (!mjolnir.config.noop) {
|
||||
await mjolnir.client.sendStateEvent(roomId, "m.room.server_acl", "", finalAcl);
|
||||
} else {
|
||||
await mjolnir.logMessage(LogLevel.WARN, "ApplyAcl", `Tried to apply ACL in ${roomId} but Mjolnir is running in no-op mode`, roomId);
|
||||
}
|
||||
} catch (e) {
|
||||
const message = e.message || (e.body ? e.body.error : '<no message>');
|
||||
const kind = message && message.includes("You don't have permission to post that to the room") ? ERROR_KIND_PERMISSION : ERROR_KIND_FATAL;
|
||||
errors.push({ roomId, errorMessage: message, errorKind: kind });
|
||||
}
|
||||
}
|
||||
|
||||
return errors;
|
||||
}
|
@ -1,93 +0,0 @@
|
||||
/*
|
||||
Copyright 2019, 2020 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 PolicyList from "../models/PolicyList";
|
||||
import { RoomUpdateError } from "../models/RoomUpdateError";
|
||||
import { Mjolnir } from "../Mjolnir";
|
||||
import { LogLevel } from "matrix-bot-sdk";
|
||||
import { ERROR_KIND_FATAL, ERROR_KIND_PERMISSION } from "../ErrorCache";
|
||||
|
||||
/**
|
||||
* Applies the member bans represented by the ban lists to the provided rooms, returning the
|
||||
* room IDs that could not be updated and their error.
|
||||
* @param {PolicyList[]} lists The lists to determine bans from.
|
||||
* @param {string[]} roomIds The room IDs to apply the bans in.
|
||||
* @param {Mjolnir} mjolnir The Mjolnir client to apply the bans with.
|
||||
*/
|
||||
export async function applyUserBans(lists: PolicyList[], roomIds: string[], mjolnir: Mjolnir): Promise<RoomUpdateError[]> {
|
||||
// We can only ban people who are not already banned, and who match the rules.
|
||||
const errors: RoomUpdateError[] = [];
|
||||
for (const roomId of roomIds) {
|
||||
try {
|
||||
// We specifically use sendNotice to avoid having to escape HTML
|
||||
await mjolnir.logMessage(LogLevel.DEBUG, "ApplyBan", `Updating member bans in ${roomId}`, roomId);
|
||||
|
||||
let members: { userId: string, membership: string }[];
|
||||
|
||||
if (mjolnir.config.fasterMembershipChecks) {
|
||||
const memberIds = await mjolnir.client.getJoinedRoomMembers(roomId);
|
||||
members = memberIds.map(u => {
|
||||
return { userId: u, membership: "join" };
|
||||
});
|
||||
} else {
|
||||
const state = await mjolnir.client.getRoomState(roomId);
|
||||
members = state.filter(s => s['type'] === 'm.room.member' && !!s['state_key']).map(s => {
|
||||
return { userId: s['state_key'], membership: s['content'] ? s['content']['membership'] : 'leave' };
|
||||
});
|
||||
}
|
||||
|
||||
for (const member of members) {
|
||||
if (member.membership === 'ban') {
|
||||
continue; // user already banned
|
||||
}
|
||||
|
||||
let banned = false;
|
||||
for (const list of lists) {
|
||||
for (const userRule of list.userRules) {
|
||||
if (userRule.isMatch(member.userId)) {
|
||||
// User needs to be banned
|
||||
|
||||
// We specifically use sendNotice to avoid having to escape HTML
|
||||
await mjolnir.logMessage(LogLevel.INFO, "ApplyBan", `Banning ${member.userId} in ${roomId} for: ${userRule.reason}`, roomId);
|
||||
|
||||
if (!mjolnir.config.noop) {
|
||||
await mjolnir.client.banUser(member.userId, roomId, userRule.reason);
|
||||
if (mjolnir.automaticRedactGlobs.find(g => g.test(userRule.reason.toLowerCase()))) {
|
||||
mjolnir.queueRedactUserMessagesIn(member.userId, roomId);
|
||||
}
|
||||
} else {
|
||||
await mjolnir.logMessage(LogLevel.WARN, "ApplyBan", `Tried to ban ${member.userId} in ${roomId} but Mjolnir is running in no-op mode`, roomId);
|
||||
}
|
||||
|
||||
banned = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (banned) break;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
const message = e.message || (e.body ? e.body.error : '<no message>');
|
||||
errors.push({
|
||||
roomId,
|
||||
errorMessage: message,
|
||||
errorKind: message && message.includes("You don't have permission to ban") ? ERROR_KIND_PERMISSION : ERROR_KIND_FATAL,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return errors;
|
||||
}
|
@ -13,11 +13,12 @@ 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 { LogLevel } from "matrix-bot-sdk"
|
||||
import { LogLevel, MatrixClient } from "matrix-bot-sdk"
|
||||
import { ERROR_KIND_FATAL } from "../ErrorCache";
|
||||
import { RoomUpdateError } from "../models/RoomUpdateError";
|
||||
import { redactUserMessagesIn } from "../utils";
|
||||
import { Mjolnir } from "../Mjolnir";
|
||||
import ManagementRoomOutput from "../ManagementRoom";
|
||||
|
||||
export interface QueuedRedaction {
|
||||
/** The room which the redaction will take place in. */
|
||||
@ -27,7 +28,7 @@ export interface QueuedRedaction {
|
||||
* Called by the EventRedactionQueue.
|
||||
* @param client A MatrixClient to use to carry out the redaction.
|
||||
*/
|
||||
redact(mjolnir: Mjolnir): Promise<void>
|
||||
redact(client: MatrixClient, managementRoom: ManagementRoomOutput): Promise<void>
|
||||
/**
|
||||
* Used to test whether the redaction is the equivalent to another redaction.
|
||||
* @param redaction Another QueuedRedaction to test if this redaction is an equivalent to.
|
||||
@ -47,9 +48,9 @@ export class RedactUserInRoom implements QueuedRedaction {
|
||||
this.roomId = roomId;
|
||||
}
|
||||
|
||||
public async redact(mjolnir: Mjolnir) {
|
||||
await mjolnir.logMessage(LogLevel.DEBUG, "Mjolnir", `Redacting events from ${this.userId} in room ${this.roomId}.`);
|
||||
await redactUserMessagesIn(mjolnir, this.userId, [this.roomId]);
|
||||
public async redact(client: MatrixClient, managementRoom: ManagementRoomOutput) {
|
||||
await managementRoom.logMessage(LogLevel.DEBUG, "Mjolnir", `Redacting events from ${this.userId} in room ${this.roomId}.`);
|
||||
await redactUserMessagesIn(client, managementRoom, this.userId, [this.roomId]);
|
||||
}
|
||||
|
||||
public redactionEqual(redaction: QueuedRedaction): boolean {
|
||||
@ -107,12 +108,12 @@ export class EventRedactionQueue {
|
||||
* @param limitToRoomId If the roomId is provided, only redactions for that room will be processed.
|
||||
* @returns A description of any errors encountered by each QueuedRedaction that was processed.
|
||||
*/
|
||||
public async process(mjolnir: Mjolnir, limitToRoomId?: string): Promise<RoomUpdateError[]> {
|
||||
public async process(client: MatrixClient, managementRoom: ManagementRoomOutput, limitToRoomId?: string): Promise<RoomUpdateError[]> {
|
||||
const errors: RoomUpdateError[] = [];
|
||||
const redact = async (currentBatch: QueuedRedaction[]) => {
|
||||
for (const redaction of currentBatch) {
|
||||
try {
|
||||
await redaction.redact(mjolnir);
|
||||
await redaction.redact(client, managementRoom);
|
||||
} catch (e) {
|
||||
let roomError: RoomUpdateError;
|
||||
if (e.roomId && e.errorMessage && e.errorKind) {
|
||||
|
60
src/utils.ts
60
src/utils.ts
@ -30,6 +30,7 @@ import {
|
||||
import { Mjolnir } from "./Mjolnir";
|
||||
import { ClientRequest, IncomingMessage } from "http";
|
||||
import { default as parseDuration } from "parse-duration";
|
||||
import ManagementRoomOutput from "./ManagementRoom";
|
||||
|
||||
// Define a few aliases to simplify parsing durations.
|
||||
|
||||
@ -70,17 +71,17 @@ export function isTrueJoinEvent(event: any): boolean {
|
||||
return membership === 'join' && prevMembership !== "join";
|
||||
}
|
||||
|
||||
export async function redactUserMessagesIn(mjolnir: Mjolnir, userIdOrGlob: string, targetRoomIds: string[], limit = 1000) {
|
||||
export async function redactUserMessagesIn(client: MatrixClient, managementRoom: ManagementRoomOutput, userIdOrGlob: string, targetRoomIds: string[], limit = 1000, noop = false) {
|
||||
for (const targetRoomId of targetRoomIds) {
|
||||
await mjolnir.logMessage(LogLevel.DEBUG, "utils#redactUserMessagesIn", `Fetching sent messages for ${userIdOrGlob} in ${targetRoomId} to redact...`, targetRoomId);
|
||||
await managementRoom.logMessage(LogLevel.DEBUG, "utils#redactUserMessagesIn", `Fetching sent messages for ${userIdOrGlob} in ${targetRoomId} to redact...`, targetRoomId);
|
||||
|
||||
await getMessagesByUserIn(mjolnir.client, userIdOrGlob, targetRoomId, limit, async (eventsToRedact) => {
|
||||
await getMessagesByUserIn(client, userIdOrGlob, targetRoomId, limit, async (eventsToRedact) => {
|
||||
for (const victimEvent of eventsToRedact) {
|
||||
await mjolnir.logMessage(LogLevel.DEBUG, "utils#redactUserMessagesIn", `Redacting ${victimEvent['event_id']} in ${targetRoomId}`, targetRoomId);
|
||||
if (!mjolnir.config.noop) {
|
||||
await mjolnir.client.redactEvent(targetRoomId, victimEvent['event_id']);
|
||||
await managementRoom.logMessage(LogLevel.DEBUG, "utils#redactUserMessagesIn", `Redacting ${victimEvent['event_id']} in ${targetRoomId}`, targetRoomId);
|
||||
if (!noop) {
|
||||
await client.redactEvent(targetRoomId, victimEvent['event_id']);
|
||||
} else {
|
||||
await mjolnir.logMessage(LogLevel.WARN, "utils#redactUserMessagesIn", `Tried to redact ${victimEvent['event_id']} in ${targetRoomId} but Mjolnir is running in no-op mode`, targetRoomId);
|
||||
await managementRoom.logMessage(LogLevel.WARN, "utils#redactUserMessagesIn", `Tried to redact ${victimEvent['event_id']} in ${targetRoomId} but Mjolnir is running in no-op mode`, targetRoomId);
|
||||
}
|
||||
}
|
||||
});
|
||||
@ -189,51 +190,6 @@ export async function getMessagesByUserIn(client: MatrixClient, sender: string,
|
||||
} while (token && processed < limit)
|
||||
}
|
||||
|
||||
/*
|
||||
* Take an arbitrary string and a set of room IDs, and return a
|
||||
* TextualMessageEventContent whose plaintext component replaces those room
|
||||
* IDs with their canonical aliases, and whose html component replaces those
|
||||
* room IDs with their matrix.to room pills.
|
||||
*
|
||||
* @param client The matrix client on which to query for room aliases
|
||||
* @param text An arbitrary string to rewrite with room aliases and pills
|
||||
* @param roomIds A set of room IDs to find and replace in `text`
|
||||
* @param msgtype The desired message type of the returned TextualMessageEventContent
|
||||
* @returns A TextualMessageEventContent with replaced room IDs
|
||||
*/
|
||||
export async function replaceRoomIdsWithPills(mjolnir: Mjolnir, text: string, roomIds: Set<string>, msgtype: MessageType = "m.text"): Promise<TextualMessageEventContent> {
|
||||
const content: TextualMessageEventContent = {
|
||||
body: text,
|
||||
formatted_body: htmlEscape(text),
|
||||
msgtype: msgtype,
|
||||
format: "org.matrix.custom.html",
|
||||
};
|
||||
|
||||
const escapeRegex = (v: string): string => {
|
||||
return v.replace(/[-\/\\^$*+?.()|[\]{}]/g, '\\$&');
|
||||
};
|
||||
|
||||
const viaServers = [(new UserID(await mjolnir.client.getUserId())).domain];
|
||||
for (const roomId of roomIds) {
|
||||
let alias = roomId;
|
||||
try {
|
||||
alias = (await mjolnir.client.getPublishedAlias(roomId)) || roomId;
|
||||
} catch (e) {
|
||||
// This is a recursive call, so tell the function not to try and call us
|
||||
await mjolnir.logMessage(LogLevel.WARN, "utils", `Failed to resolve room alias for ${roomId} - see console for details`, null, true);
|
||||
LogService.warn("utils", extractRequestError(e));
|
||||
}
|
||||
const regexRoomId = new RegExp(escapeRegex(roomId), "g");
|
||||
content.body = content.body.replace(regexRoomId, alias);
|
||||
if (content.formatted_body) {
|
||||
const permalink = Permalinks.forRoom(alias, alias !== roomId ? [] : viaServers);
|
||||
content.formatted_body = content.formatted_body.replace(regexRoomId, `<a href="${permalink}">${alias}</a>`);
|
||||
}
|
||||
}
|
||||
|
||||
return content;
|
||||
}
|
||||
|
||||
let isMatrixClientPatchedForConciseExceptions = false;
|
||||
|
||||
/**
|
||||
|
Loading…
Reference in New Issue
Block a user