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 {
|
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) {
|
public resetError(roomId: string, kind: string) {
|
||||||
if (!ErrorCache.roomsToErrors[roomId]) {
|
if (!this.roomsToErrors[roomId]) {
|
||||||
ErrorCache.roomsToErrors[roomId] = {};
|
this.roomsToErrors[roomId] = {};
|
||||||
}
|
}
|
||||||
ErrorCache.roomsToErrors[roomId][kind] = 0;
|
this.roomsToErrors[roomId][kind] = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
public static triggerError(roomId: string, kind: string): boolean {
|
public triggerError(roomId: string, kind: string): boolean {
|
||||||
if (!ErrorCache.roomsToErrors[roomId]) {
|
if (!this.roomsToErrors[roomId]) {
|
||||||
ErrorCache.roomsToErrors[roomId] = {};
|
this.roomsToErrors[roomId] = {};
|
||||||
}
|
}
|
||||||
|
|
||||||
const triggers = ErrorCache.roomsToErrors[roomId];
|
const triggers = this.roomsToErrors[roomId];
|
||||||
if (!triggers[kind]) {
|
if (!triggers[kind]) {
|
||||||
triggers[kind] = 0;
|
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 { ReportManager } from "./report/ReportManager";
|
||||||
import { ReportPoller } from "./report/ReportPoller";
|
import { ReportPoller } from "./report/ReportPoller";
|
||||||
import { WebAPIs } from "./webapis/WebAPIs";
|
import { WebAPIs } from "./webapis/WebAPIs";
|
||||||
import { replaceRoomIdsWithPills } from "./utils";
|
|
||||||
import RuleServer from "./models/RuleServer";
|
import RuleServer from "./models/RuleServer";
|
||||||
import { RoomMemberManager } from "./RoomMembers";
|
import { RoomMemberManager } from "./RoomMembers";
|
||||||
import { ProtectedRoomActivityTracker } from "./queues/ProtectedRoomActivityTracker";
|
import { ProtectedRoomActivityTracker } from "./queues/ProtectedRoomActivityTracker";
|
||||||
import { ThrottlingQueue } from "./queues/ThrottlingQueue";
|
import { ThrottlingQueue } from "./queues/ThrottlingQueue";
|
||||||
import { IConfig } from "./config";
|
import { IConfig } from "./config";
|
||||||
import PolicyList, { ListRuleChange } from "./models/PolicyList";
|
import PolicyList, { ListRuleChange } from "./models/PolicyList";
|
||||||
|
import { ProtectedRooms } from "./ProtectedRooms";
|
||||||
const levelToFn = {
|
import ManagementRoomOutput from "./ManagementRoom";
|
||||||
[LogLevel.DEBUG.toString()]: LogService.debug,
|
|
||||||
[LogLevel.INFO.toString()]: LogService.info,
|
|
||||||
[LogLevel.WARN.toString()]: LogService.warn,
|
|
||||||
[LogLevel.ERROR.toString()]: LogService.error,
|
|
||||||
};
|
|
||||||
|
|
||||||
export const STATE_NOT_STARTED = "not_started";
|
export const STATE_NOT_STARTED = "not_started";
|
||||||
export const STATE_CHECKING_PERMISSIONS = "checking_permissions";
|
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
|
* but have been flagged by the automatic spam detection as suispicous
|
||||||
*/
|
*/
|
||||||
private unlistedUserRedactionQueue = new UnlistedUserRedactionQueue();
|
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`.
|
* Every room that we are joined to except the management room. Used to implement `config.protectAllJoinedRooms`.
|
||||||
*/
|
*/
|
||||||
private protectedJoinedRoomIds: string[] = [];
|
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`.
|
* 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 explicitlyProtectedRoomIds: string[] = [];
|
||||||
private unprotectedWatchedListRooms: string[] = [];
|
private unprotectedWatchedListRooms: string[] = [];
|
||||||
private webapis: WebAPIs;
|
private webapis: WebAPIs;
|
||||||
private protectedRoomActivityTracker: ProtectedRoomActivityTracker;
|
|
||||||
public taskQueue: ThrottlingQueue;
|
public taskQueue: ThrottlingQueue;
|
||||||
/**
|
private managementRoom: ManagementRoomOutput;
|
||||||
* 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();
|
|
||||||
/*
|
/*
|
||||||
* Config-enabled polling of reports in Synapse, so Mjolnir can react to reports
|
* 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
|
// Setup Web APIs
|
||||||
console.log("Creating Web APIs");
|
console.log("Creating Web APIs");
|
||||||
const reportManager = new ReportManager(this);
|
const reportManager = new ReportManager(this);
|
||||||
@ -315,10 +292,6 @@ export class Mjolnir {
|
|||||||
return this.unlistedUserRedactionQueue;
|
return this.unlistedUserRedactionQueue;
|
||||||
}
|
}
|
||||||
|
|
||||||
public get automaticRedactGlobs(): MatrixGlob[] {
|
|
||||||
return this.automaticRedactionReasons;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Start Mjölnir.
|
* Start Mjölnir.
|
||||||
*/
|
*/
|
||||||
@ -339,7 +312,7 @@ export class Mjolnir {
|
|||||||
if (err.body?.errcode !== "M_NOT_FOUND") {
|
if (err.body?.errcode !== "M_NOT_FOUND") {
|
||||||
throw err;
|
throw err;
|
||||||
} else {
|
} 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);
|
this.reportPoller.start(reportPollSetting.from);
|
||||||
@ -348,7 +321,7 @@ export class Mjolnir {
|
|||||||
// Load the state.
|
// Load the state.
|
||||||
this.currentState = STATE_CHECKING_PERMISSIONS;
|
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);
|
await this.resyncJoinedRooms(false);
|
||||||
try {
|
try {
|
||||||
const data: { rooms?: string[] } | null = await this.client.getAccountData(PROTECTED_ROOMS_EVENT_TYPE);
|
const data: { rooms?: string[] } | null = await this.client.getAccountData(PROTECTED_ROOMS_EVENT_TYPE);
|
||||||
@ -366,25 +339,25 @@ export class Mjolnir {
|
|||||||
this.applyUnprotectedRooms();
|
this.applyUnprotectedRooms();
|
||||||
|
|
||||||
if (this.config.verifyPermissionsOnStartup) {
|
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);
|
await this.verifyPermissions(this.config.verboseLogging);
|
||||||
}
|
}
|
||||||
|
|
||||||
this.currentState = STATE_SYNCING;
|
this.currentState = STATE_SYNCING;
|
||||||
if (this.config.syncOnStartup) {
|
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.syncLists(this.config.verboseLogging);
|
||||||
await this.registerProtections();
|
await this.registerProtections();
|
||||||
}
|
}
|
||||||
|
|
||||||
this.currentState = STATE_RUNNING;
|
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) {
|
} catch (err) {
|
||||||
try {
|
try {
|
||||||
LogService.error("Mjolnir", "Error during startup:");
|
LogService.error("Mjolnir", "Error during startup:");
|
||||||
LogService.error("Mjolnir", extractRequestError(err));
|
LogService.error("Mjolnir", extractRequestError(err));
|
||||||
this.stop();
|
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;
|
throw err;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
LogService.error("Mjolnir", `Failed to report startup error to the management room: ${e}`);
|
LogService.error("Mjolnir", `Failed to report startup error to the management room: ${e}`);
|
||||||
@ -403,39 +376,9 @@ export class Mjolnir {
|
|||||||
this.reportPoller?.stop();
|
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) {
|
public async addProtectedRoom(roomId: string) {
|
||||||
this.protectedRooms[roomId] = Permalinks.forRoom(roomId);
|
this.protectedRooms[roomId] = Permalinks.forRoom(roomId);
|
||||||
this.roomJoins.addRoom(roomId);
|
this.roomJoins.addRoom(roomId);
|
||||||
this.protectedRoomActivityTracker.addProtectedRoom(roomId);
|
|
||||||
|
|
||||||
const unprotectedIdx = this.unprotectedWatchedListRooms.indexOf(roomId);
|
const unprotectedIdx = this.unprotectedWatchedListRooms.indexOf(roomId);
|
||||||
if (unprotectedIdx >= 0) this.unprotectedWatchedListRooms.splice(unprotectedIdx, 1);
|
if (unprotectedIdx >= 0) this.unprotectedWatchedListRooms.splice(unprotectedIdx, 1);
|
||||||
@ -450,13 +393,11 @@ export class Mjolnir {
|
|||||||
const rooms = (additionalProtectedRooms?.rooms ?? []);
|
const rooms = (additionalProtectedRooms?.rooms ?? []);
|
||||||
rooms.push(roomId);
|
rooms.push(roomId);
|
||||||
await this.client.setAccountData(PROTECTED_ROOMS_EVENT_TYPE, { rooms: rooms });
|
await this.client.setAccountData(PROTECTED_ROOMS_EVENT_TYPE, { rooms: rooms });
|
||||||
await this.syncLists(this.config.verboseLogging);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public async removeProtectedRoom(roomId: string) {
|
public async removeProtectedRoom(roomId: string) {
|
||||||
delete this.protectedRooms[roomId];
|
delete this.protectedRooms[roomId];
|
||||||
this.roomJoins.removeRoom(roomId);
|
this.roomJoins.removeRoom(roomId);
|
||||||
this.protectedRoomActivityTracker.removeProtectedRoom(roomId);
|
|
||||||
|
|
||||||
const idx = this.explicitlyProtectedRoomIds.indexOf(roomId);
|
const idx = this.explicitlyProtectedRoomIds.indexOf(roomId);
|
||||||
if (idx >= 0) this.explicitlyProtectedRoomIds.splice(idx, 1);
|
if (idx >= 0) this.explicitlyProtectedRoomIds.splice(idx, 1);
|
||||||
@ -471,27 +412,33 @@ export class Mjolnir {
|
|||||||
await this.client.setAccountData(PROTECTED_ROOMS_EVENT_TYPE, additionalProtectedRooms);
|
await this.client.setAccountData(PROTECTED_ROOMS_EVENT_TYPE, additionalProtectedRooms);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// need to brewritten to add/remove from a ProtectedRooms instance.
|
||||||
private async resyncJoinedRooms(withSync = true) {
|
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;
|
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 oldRoomIdsSet = new Set(this.protectedJoinedRoomIds);
|
||||||
const joinedRoomIdsSet = new Set(joinedRoomIds);
|
const joinedRoomIdsSet = new Set(joinedRoomIds);
|
||||||
// Remove every room id that we have joined from `this.protectedRooms`.
|
// find every room that we have left (since last time)
|
||||||
for (const roomId of this.protectedJoinedRoomIds) {
|
for (const roomId of oldRoomIdsSet.keys()) {
|
||||||
delete this.protectedRooms[roomId];
|
|
||||||
this.protectedRoomActivityTracker.removeProtectedRoom(roomId);
|
|
||||||
if (!joinedRoomIdsSet.has(roomId)) {
|
if (!joinedRoomIdsSet.has(roomId)) {
|
||||||
|
// Then we have left this room.
|
||||||
|
delete this.protectedRooms[roomId];
|
||||||
|
this.protectedRoomsTracker.removeProtectedRoom(roomId);
|
||||||
this.roomJoins.removeRoom(roomId);
|
this.roomJoins.removeRoom(roomId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
this.protectedJoinedRoomIds = joinedRoomIds;
|
// find every room that we have joined (since last time).
|
||||||
// Add all joined rooms back to the permalink object
|
for (const roomId of joinedRoomIdsSet.keys()) {
|
||||||
for (const roomId of joinedRoomIds) {
|
|
||||||
this.protectedRooms[roomId] = Permalinks.forRoom(roomId);
|
|
||||||
this.protectedRoomActivityTracker.addProtectedRoom(roomId);
|
|
||||||
if (!oldRoomIdsSet.has(roomId)) {
|
if (!oldRoomIdsSet.has(roomId)) {
|
||||||
|
// Then we have joined this room
|
||||||
this.roomJoins.addRoom(roomId);
|
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;
|
validatedSettings[key] = value;
|
||||||
} else {
|
} else {
|
||||||
await this.logMessage(
|
await this.managementRoom.logMessage(
|
||||||
LogLevel.WARN,
|
LogLevel.WARN,
|
||||||
"getProtectionSetting",
|
"getProtectionSetting",
|
||||||
`Tried to read ${protectionName}.${key} and got invalid value ${value}`
|
`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
|
// 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 });
|
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> {
|
private requiredProtectionPermissions(): Set<string> {
|
||||||
return new Set(this.enabledProtections.map((p) => p.requiredStatePermissions).flat())
|
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) {
|
private async handleConsequence(protection: Protection, roomId: string, eventId: string, sender: string, consequence: Consequence) {
|
||||||
switch (consequence.type) {
|
switch (consequence.type) {
|
||||||
case ConsequenceType.alert:
|
case ConsequenceType.alert:
|
||||||
@ -1039,111 +810,8 @@ export class Mjolnir {
|
|||||||
// can flag the event for redaction.
|
// can flag the event for redaction.
|
||||||
await this.unlistedUserRedactionHandler.handleEvent(roomId, event, this);
|
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> {
|
public async isSynapseAdmin(): Promise<boolean> {
|
||||||
@ -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 }) {
|
private async handleReport({ roomId, reporterId, event, reason }: { roomId: string, reporterId: string, event: any, reason?: string }) {
|
||||||
for (const protection of this.enabledProtections) {
|
for (const protection of this.enabledProtections) {
|
||||||
await protection.handleReport(this, roomId, reporterId, event, reason);
|
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
|
See the License for the specific language governing permissions and
|
||||||
limitations under the License.
|
limitations under the License.
|
||||||
*/
|
*/
|
||||||
import { LogLevel } from "matrix-bot-sdk"
|
import { LogLevel, MatrixClient } from "matrix-bot-sdk"
|
||||||
import { ERROR_KIND_FATAL } from "../ErrorCache";
|
import { ERROR_KIND_FATAL } from "../ErrorCache";
|
||||||
import { RoomUpdateError } from "../models/RoomUpdateError";
|
import { RoomUpdateError } from "../models/RoomUpdateError";
|
||||||
import { redactUserMessagesIn } from "../utils";
|
import { redactUserMessagesIn } from "../utils";
|
||||||
import { Mjolnir } from "../Mjolnir";
|
import { Mjolnir } from "../Mjolnir";
|
||||||
|
import ManagementRoomOutput from "../ManagementRoom";
|
||||||
|
|
||||||
export interface QueuedRedaction {
|
export interface QueuedRedaction {
|
||||||
/** The room which the redaction will take place in. */
|
/** The room which the redaction will take place in. */
|
||||||
@ -27,7 +28,7 @@ export interface QueuedRedaction {
|
|||||||
* Called by the EventRedactionQueue.
|
* Called by the EventRedactionQueue.
|
||||||
* @param client A MatrixClient to use to carry out the redaction.
|
* @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.
|
* 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.
|
* @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;
|
this.roomId = roomId;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async redact(mjolnir: Mjolnir) {
|
public async redact(client: MatrixClient, managementRoom: ManagementRoomOutput) {
|
||||||
await mjolnir.logMessage(LogLevel.DEBUG, "Mjolnir", `Redacting events from ${this.userId} in room ${this.roomId}.`);
|
await managementRoom.logMessage(LogLevel.DEBUG, "Mjolnir", `Redacting events from ${this.userId} in room ${this.roomId}.`);
|
||||||
await redactUserMessagesIn(mjolnir, this.userId, [this.roomId]);
|
await redactUserMessagesIn(client, managementRoom, this.userId, [this.roomId]);
|
||||||
}
|
}
|
||||||
|
|
||||||
public redactionEqual(redaction: QueuedRedaction): boolean {
|
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.
|
* @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.
|
* @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 errors: RoomUpdateError[] = [];
|
||||||
const redact = async (currentBatch: QueuedRedaction[]) => {
|
const redact = async (currentBatch: QueuedRedaction[]) => {
|
||||||
for (const redaction of currentBatch) {
|
for (const redaction of currentBatch) {
|
||||||
try {
|
try {
|
||||||
await redaction.redact(mjolnir);
|
await redaction.redact(client, managementRoom);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
let roomError: RoomUpdateError;
|
let roomError: RoomUpdateError;
|
||||||
if (e.roomId && e.errorMessage && e.errorKind) {
|
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 { Mjolnir } from "./Mjolnir";
|
||||||
import { ClientRequest, IncomingMessage } from "http";
|
import { ClientRequest, IncomingMessage } from "http";
|
||||||
import { default as parseDuration } from "parse-duration";
|
import { default as parseDuration } from "parse-duration";
|
||||||
|
import ManagementRoomOutput from "./ManagementRoom";
|
||||||
|
|
||||||
// Define a few aliases to simplify parsing durations.
|
// Define a few aliases to simplify parsing durations.
|
||||||
|
|
||||||
@ -70,17 +71,17 @@ export function isTrueJoinEvent(event: any): boolean {
|
|||||||
return membership === 'join' && prevMembership !== "join";
|
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) {
|
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) {
|
for (const victimEvent of eventsToRedact) {
|
||||||
await mjolnir.logMessage(LogLevel.DEBUG, "utils#redactUserMessagesIn", `Redacting ${victimEvent['event_id']} in ${targetRoomId}`, targetRoomId);
|
await managementRoom.logMessage(LogLevel.DEBUG, "utils#redactUserMessagesIn", `Redacting ${victimEvent['event_id']} in ${targetRoomId}`, targetRoomId);
|
||||||
if (!mjolnir.config.noop) {
|
if (!noop) {
|
||||||
await mjolnir.client.redactEvent(targetRoomId, victimEvent['event_id']);
|
await client.redactEvent(targetRoomId, victimEvent['event_id']);
|
||||||
} else {
|
} 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)
|
} 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;
|
let isMatrixClientPatchedForConciseExceptions = false;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
Loading…
Reference in New Issue
Block a user