Merge branch 'main' into gnuxie/replace-groups

This commit is contained in:
gnuxie 2022-08-09 11:30:59 +01:00
commit 2042d9ba4c
35 changed files with 780 additions and 579 deletions

View File

@ -27,19 +27,17 @@ import {
TextualMessageEventContent TextualMessageEventContent
} from "matrix-bot-sdk"; } from "matrix-bot-sdk";
import BanList, { ALL_RULE_TYPES as ALL_BAN_LIST_RULE_TYPES, ListRuleChange, RULE_ROOM, RULE_SERVER, RULE_USER } from "./models/BanList"; import { ALL_RULE_TYPES as ALL_BAN_LIST_RULE_TYPES, RULE_ROOM, RULE_SERVER, RULE_USER } from "./models/ListRule";
import { applyServerAcls } from "./actions/ApplyAcl"; import { applyServerAcls } from "./actions/ApplyAcl";
import { RoomUpdateError } from "./models/RoomUpdateError"; import { RoomUpdateError } from "./models/RoomUpdateError";
import { COMMAND_PREFIX, handleCommand } from "./commands/CommandHandler"; import { COMMAND_PREFIX, handleCommand } from "./commands/CommandHandler";
import { applyUserBans } from "./actions/ApplyBan"; import { applyUserBans } from "./actions/ApplyBan";
import config from "./config";
import ErrorCache, { ERROR_KIND_FATAL, ERROR_KIND_PERMISSION } from "./ErrorCache"; import ErrorCache, { ERROR_KIND_FATAL, ERROR_KIND_PERMISSION } from "./ErrorCache";
import { Protection } from "./protections/IProtection"; import { Protection } from "./protections/IProtection";
import { PROTECTIONS } from "./protections/protections"; import { PROTECTIONS } from "./protections/protections";
import { ConsequenceType, Consequence } from "./protections/consequence"; import { ConsequenceType, Consequence } from "./protections/consequence";
import { ProtectionSettingValidationError } from "./protections/ProtectionSettings"; import { ProtectionSettingValidationError } from "./protections/ProtectionSettings";
import { UnlistedUserRedactionQueue } from "./queues/UnlistedUserRedactionQueue"; import { UnlistedUserRedactionQueue } from "./queues/UnlistedUserRedactionQueue";
import { Healthz } from "./health/healthz";
import { EventRedactionQueue, RedactUserInRoom } from "./queues/EventRedactionQueue"; import { EventRedactionQueue, RedactUserInRoom } from "./queues/EventRedactionQueue";
import { htmlEscape } from "./utils"; import { htmlEscape } from "./utils";
import { ReportManager } from "./report/ReportManager"; import { ReportManager } from "./report/ReportManager";
@ -50,6 +48,8 @@ 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 PolicyList, { ListRuleChange } from "./models/PolicyList";
const levelToFn = { const levelToFn = {
[LogLevel.DEBUG.toString()]: LogService.debug, [LogLevel.DEBUG.toString()]: LogService.debug,
@ -103,6 +103,15 @@ export class Mjolnir {
private webapis: WebAPIs; private webapis: WebAPIs;
private protectedRoomActivityTracker: ProtectedRoomActivityTracker; private protectedRoomActivityTracker: ProtectedRoomActivityTracker;
public taskQueue: ThrottlingQueue; 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();
/* /*
* 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
*/ */
@ -161,8 +170,8 @@ export class Mjolnir {
* @param {MatrixClient} client The client for Mjolnir to use. * @param {MatrixClient} client The client for Mjolnir to use.
* @returns A new Mjolnir instance that can be started without further setup. * @returns A new Mjolnir instance that can be started without further setup.
*/ */
static async setupMjolnirFromConfig(client: MatrixClient): Promise<Mjolnir> { static async setupMjolnirFromConfig(client: MatrixClient, config: IConfig): Promise<Mjolnir> {
const banLists: BanList[] = []; const policyLists: PolicyList[] = [];
const protectedRooms: { [roomId: string]: string } = {}; const protectedRooms: { [roomId: string]: string } = {};
const joinedRooms = await client.getJoinedRooms(); const joinedRooms = await client.getJoinedRooms();
// Ensure we're also joined to the rooms we're protecting // Ensure we're also joined to the rooms we're protecting
@ -187,7 +196,7 @@ export class Mjolnir {
} }
const ruleServer = config.web.ruleServer ? new RuleServer() : null; const ruleServer = config.web.ruleServer ? new RuleServer() : null;
const mjolnir = new Mjolnir(client, managementRoomId, protectedRooms, banLists, ruleServer); const mjolnir = new Mjolnir(client, managementRoomId, config, protectedRooms, policyLists, ruleServer);
await mjolnir.logMessage(LogLevel.INFO, "index", "Mjolnir is starting up. Use !mjolnir to query status."); await mjolnir.logMessage(LogLevel.INFO, "index", "Mjolnir is starting up. Use !mjolnir to query status.");
Mjolnir.addJoinOnInviteListener(mjolnir, client, config); Mjolnir.addJoinOnInviteListener(mjolnir, client, config);
return mjolnir; return mjolnir;
@ -196,18 +205,19 @@ export class Mjolnir {
constructor( constructor(
public readonly client: MatrixClient, public readonly client: MatrixClient,
public readonly managementRoomId: string, public readonly managementRoomId: string,
public readonly config: IConfig,
/* /*
* All the rooms that Mjolnir is protecting and their permalinks. * All the rooms that Mjolnir is protecting and their permalinks.
* If `config.protectAllJoinedRooms` is specified, then `protectedRooms` will be all joined rooms except watched banlists that we can't protect (because they aren't curated by us). * If `config.protectAllJoinedRooms` is specified, then `protectedRooms` will be all joined rooms except watched banlists that we can't protect (because they aren't curated by us).
*/ */
public readonly protectedRooms: { [roomId: string]: string }, public readonly protectedRooms: { [roomId: string]: string },
private banLists: BanList[], private policyLists: PolicyList[],
// Combines the rules from ban lists so they can be served to a homeserver module or another consumer. // Combines the rules from ban lists so they can be served to a homeserver module or another consumer.
public readonly ruleServer: RuleServer|null, public readonly ruleServer: RuleServer | null,
) { ) {
this.explicitlyProtectedRoomIds = Object.keys(this.protectedRooms); this.explicitlyProtectedRoomIds = Object.keys(this.protectedRooms);
for (const reason of config.automaticallyRedactForReasons) { for (const reason of this.config.automaticallyRedactForReasons) {
this.automaticRedactionReasons.push(new MatrixGlob(reason.toLowerCase())); this.automaticRedactionReasons.push(new MatrixGlob(reason.toLowerCase()));
} }
@ -275,7 +285,7 @@ export class Mjolnir {
console.log("Creating Web APIs"); console.log("Creating Web APIs");
const reportManager = new ReportManager(this); const reportManager = new ReportManager(this);
reportManager.on("report.new", this.handleReport.bind(this)); reportManager.on("report.new", this.handleReport.bind(this));
this.webapis = new WebAPIs(reportManager, this.ruleServer); this.webapis = new WebAPIs(reportManager, this.config, this.ruleServer);
if (config.pollReports) { if (config.pollReports) {
this.reportPoller = new ReportPoller(this, reportManager); this.reportPoller = new ReportPoller(this, reportManager);
} }
@ -284,8 +294,8 @@ export class Mjolnir {
this.taskQueue = new ThrottlingQueue(this, config.backgroundDelayMS); this.taskQueue = new ThrottlingQueue(this, config.backgroundDelayMS);
} }
public get lists(): BanList[] { public get lists(): PolicyList[] {
return this.banLists; return this.policyLists;
} }
public get state(): string { public get state(): string {
@ -352,23 +362,22 @@ export class Mjolnir {
} catch (e) { } catch (e) {
LogService.warn("Mjolnir", extractRequestError(e)); LogService.warn("Mjolnir", extractRequestError(e));
} }
await this.buildWatchedBanLists(); await this.buildWatchedPolicyLists();
this.applyUnprotectedRooms(); this.applyUnprotectedRooms();
if (config.verifyPermissionsOnStartup) { if (this.config.verifyPermissionsOnStartup) {
await this.logMessage(LogLevel.INFO, "Mjolnir@startup", "Checking permissions..."); await this.logMessage(LogLevel.INFO, "Mjolnir@startup", "Checking permissions...");
await this.verifyPermissions(config.verboseLogging); await this.verifyPermissions(this.config.verboseLogging);
} }
this.currentState = STATE_SYNCING; this.currentState = STATE_SYNCING;
if (config.syncOnStartup) { if (this.config.syncOnStartup) {
await this.logMessage(LogLevel.INFO, "Mjolnir@startup", "Syncing lists..."); await this.logMessage(LogLevel.INFO, "Mjolnir@startup", "Syncing lists...");
await this.syncLists(config.verboseLogging); await this.syncLists(this.config.verboseLogging);
await this.registerProtections(); await this.registerProtections();
} }
this.currentState = STATE_RUNNING; this.currentState = STATE_RUNNING;
Healthz.isHealthy = true;
await this.logMessage(LogLevel.INFO, "Mjolnir@startup", "Startup complete. Now monitoring rooms."); await this.logMessage(LogLevel.INFO, "Mjolnir@startup", "Startup complete. Now monitoring rooms.");
} catch (err) { } catch (err) {
try { try {
@ -398,14 +407,13 @@ export class Mjolnir {
if (!additionalRoomIds) additionalRoomIds = []; if (!additionalRoomIds) additionalRoomIds = [];
if (!Array.isArray(additionalRoomIds)) additionalRoomIds = [additionalRoomIds]; if (!Array.isArray(additionalRoomIds)) additionalRoomIds = [additionalRoomIds];
if (config.RUNTIME.client && (config.verboseLogging || LogLevel.INFO.includes(level))) { if (this.config.verboseLogging || LogLevel.INFO.includes(level)) {
let clientMessage = message; let clientMessage = message;
if (level === LogLevel.WARN) clientMessage = `⚠ | ${message}`; if (level === LogLevel.WARN) clientMessage = `⚠ | ${message}`;
if (level === LogLevel.ERROR) clientMessage = `‼ | ${message}`; if (level === LogLevel.ERROR) clientMessage = `‼ | ${message}`;
const client = config.RUNTIME.client; const client = this.client;
const managementRoomId = await client.resolveRoom(config.managementRoom); const roomIds = [this.managementRoomId, ...additionalRoomIds];
const roomIds = [managementRoomId, ...additionalRoomIds];
let evContent: TextualMessageEventContent = { let evContent: TextualMessageEventContent = {
body: message, body: message,
@ -417,7 +425,7 @@ export class Mjolnir {
evContent = await replaceRoomIdsWithPills(this, clientMessage, new Set(roomIds), "m.notice"); evContent = await replaceRoomIdsWithPills(this, clientMessage, new Set(roomIds), "m.notice");
} }
await client.sendMessage(managementRoomId, evContent); await client.sendMessage(this.managementRoomId, evContent);
} }
levelToFn[level.toString()](module, message); levelToFn[level.toString()](module, message);
@ -442,7 +450,7 @@ 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(config.verboseLogging); await this.syncLists(this.config.verboseLogging);
} }
public async removeProtectedRoom(roomId: string) { public async removeProtectedRoom(roomId: string) {
@ -464,7 +472,7 @@ export class Mjolnir {
} }
private async resyncJoinedRooms(withSync = true) { private async resyncJoinedRooms(withSync = true) {
if (!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);
const oldRoomIdsSet = new Set(this.protectedJoinedRoomIds); const oldRoomIdsSet = new Set(this.protectedJoinedRoomIds);
@ -490,7 +498,7 @@ export class Mjolnir {
this.applyUnprotectedRooms(); this.applyUnprotectedRooms();
if (withSync) { if (withSync) {
await this.syncLists(config.verboseLogging); await this.syncLists(this.config.verboseLogging);
} }
} }
@ -563,12 +571,12 @@ export class Mjolnir {
const validatedSettings: { [setting: string]: any } = {} const validatedSettings: { [setting: string]: any } = {}
for (let [key, value] of Object.entries(savedSettings)) { for (let [key, value] of Object.entries(savedSettings)) {
if ( if (
// is this a setting name with a known parser? // is this a setting name with a known parser?
key in settingDefinitions key in settingDefinitions
// is the datatype of this setting's value what we expect? // is the datatype of this setting's value what we expect?
&& typeof(settingDefinitions[key].value) === typeof(value) && typeof (settingDefinitions[key].value) === typeof (value)
// is this setting's value valid for the setting? // is this setting's value valid for the setting?
&& settingDefinitions[key].validate(value) && settingDefinitions[key].validate(value)
) { ) {
validatedSettings[key] = value; validatedSettings[key] = value;
} else { } else {
@ -602,8 +610,8 @@ export class Mjolnir {
if (!(key in protection.settings)) { if (!(key in protection.settings)) {
throw new ProtectionSettingValidationError(`Failed to find protection setting by name: ${key}`); throw new ProtectionSettingValidationError(`Failed to find protection setting by name: ${key}`);
} }
if (typeof(protection.settings[key].value) !== typeof(value)) { if (typeof (protection.settings[key].value) !== typeof (value)) {
throw new ProtectionSettingValidationError(`Invalid type for protection setting: ${key} (${typeof(value)})`); throw new ProtectionSettingValidationError(`Invalid type for protection setting: ${key} (${typeof (value)})`);
} }
if (!protection.settings[key].validate(value)) { if (!protection.settings[key].validate(value)) {
throw new ProtectionSettingValidationError(`Invalid value for protection setting: ${key} (${value})`); throw new ProtectionSettingValidationError(`Invalid value for protection setting: ${key} (${value})`);
@ -653,16 +661,16 @@ export class Mjolnir {
} }
/** /**
* Helper for constructing `BanList`s and making sure they have the right listeners set up. * Helper for constructing `PolicyList`s and making sure they have the right listeners set up.
* @param roomId The room id for the `BanList`. * @param roomId The room id for the `PolicyList`.
* @param roomRef A reference (matrix.to URL) for the `BanList`. * @param roomRef A reference (matrix.to URL) for the `PolicyList`.
*/ */
private async addBanList(roomId: string, roomRef: string): Promise<BanList> { private async addPolicyList(roomId: string, roomRef: string): Promise<PolicyList> {
const list = new BanList(roomId, roomRef, this.client); const list = new PolicyList(roomId, roomRef, this.client);
this.ruleServer?.watch(list); this.ruleServer?.watch(list);
list.on('BanList.batch', this.syncWithBanList.bind(this)); list.on('PolicyList.batch', this.syncWithPolicyList.bind(this));
await list.updateList(); await list.updateList();
this.banLists.push(list); this.policyLists.push(list);
return list; return list;
} }
@ -676,7 +684,7 @@ export class Mjolnir {
return this.protections.get(protectionName) ?? null; return this.protections.get(protectionName) ?? null;
} }
public async watchList(roomRef: string): Promise<BanList | null> { public async watchList(roomRef: string): Promise<PolicyList | null> {
const joinedRooms = await this.client.getJoinedRooms(); const joinedRooms = await this.client.getJoinedRooms();
const permalink = Permalinks.parseUrl(roomRef); const permalink = Permalinks.parseUrl(roomRef);
if (!permalink.roomIdOrAlias) return null; if (!permalink.roomIdOrAlias) return null;
@ -686,38 +694,38 @@ export class Mjolnir {
await this.client.joinRoom(permalink.roomIdOrAlias, permalink.viaServers); await this.client.joinRoom(permalink.roomIdOrAlias, permalink.viaServers);
} }
if (this.banLists.find(b => b.roomId === roomId)) return null; if (this.policyLists.find(b => b.roomId === roomId)) return null;
const list = await this.addBanList(roomId, roomRef); const list = await this.addPolicyList(roomId, roomRef);
await this.client.setAccountData(WATCHED_LISTS_EVENT_TYPE, { await this.client.setAccountData(WATCHED_LISTS_EVENT_TYPE, {
references: this.banLists.map(b => b.roomRef), references: this.policyLists.map(b => b.roomRef),
}); });
await this.warnAboutUnprotectedBanListRoom(roomId); await this.warnAboutUnprotectedPolicyListRoom(roomId);
return list; return list;
} }
public async unwatchList(roomRef: string): Promise<BanList | null> { public async unwatchList(roomRef: string): Promise<PolicyList | null> {
const permalink = Permalinks.parseUrl(roomRef); const permalink = Permalinks.parseUrl(roomRef);
if (!permalink.roomIdOrAlias) return null; if (!permalink.roomIdOrAlias) return null;
const roomId = await this.client.resolveRoom(permalink.roomIdOrAlias); const roomId = await this.client.resolveRoom(permalink.roomIdOrAlias);
const list = this.banLists.find(b => b.roomId === roomId) || null; const list = this.policyLists.find(b => b.roomId === roomId) || null;
if (list) { if (list) {
this.banLists.splice(this.banLists.indexOf(list), 1); this.policyLists.splice(this.policyLists.indexOf(list), 1);
this.ruleServer?.unwatch(list); this.ruleServer?.unwatch(list);
} }
await this.client.setAccountData(WATCHED_LISTS_EVENT_TYPE, { await this.client.setAccountData(WATCHED_LISTS_EVENT_TYPE, {
references: this.banLists.map(b => b.roomRef), references: this.policyLists.map(b => b.roomRef),
}); });
return list; return list;
} }
public async warnAboutUnprotectedBanListRoom(roomId: string) { public async warnAboutUnprotectedPolicyListRoom(roomId: string) {
if (!config.protectAllJoinedRooms) return; // doesn't matter if (!this.config.protectAllJoinedRooms) return; // doesn't matter
if (this.explicitlyProtectedRoomIds.includes(roomId)) return; // explicitly protected if (this.explicitlyProtectedRoomIds.includes(roomId)) return; // explicitly protected
const createEvent = new CreateEvent(await this.client.getRoomStateEvent(roomId, "m.room.create", "")); const createEvent = new CreateEvent(await this.client.getRoomStateEvent(roomId, "m.room.create", ""));
@ -744,8 +752,8 @@ export class Mjolnir {
} }
} }
private async buildWatchedBanLists() { private async buildWatchedPolicyLists() {
this.banLists = []; this.policyLists = [];
const joinedRooms = await this.client.getJoinedRooms(); const joinedRooms = await this.client.getJoinedRooms();
let watchedListsEvent: { references?: string[] } | null = null; let watchedListsEvent: { references?: string[] } | null = null;
@ -764,8 +772,8 @@ export class Mjolnir {
await this.client.joinRoom(permalink.roomIdOrAlias, permalink.viaServers); await this.client.joinRoom(permalink.roomIdOrAlias, permalink.viaServers);
} }
await this.warnAboutUnprotectedBanListRoom(roomId); await this.warnAboutUnprotectedPolicyListRoom(roomId);
await this.addBanList(roomId, roomRef); await this.addPolicyList(roomId, roomRef);
} }
} }
@ -891,15 +899,15 @@ export class Mjolnir {
* @param verbose Whether to report any errors to the management room. * @param verbose Whether to report any errors to the management room.
*/ */
public async syncLists(verbose = true) { public async syncLists(verbose = true) {
for (const list of this.banLists) { for (const list of this.policyLists) {
const changes = await list.updateList(); const changes = await list.updateList();
await this.printBanlistChanges(changes, list, true); await this.printBanlistChanges(changes, list, true);
} }
let hadErrors = false; let hadErrors = false;
const [aclErrors, banErrors] = await Promise.all([ const [aclErrors, banErrors] = await Promise.all([
applyServerAcls(this.banLists, this.protectedRoomsByActivity(), this), applyServerAcls(this.policyLists, this.protectedRoomsByActivity(), this),
applyUserBans(this.banLists, this.protectedRoomsByActivity(), this) applyUserBans(this.policyLists, this.protectedRoomsByActivity(), this)
]); ]);
const redactionErrors = await this.processRedactionQueue(); const redactionErrors = await this.processRedactionQueue();
hadErrors = hadErrors || await this.printActionResult(aclErrors, "Errors updating server ACLs:"); hadErrors = hadErrors || await this.printActionResult(aclErrors, "Errors updating server ACLs:");
@ -921,16 +929,16 @@ export class Mjolnir {
/** /**
* Pulls any changes to the rules that are in a policy room and updates all protected rooms * 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. * with those changes. Does not fail if there are errors updating the room, these are reported to the management room.
* @param banList The `BanList` which we will check for changes and apply them to all protected rooms. * @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. * @returns When all of the protected rooms have been updated.
*/ */
private async syncWithBanList(banList: BanList): Promise<void> { private async syncWithPolicyList(policyList: PolicyList): Promise<void> {
const changes = await banList.updateList(); const changes = await policyList.updateList();
let hadErrors = false; let hadErrors = false;
const [aclErrors, banErrors] = await Promise.all([ const [aclErrors, banErrors] = await Promise.all([
applyServerAcls(this.banLists, this.protectedRoomsByActivity(), this), applyServerAcls(this.policyLists, this.protectedRoomsByActivity(), this),
applyUserBans(this.banLists, this.protectedRoomsByActivity(), this) applyUserBans(this.policyLists, this.protectedRoomsByActivity(), this)
]); ]);
const redactionErrors = await this.processRedactionQueue(); const redactionErrors = await this.processRedactionQueue();
hadErrors = hadErrors || await this.printActionResult(aclErrors, "Errors updating server ACLs:"); hadErrors = hadErrors || await this.printActionResult(aclErrors, "Errors updating server ACLs:");
@ -948,7 +956,7 @@ export class Mjolnir {
}); });
} }
// This can fail if the change is very large and it is much less important than applying bans, so do it last. // 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, banList, true); 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) {
@ -998,10 +1006,10 @@ export class Mjolnir {
// Check for updated ban lists before checking protected rooms - the ban lists might be protected // Check for updated ban lists before checking protected rooms - the ban lists might be protected
// themselves. // themselves.
const banList = this.banLists.find(list => list.roomId === roomId); const policyList = this.policyLists.find(list => list.roomId === roomId);
if (banList !== undefined) { if (policyList !== undefined) {
if (ALL_BAN_LIST_RULE_TYPES.includes(event['type']) || event['type'] === 'm.room.redaction') { if (ALL_BAN_LIST_RULE_TYPES.includes(event['type']) || event['type'] === 'm.room.redaction') {
banList.updateForEvent(event) policyList.updateForEvent(event)
} }
} }
@ -1046,7 +1054,7 @@ export class Mjolnir {
// we cannot eagerly ban users (that is to ban them when they have never been a member) // 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. // 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. // Only apply bans and then redactions in the room we are currently looking at.
const banErrors = await applyUserBans(this.banLists, [roomId], this); const banErrors = await applyUserBans(this.policyLists, [roomId], this);
const redactionErrors = await this.processRedactionQueue(roomId); const redactionErrors = await this.processRedactionQueue(roomId);
await this.printActionResult(banErrors); await this.printActionResult(banErrors);
await this.printActionResult(redactionErrors); await this.printActionResult(redactionErrors);
@ -1060,7 +1068,7 @@ export class Mjolnir {
* @param ignoreSelf Whether to exclude changes that have been made by Mjolnir. * @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). * @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: BanList, ignoreSelf = false): Promise<boolean> { private async printBanlistChanges(changes: ListRuleChange[], list: PolicyList, ignoreSelf = false): Promise<boolean> {
if (ignoreSelf) { if (ignoreSelf) {
const sender = await this.client.getUserId(); const sender = await this.client.getUserId();
changes = changes.filter(change => change.sender !== sender); changes = changes.filter(change => change.sender !== sender);

View File

@ -14,11 +14,10 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import BanList from "../models/BanList"; import PolicyList from "../models/PolicyList";
import { ServerAcl } from "../models/ServerAcl"; import { ServerAcl } from "../models/ServerAcl";
import { RoomUpdateError } from "../models/RoomUpdateError"; import { RoomUpdateError } from "../models/RoomUpdateError";
import { Mjolnir } from "../Mjolnir"; import { Mjolnir } from "../Mjolnir";
import config from "../config";
import { LogLevel, UserID } from "matrix-bot-sdk"; import { LogLevel, UserID } from "matrix-bot-sdk";
import { ERROR_KIND_FATAL, ERROR_KIND_PERMISSION } from "../ErrorCache"; import { ERROR_KIND_FATAL, ERROR_KIND_PERMISSION } from "../ErrorCache";
@ -26,11 +25,21 @@ import { ERROR_KIND_FATAL, ERROR_KIND_PERMISSION } from "../ErrorCache";
* Applies the server ACLs represented by the ban lists to the provided rooms, returning the * 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. * room IDs that could not be updated and their error.
* Does not update the banLists before taking their rules to build the server ACL. * Does not update the banLists before taking their rules to build the server ACL.
* @param {BanList[]} lists The lists to construct ACLs from. * @param {PolicyList[]} lists The lists to construct ACLs from.
* @param {string[]} roomIds The room IDs to apply the ACLs in. * @param {string[]} roomIds The room IDs to apply the ACLs in.
* @param {Mjolnir} mjolnir The Mjolnir client to apply the ACLs with. * @param {Mjolnir} mjolnir The Mjolnir client to apply the ACLs with.
*/ */
export async function applyServerAcls(lists: BanList[], roomIds: string[], mjolnir: Mjolnir): Promise<RoomUpdateError[]> { 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; const serverName: string = new UserID(await mjolnir.client.getUserId()).domain;
// Construct a server ACL first // Construct a server ACL first
@ -47,7 +56,7 @@ export async function applyServerAcls(lists: BanList[], roomIds: string[], mjoln
mjolnir.logMessage(LogLevel.WARN, "ApplyAcl", `Mjölnir has detected and removed an ACL that would exclude itself. Please check the ACL lists.`); mjolnir.logMessage(LogLevel.WARN, "ApplyAcl", `Mjölnir has detected and removed an ACL that would exclude itself. Please check the ACL lists.`);
} }
if (config.verboseLogging) { if (mjolnir.config.verboseLogging) {
// We specifically use sendNotice to avoid having to escape HTML // 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)}`); await mjolnir.client.sendNotice(mjolnir.managementRoomId, `Constructed server ACL:\n${JSON.stringify(finalAcl, null, 2)}`);
} }
@ -70,7 +79,7 @@ export async function applyServerAcls(lists: BanList[], roomIds: string[], mjoln
// We specifically use sendNotice to avoid having to escape HTML // We specifically use sendNotice to avoid having to escape HTML
await mjolnir.logMessage(LogLevel.DEBUG, "ApplyAcl", `Applying ACL in ${roomId}`, roomId); await mjolnir.logMessage(LogLevel.DEBUG, "ApplyAcl", `Applying ACL in ${roomId}`, roomId);
if (!config.noop) { if (!mjolnir.config.noop) {
await mjolnir.client.sendStateEvent(roomId, "m.room.server_acl", "", finalAcl); await mjolnir.client.sendStateEvent(roomId, "m.room.server_acl", "", finalAcl);
} else { } else {
await mjolnir.logMessage(LogLevel.WARN, "ApplyAcl", `Tried to apply ACL in ${roomId} but Mjolnir is running in no-op mode`, roomId); await mjolnir.logMessage(LogLevel.WARN, "ApplyAcl", `Tried to apply ACL in ${roomId} but Mjolnir is running in no-op mode`, roomId);
@ -78,7 +87,7 @@ export async function applyServerAcls(lists: BanList[], roomIds: string[], mjoln
} catch (e) { } catch (e) {
const message = e.message || (e.body ? e.body.error : '<no message>'); 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; 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}); errors.push({ roomId, errorMessage: message, errorKind: kind });
} }
} }

View File

@ -14,21 +14,20 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import BanList from "../models/BanList"; import PolicyList from "../models/PolicyList";
import { RoomUpdateError } from "../models/RoomUpdateError"; import { RoomUpdateError } from "../models/RoomUpdateError";
import { Mjolnir } from "../Mjolnir"; import { Mjolnir } from "../Mjolnir";
import config from "../config";
import { LogLevel } from "matrix-bot-sdk"; import { LogLevel } from "matrix-bot-sdk";
import { ERROR_KIND_FATAL, ERROR_KIND_PERMISSION } from "../ErrorCache"; import { ERROR_KIND_FATAL, ERROR_KIND_PERMISSION } from "../ErrorCache";
/** /**
* Applies the member bans represented by the ban lists to the provided rooms, returning the * 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. * room IDs that could not be updated and their error.
* @param {BanList[]} lists The lists to determine bans from. * @param {PolicyList[]} lists The lists to determine bans from.
* @param {string[]} roomIds The room IDs to apply the bans in. * @param {string[]} roomIds The room IDs to apply the bans in.
* @param {Mjolnir} mjolnir The Mjolnir client to apply the bans with. * @param {Mjolnir} mjolnir The Mjolnir client to apply the bans with.
*/ */
export async function applyUserBans(lists: BanList[], roomIds: string[], mjolnir: Mjolnir): Promise<RoomUpdateError[]> { 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. // We can only ban people who are not already banned, and who match the rules.
const errors: RoomUpdateError[] = []; const errors: RoomUpdateError[] = [];
for (const roomId of roomIds) { for (const roomId of roomIds) {
@ -38,15 +37,15 @@ export async function applyUserBans(lists: BanList[], roomIds: string[], mjolnir
let members: { userId: string, membership: string }[]; let members: { userId: string, membership: string }[];
if (config.fasterMembershipChecks) { if (mjolnir.config.fasterMembershipChecks) {
const memberIds = await mjolnir.client.getJoinedRoomMembers(roomId); const memberIds = await mjolnir.client.getJoinedRoomMembers(roomId);
members = memberIds.map(u => { members = memberIds.map(u => {
return {userId: u, membership: "join"}; return { userId: u, membership: "join" };
}); });
} else { } else {
const state = await mjolnir.client.getRoomState(roomId); const state = await mjolnir.client.getRoomState(roomId);
members = state.filter(s => s['type'] === 'm.room.member' && !!s['state_key']).map(s => { 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'}; return { userId: s['state_key'], membership: s['content'] ? s['content']['membership'] : 'leave' };
}); });
} }
@ -64,7 +63,7 @@ export async function applyUserBans(lists: BanList[], roomIds: string[], mjolnir
// We specifically use sendNotice to avoid having to escape HTML // 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); await mjolnir.logMessage(LogLevel.INFO, "ApplyBan", `Banning ${member.userId} in ${roomId} for: ${userRule.reason}`, roomId);
if (!config.noop) { if (!mjolnir.config.noop) {
await mjolnir.client.banUser(member.userId, roomId, userRule.reason); await mjolnir.client.banUser(member.userId, roomId, userRule.reason);
if (mjolnir.automaticRedactGlobs.find(g => g.test(userRule.reason.toLowerCase()))) { if (mjolnir.automaticRedactGlobs.find(g => g.test(userRule.reason.toLowerCase()))) {
mjolnir.queueRedactUserMessagesIn(member.userId, roomId); mjolnir.queueRedactUserMessagesIn(member.userId, roomId);

View File

@ -28,8 +28,10 @@ import { execRedactCommand } from "./RedactCommand";
import { execImportCommand } from "./ImportCommand"; import { execImportCommand } from "./ImportCommand";
import { execSetDefaultListCommand } from "./SetDefaultBanListCommand"; import { execSetDefaultListCommand } from "./SetDefaultBanListCommand";
import { execDeactivateCommand } from "./DeactivateCommand"; import { execDeactivateCommand } from "./DeactivateCommand";
import { execDisableProtection, execEnableProtection, execListProtections, execConfigGetProtection, import {
execConfigSetProtection, execConfigAddProtection, execConfigRemoveProtection } from "./ProtectionsCommands"; execDisableProtection, execEnableProtection, execListProtections, execConfigGetProtection,
execConfigSetProtection, execConfigAddProtection, execConfigRemoveProtection
} from "./ProtectionsCommands";
import { execListProtectedRooms } from "./ListProtectedRoomsCommand"; import { execListProtectedRooms } from "./ListProtectedRoomsCommand";
import { execAddProtectedRoom, execRemoveProtectedRoom } from "./AddRemoveProtectedRoomsCommand"; import { execAddProtectedRoom, execRemoveProtectedRoom } from "./AddRemoveProtectedRoomsCommand";
import { execAddRoomToDirectoryCommand, execRemoveRoomFromDirectoryCommand } from "./AddRemoveRoomFromDirectoryCommand"; import { execAddRoomToDirectoryCommand, execRemoveRoomFromDirectoryCommand } from "./AddRemoveRoomFromDirectoryCommand";

View File

@ -15,7 +15,7 @@ limitations under the License.
*/ */
import { Mjolnir } from "../Mjolnir"; import { Mjolnir } from "../Mjolnir";
import { SHORTCODE_EVENT_TYPE } from "../models/BanList"; import { SHORTCODE_EVENT_TYPE } from "../models/PolicyList";
import { Permalinks, RichReply } from "matrix-bot-sdk"; import { Permalinks, RichReply } from "matrix-bot-sdk";
// !mjolnir list create <shortcode> <alias localpart> // !mjolnir list create <shortcode> <alias localpart>
@ -48,7 +48,7 @@ export async function execCreateListCommand(roomId: string, event: any, mjolnir:
preset: "public_chat", preset: "public_chat",
room_alias_name: aliasLocalpart, room_alias_name: aliasLocalpart,
invite: [event['sender']], invite: [event['sender']],
initial_state: [{type: SHORTCODE_EVENT_TYPE, state_key: "", content: {shortcode: shortcode}}], initial_state: [{ type: SHORTCODE_EVENT_TYPE, state_key: "", content: { shortcode: shortcode } }],
power_level_content_override: powerLevels, power_level_content_override: powerLevels,
}); });

View File

@ -16,7 +16,7 @@ limitations under the License.
import { RichReply } from "matrix-bot-sdk"; import { RichReply } from "matrix-bot-sdk";
import { Mjolnir } from "../Mjolnir"; import { Mjolnir } from "../Mjolnir";
import { RULE_ROOM, RULE_SERVER, RULE_USER } from "../models/BanList"; import { EntityType } from "../models/ListRule";
import { htmlEscape } from "../utils"; import { htmlEscape } from "../utils";
/** /**
@ -33,7 +33,7 @@ export async function execRulesMatchingCommand(roomId: string, event: any, mjoln
let html = ""; let html = "";
let text = ""; let text = "";
for (const list of mjolnir.lists) { for (const list of mjolnir.lists) {
const matches = list.rulesMatchingEntity(entity) const matches = list.rulesMatchingEntity(entity)
if (matches.length === 0) { if (matches.length === 0) {
continue; continue;
@ -48,12 +48,16 @@ export async function execRulesMatchingCommand(roomId: string, event: any, mjoln
for (const rule of matches) { for (const rule of matches) {
// If we know the rule kind, we will give it a readable name, otherwise just use its name. // If we know the rule kind, we will give it a readable name, otherwise just use its name.
let ruleKind: string = rule.kind; let ruleKind: string = rule.kind;
if (ruleKind === RULE_USER) { switch (ruleKind) {
ruleKind = 'user'; case EntityType.RULE_USER:
} else if (ruleKind === RULE_SERVER) { ruleKind = 'user';
ruleKind = 'server'; break;
} else if (ruleKind === RULE_ROOM) { case EntityType.RULE_SERVER:
ruleKind = 'room'; ruleKind = 'server';
break;
case EntityType.RULE_ROOM:
ruleKind = 'room';
break;
} }
html += `<li>${htmlEscape(ruleKind)} (<code>${htmlEscape(rule.recommendation ?? "")}</code>): <code>${htmlEscape(rule.entity)}</code> (${htmlEscape(rule.reason)})</li>`; html += `<li>${htmlEscape(ruleKind)} (<code>${htmlEscape(rule.recommendation ?? "")}</code>): <code>${htmlEscape(rule.entity)}</code> (${htmlEscape(rule.reason)})</li>`;
text += `* ${ruleKind} (${rule.recommendation}): ${rule.entity} (${rule.reason})\n`; text += `* ${ruleKind} (${rule.recommendation}): ${rule.entity} (${rule.reason})\n`;

View File

@ -16,8 +16,7 @@ limitations under the License.
import { Mjolnir } from "../Mjolnir"; import { Mjolnir } from "../Mjolnir";
import { RichReply } from "matrix-bot-sdk"; import { RichReply } from "matrix-bot-sdk";
import { RECOMMENDATION_BAN, recommendationToStable } from "../models/ListRule"; import { EntityType, Recommendation } from "../models/ListRule";
import { RULE_SERVER, RULE_USER, ruleTypeToStable } from "../models/BanList";
// !mjolnir import <room ID> <shortcode> // !mjolnir import <room ID> <shortcode>
export async function execImportCommand(roomId: string, event: any, mjolnir: Mjolnir, parts: string[]) { export async function execImportCommand(roomId: string, event: any, mjolnir: Mjolnir, parts: string[]) {
@ -45,14 +44,13 @@ export async function execImportCommand(roomId: string, event: any, mjolnir: Mjo
await mjolnir.client.sendNotice(mjolnir.managementRoomId, `Adding user ${stateEvent['state_key']} to ban list`); await mjolnir.client.sendNotice(mjolnir.managementRoomId, `Adding user ${stateEvent['state_key']} to ban list`);
const recommendation = recommendationToStable(RECOMMENDATION_BAN);
const ruleContent = { const ruleContent = {
entity: stateEvent['state_key'], entity: stateEvent['state_key'],
recommendation, recommendation: Recommendation.Ban,
reason: reason, reason: reason,
}; };
const stateKey = `rule:${ruleContent.entity}`; const stateKey = `rule:${ruleContent.entity}`;
let stableRule = ruleTypeToStable(RULE_USER); let stableRule = EntityType.RULE_USER;
if (stableRule) { if (stableRule) {
await mjolnir.client.sendStateEvent(list.roomId, stableRule, stateKey, ruleContent); await mjolnir.client.sendStateEvent(list.roomId, stableRule, stateKey, ruleContent);
} }
@ -66,14 +64,13 @@ export async function execImportCommand(roomId: string, event: any, mjolnir: Mjo
await mjolnir.client.sendNotice(mjolnir.managementRoomId, `Adding server ${server} to ban list`); await mjolnir.client.sendNotice(mjolnir.managementRoomId, `Adding server ${server} to ban list`);
const recommendation = recommendationToStable(RECOMMENDATION_BAN);
const ruleContent = { const ruleContent = {
entity: server, entity: server,
recommendation, recommendation: Recommendation.Ban,
reason: reason, reason: reason,
}; };
const stateKey = `rule:${ruleContent.entity}`; const stateKey = `rule:${ruleContent.entity}`;
let stableRule = ruleTypeToStable(RULE_SERVER); let stableRule = EntityType.RULE_SERVER;
if (stableRule) { if (stableRule) {
await mjolnir.client.sendStateEvent(list.roomId, stableRule, stateKey, ruleContent); await mjolnir.client.sendStateEvent(list.roomId, stableRule, stateKey, ruleContent);
} }

View File

@ -16,7 +16,6 @@ limitations under the License.
import { Mjolnir } from "../Mjolnir"; import { Mjolnir } from "../Mjolnir";
import { LogLevel, MatrixGlob, RichReply } from "matrix-bot-sdk"; import { LogLevel, MatrixGlob, RichReply } from "matrix-bot-sdk";
import config from "../config";
// !mjolnir kick <user|filter> [room] [reason] // !mjolnir kick <user|filter> [room] [reason]
export async function execKickCommand(roomId: string, event: any, mjolnir: Mjolnir, parts: string[]) { export async function execKickCommand(roomId: string, event: any, mjolnir: Mjolnir, parts: string[]) {
@ -30,7 +29,7 @@ export async function execKickCommand(roomId: string, event: any, mjolnir: Mjoln
parts.pop(); parts.pop();
} }
if (config.commands.confirmWildcardBan && /[*?]/.test(glob) && !force) { if (mjolnir.config.commands.confirmWildcardBan && /[*?]/.test(glob) && !force) {
let replyMessage = "Wildcard bans require an addition `--force` argument to confirm"; let replyMessage = "Wildcard bans require an addition `--force` argument to confirm";
const reply = RichReply.createFor(roomId, event, replyMessage, replyMessage); const reply = RichReply.createFor(roomId, event, replyMessage, replyMessage);
reply["msgtype"] = "m.notice"; reply["msgtype"] = "m.notice";
@ -60,7 +59,7 @@ export async function execKickCommand(roomId: string, event: any, mjolnir: Mjoln
if (kickRule.test(victim)) { if (kickRule.test(victim)) {
await mjolnir.logMessage(LogLevel.DEBUG, "KickCommand", `Removing ${victim} in ${protectedRoomId}`, protectedRoomId); await mjolnir.logMessage(LogLevel.DEBUG, "KickCommand", `Removing ${victim} in ${protectedRoomId}`, protectedRoomId);
if (!config.noop) { if (!mjolnir.config.noop) {
try { try {
await mjolnir.taskQueue.push(async () => { await mjolnir.taskQueue.push(async () => {
return mjolnir.client.kickUser(victim, protectedRoomId, reason); return mjolnir.client.kickUser(victim, protectedRoomId, reason);

View File

@ -14,14 +14,13 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import config from "../config";
import { Mjolnir } from "../Mjolnir"; import { Mjolnir } from "../Mjolnir";
import { RichReply } from "matrix-bot-sdk"; import { RichReply } from "matrix-bot-sdk";
// !mjolnir make admin <room> [<user ID>] // !mjolnir make admin <room> [<user ID>]
export async function execMakeRoomAdminCommand(roomId: string, event: any, mjolnir: Mjolnir, parts: string[]) { export async function execMakeRoomAdminCommand(roomId: string, event: any, mjolnir: Mjolnir, parts: string[]) {
const isAdmin = await mjolnir.isSynapseAdmin(); const isAdmin = await mjolnir.isSynapseAdmin();
if (!config.admin?.enableMakeRoomAdminCommand || !isAdmin) { if (!mjolnir.config.admin?.enableMakeRoomAdminCommand || !isAdmin) {
const message = "Either the command is disabled or I am not running as homeserver administrator."; const message = "Either the command is disabled or I am not running as homeserver administrator.";
const reply = RichReply.createFor(roomId, event, message, message); const reply = RichReply.createFor(roomId, event, message, message);
reply['msgtype'] = "m.notice"; reply['msgtype'] = "m.notice";

View File

@ -31,6 +31,6 @@ export async function execSetDefaultListCommand(roomId: string, event: any, mjol
return; return;
} }
await mjolnir.client.setAccountData(DEFAULT_LIST_EVENT_TYPE, {shortcode}); await mjolnir.client.setAccountData(DEFAULT_LIST_EVENT_TYPE, { shortcode });
await mjolnir.client.unstableApis.addReactionToEvent(roomId, event['event_id'], '✅'); await mjolnir.client.unstableApis.addReactionToEvent(roomId, event['event_id'], '✅');
} }

View File

@ -15,21 +15,20 @@ limitations under the License.
*/ */
import { Mjolnir } from "../Mjolnir"; import { Mjolnir } from "../Mjolnir";
import BanList, { RULE_ROOM, RULE_SERVER, RULE_USER, USER_RULE_TYPES } from "../models/BanList"; import PolicyList from "../models/PolicyList";
import { extractRequestError, LogLevel, LogService, MatrixGlob, RichReply } from "matrix-bot-sdk"; import { extractRequestError, LogLevel, LogService, MatrixGlob, RichReply } from "matrix-bot-sdk";
import { RECOMMENDATION_BAN, recommendationToStable } from "../models/ListRule"; import { Recommendation, RULE_ROOM, RULE_SERVER, RULE_USER, USER_RULE_TYPES } from "../models/ListRule";
import config from "../config";
import { DEFAULT_LIST_EVENT_TYPE } from "./SetDefaultBanListCommand"; import { DEFAULT_LIST_EVENT_TYPE } from "./SetDefaultBanListCommand";
interface Arguments { interface Arguments {
list: BanList | null; list: PolicyList | null;
entity: string; entity: string;
ruleType: string | null; ruleType: string | null;
reason: string; reason: string;
} }
// Exported for tests // Exported for tests
export async function parseArguments(roomId: string, event: any, mjolnir: Mjolnir, parts: string[]): Promise<Arguments|null> { export async function parseArguments(roomId: string, event: any, mjolnir: Mjolnir, parts: string[]): Promise<Arguments | null> {
let defaultShortcode: string | null = null; let defaultShortcode: string | null = null;
try { try {
const data: { shortcode: string } = await mjolnir.client.getAccountData(DEFAULT_LIST_EVENT_TYPE); const data: { shortcode: string } = await mjolnir.client.getAccountData(DEFAULT_LIST_EVENT_TYPE);
@ -44,7 +43,7 @@ export async function parseArguments(roomId: string, event: any, mjolnir: Mjolni
let argumentIndex = 2; let argumentIndex = 2;
let ruleType: string | null = null; let ruleType: string | null = null;
let entity: string | null = null; let entity: string | null = null;
let list: BanList | null = null; let list: PolicyList | null = null;
let force = false; let force = false;
while (argumentIndex < 7 && argumentIndex < parts.length) { while (argumentIndex < 7 && argumentIndex < parts.length) {
const arg = parts[argumentIndex++]; const arg = parts[argumentIndex++];
@ -95,7 +94,7 @@ export async function parseArguments(roomId: string, event: any, mjolnir: Mjolni
else if (!ruleType) replyMessage = "Please specify the type as either 'user', 'room', or 'server'"; else if (!ruleType) replyMessage = "Please specify the type as either 'user', 'room', or 'server'";
else if (!entity) replyMessage = "No entity found"; else if (!entity) replyMessage = "No entity found";
if (config.commands.confirmWildcardBan && /[*?]/.test(entity) && !force) { if (mjolnir.config.commands.confirmWildcardBan && /[*?]/.test(entity) && !force) {
replyMessage = "Wildcard bans require an additional `--force` argument to confirm"; replyMessage = "Wildcard bans require an additional `--force` argument to confirm";
} }
@ -119,10 +118,9 @@ export async function execBanCommand(roomId: string, event: any, mjolnir: Mjolni
const bits = await parseArguments(roomId, event, mjolnir, parts); const bits = await parseArguments(roomId, event, mjolnir, parts);
if (!bits) return; // error already handled if (!bits) return; // error already handled
const recommendation = recommendationToStable(RECOMMENDATION_BAN);
const ruleContent = { const ruleContent = {
entity: bits.entity, entity: bits.entity,
recommendation, recommendation: Recommendation.Ban,
reason: bits.reason || '<no reason supplied>', reason: bits.reason || '<no reason supplied>',
}; };
const stateKey = `rule:${bits.entity}`; const stateKey = `rule:${bits.entity}`;
@ -151,7 +149,7 @@ export async function execUnbanCommand(roomId: string, event: any, mjolnir: Mjol
if (rule.test(victim)) { if (rule.test(victim)) {
await mjolnir.logMessage(LogLevel.DEBUG, "UnbanBanCommand", `Unbanning ${victim} in ${protectedRoomId}`, protectedRoomId); await mjolnir.logMessage(LogLevel.DEBUG, "UnbanBanCommand", `Unbanning ${victim} in ${protectedRoomId}`, protectedRoomId);
if (!config.noop) { if (!mjolnir.config.noop) {
await mjolnir.client.unbanUser(victim, protectedRoomId); await mjolnir.client.unbanUser(victim, protectedRoomId);
} else { } else {
await mjolnir.logMessage(LogLevel.WARN, "UnbanBanCommand", `Attempted to unban ${victim} in ${protectedRoomId} but Mjolnir is running in no-op mode`, protectedRoomId); await mjolnir.logMessage(LogLevel.WARN, "UnbanBanCommand", `Attempted to unban ${victim} in ${protectedRoomId} but Mjolnir is running in no-op mode`, protectedRoomId);
@ -164,7 +162,7 @@ export async function execUnbanCommand(roomId: string, event: any, mjolnir: Mjol
if (unbannedSomeone) { if (unbannedSomeone) {
await mjolnir.logMessage(LogLevel.DEBUG, "UnbanBanCommand", `Syncing lists to ensure no users were accidentally unbanned`); await mjolnir.logMessage(LogLevel.DEBUG, "UnbanBanCommand", `Syncing lists to ensure no users were accidentally unbanned`);
await mjolnir.syncLists(config.verboseLogging); await mjolnir.syncLists(mjolnir.config.verboseLogging);
} }
} }

View File

@ -25,7 +25,7 @@ import { MatrixClient } from "matrix-bot-sdk";
// The object is magically generated by external lib `config` // The object is magically generated by external lib `config`
// from the file specified by `NODE_ENV`, e.g. production.yaml // from the file specified by `NODE_ENV`, e.g. production.yaml
// or harness.yaml. // or harness.yaml.
interface IConfig { export interface IConfig {
homeserverUrl: string; homeserverUrl: string;
rawHomeserverUrl: string; rawHomeserverUrl: string;
accessToken: string; accessToken: string;

View File

@ -14,9 +14,10 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import config from "../config";
import * as http from "http"; import * as http from "http";
import { LogService } from "matrix-bot-sdk"; import { LogService } from "matrix-bot-sdk";
// allowed to use the global configuration since this is only intended to be used by `src/index.ts`.
import config from '../config';
export class Healthz { export class Healthz {
private static healthCode: number; private static healthCode: number;

View File

@ -56,13 +56,14 @@ if (config.health.healthz.enabled) {
patchMatrixClient(); patchMatrixClient();
config.RUNTIME.client = client; config.RUNTIME.client = client;
bot = await Mjolnir.setupMjolnirFromConfig(client); bot = await Mjolnir.setupMjolnirFromConfig(client, config);
} catch (err) { } catch (err) {
console.error(`Failed to setup mjolnir from the config ${config.dataPath}: ${err}`); console.error(`Failed to setup mjolnir from the config ${config.dataPath}: ${err}`);
throw err; throw err;
} }
try { try {
await bot.start(); await bot.start();
Healthz.isHealthy = true;
} catch (err) { } catch (err) {
console.error(`Mjolnir failed to start: ${err}`); console.error(`Mjolnir failed to start: ${err}`);
throw err; throw err;

View File

@ -16,32 +16,238 @@ limitations under the License.
import { MatrixGlob } from "matrix-bot-sdk"; import { MatrixGlob } from "matrix-bot-sdk";
export const RECOMMENDATION_BAN = "m.ban"; export enum EntityType {
export const RECOMMENDATION_BAN_TYPES = [RECOMMENDATION_BAN, "org.matrix.mjolnir.ban"]; /// `entity` is to be parsed as a glob of users IDs
RULE_USER = "m.policy.rule.user",
export function recommendationToStable(recommendation: string, unstable = false): string|null { /// `entity` is to be parsed as a glob of room IDs/aliases
if (RECOMMENDATION_BAN_TYPES.includes(recommendation)) return unstable ? RECOMMENDATION_BAN_TYPES[RECOMMENDATION_BAN_TYPES.length - 1] : RECOMMENDATION_BAN; RULE_ROOM = "m.policy.rule.room",
return null;
/// `entity` is to be parsed as a glob of server names
RULE_SERVER = "m.policy.rule.server",
} }
export class ListRule { export const RULE_USER = EntityType.RULE_USER;
export const RULE_ROOM = EntityType.RULE_ROOM;
export const RULE_SERVER = EntityType.RULE_SERVER;
// README! The order here matters for determining whether a type is obsolete, most recent should be first.
// These are the current and historical types for each type of rule which were used while MSC2313 was being developed
// and were left as an artifact for some time afterwards.
// Most rules (as of writing) will have the prefix `m.room.rule.*` as this has been in use for roughly 2 years.
export const USER_RULE_TYPES = [RULE_USER, "m.room.rule.user", "org.matrix.mjolnir.rule.user"];
export const ROOM_RULE_TYPES = [RULE_ROOM, "m.room.rule.room", "org.matrix.mjolnir.rule.room"];
export const SERVER_RULE_TYPES = [RULE_SERVER, "m.room.rule.server", "org.matrix.mjolnir.rule.server"];
export const ALL_RULE_TYPES = [...USER_RULE_TYPES, ...ROOM_RULE_TYPES, ...SERVER_RULE_TYPES];
export enum Recommendation {
/// The rule recommends a "ban".
///
/// The actual semantics for this "ban" may vary, e.g. room ban,
/// server ban, ignore user, etc. To determine the semantics for
/// this "ban", clients need to take into account the context for
/// the list, e.g. how the rule was imported.
Ban = "m.ban",
/// The rule specifies an "opinion", as a number in [-100, +100],
/// where -100 represents a user who is considered absolutely toxic
/// by whoever issued this ListRule and +100 represents a user who
/// is considered absolutely absolutely perfect by whoever issued
/// this ListRule.
Opinion = "org.matrix.msc3845.opinion",
}
/**
* All variants of recommendation `m.ban`
*/
const RECOMMENDATION_BAN_VARIANTS = [
// Stable
Recommendation.Ban,
// Unstable prefix, for compatibility.
"org.matrix.mjolnir.ban"
];
/**
* All variants of recommendation `m.opinion`
*/
const RECOMMENDATION_OPINION_VARIANTS: string[] = [
// Unstable
Recommendation.Opinion
];
export const OPINION_MIN = -100;
export const OPINION_MAX = +100;
/**
* Representation of a rule within a Policy List.
*/
export abstract class ListRule {
/**
* A glob for `entity`.
*/
private glob: MatrixGlob; private glob: MatrixGlob;
constructor(
constructor(public readonly entity: string, private action: string, public readonly reason: string, public readonly kind: string) { /**
* The entity covered by this rule, e.g. a glob user ID, a room ID, a server domain.
*/
public readonly entity: string,
/**
* A human-readable reason for this rule, for audit purposes.
*/
public readonly reason: string,
/**
* The type of entity for this rule, e.g. user, server domain, etc.
*/
public readonly kind: EntityType,
/**
* The recommendation for this rule, e.g. "ban" or "opinion", or `null`
* if the recommendation is one that Mjölnir doesn't understand.
*/
public readonly recommendation: Recommendation | null) {
this.glob = new MatrixGlob(entity); this.glob = new MatrixGlob(entity);
} }
/** /**
* The recommendation for this rule, or `null` if there is no recommendation or the recommendation is invalid. * Determine whether this rule should apply to a given entity.
* Recommendations are normalised to their stable types.
*/ */
public get recommendation(): string|null {
if (RECOMMENDATION_BAN_TYPES.includes(this.action)) return RECOMMENDATION_BAN;
return null;
}
public isMatch(entity: string): boolean { public isMatch(entity: string): boolean {
return this.glob.test(entity); return this.glob.test(entity);
} }
/**
* Validate and parse an event into a ListRule.
*
* @param event An *untrusted* event.
* @returns null if the ListRule is invalid or not recognized by Mjölnir.
*/
public static parse(event: {type: string, content: any}): ListRule | null {
// Parse common fields.
// If a field is ill-formed, discard the rule.
const content = event['content'];
if (!content || typeof content !== "object") {
return null;
}
const entity = content['entity'];
if (!entity || typeof entity !== "string") {
return null;
}
const recommendation = content['recommendation'];
if (!recommendation || typeof recommendation !== "string") {
return null;
}
const reason = content['reason'] || '<no reason>';
if (typeof reason !== "string") {
return null;
}
let type = event['type'];
let kind;
if (USER_RULE_TYPES.includes(type)) {
kind = EntityType.RULE_USER;
} else if (ROOM_RULE_TYPES.includes(type)) {
kind = EntityType.RULE_ROOM;
} else if (SERVER_RULE_TYPES.includes(type)) {
kind = EntityType.RULE_SERVER;
} else {
return null;
}
// From this point, we may need specific fields.
if (RECOMMENDATION_BAN_VARIANTS.includes(recommendation)) {
return new ListRuleBan(entity, reason, kind);
} else if (RECOMMENDATION_OPINION_VARIANTS.includes(recommendation)) {
let opinion = content['opinion'];
if (!Number.isInteger(opinion)) {
return null;
}
return new ListRuleOpinion(entity, reason, kind, opinion);
} else {
// As long as the `recommendation` is defined, we assume
// that the rule is correct, just unknown.
return new ListRuleUnknown(entity, reason, kind, content);
}
}
}
/**
* A rule representing a "ban".
*/
export class ListRuleBan extends ListRule {
constructor(
/**
* The entity covered by this rule, e.g. a glob user ID, a room ID, a server domain.
*/
entity: string,
/**
* A human-readable reason for this rule, for audit purposes.
*/
reason: string,
/**
* The type of entity for this rule, e.g. user, server domain, etc.
*/
kind: EntityType,
) {
super(entity, reason, kind, Recommendation.Ban)
}
}
/**
* A rule representing an "opinion"
*/
export class ListRuleOpinion extends ListRule {
constructor(
/**
* The entity covered by this rule, e.g. a glob user ID, a room ID, a server domain.
*/
entity: string,
/**
* A human-readable reason for this rule, for audit purposes.
*/
reason: string,
/**
* The type of entity for this rule, e.g. user, server domain, etc.
*/
kind: EntityType,
/**
* A number in [-100, +100] where -100 represents the worst possible opinion
* on the entity (e.g. toxic user or community) and +100 represents the best
* possible opinion on the entity (e.g. pillar of the community).
*/
public readonly opinion: number
) {
super(entity, reason, kind, Recommendation.Opinion);
if (!Number.isInteger(opinion)) {
throw new TypeError(`The opinion must be an integer, got ${opinion}`);
}
if (opinion < OPINION_MIN || opinion > OPINION_MAX) {
throw new TypeError(`The opinion must be within [-100, +100], got ${opinion}`);
}
}
}
/**
* Any list rule that we do not understand.
*/
export class ListRuleUnknown extends ListRule {
constructor(
/**
* The entity covered by this rule, e.g. a glob user ID, a room ID, a server domain.
*/
entity: string,
/**
* A human-readable reason for this rule, for audit purposes.
*/
reason: string,
/**
* The type of entity for this rule, e.g. user, server domain, etc.
*/
kind: EntityType,
/**
* The event used to create the rule.
*/
public readonly content: any,
) {
super(entity, reason, kind, null);
}
} }

View File

@ -16,33 +16,13 @@ limitations under the License.
import { extractRequestError, LogService, MatrixClient, UserID } from "matrix-bot-sdk"; import { extractRequestError, LogService, MatrixClient, UserID } from "matrix-bot-sdk";
import { EventEmitter } from "events"; import { EventEmitter } from "events";
import { ListRule, RECOMMENDATION_BAN } from "./ListRule"; import { ALL_RULE_TYPES, EntityType, ListRule, Recommendation, ROOM_RULE_TYPES, RULE_ROOM, RULE_SERVER, RULE_USER, SERVER_RULE_TYPES, USER_RULE_TYPES } from "./ListRule";
export const RULE_USER = "m.policy.rule.user";
export const RULE_ROOM = "m.policy.rule.room";
export const RULE_SERVER = "m.policy.rule.server";
// README! The order here matters for determining whether a type is obsolete, most recent should be first.
// These are the current and historical types for each type of rule which were used while MSC2313 was being developed
// and were left as an artifact for some time afterwards.
// Most rules (as of writing) will have the prefix `m.room.rule.*` as this has been in use for roughly 2 years.
export const USER_RULE_TYPES = [RULE_USER, "m.room.rule.user", "org.matrix.mjolnir.rule.user"];
export const ROOM_RULE_TYPES = [RULE_ROOM, "m.room.rule.room", "org.matrix.mjolnir.rule.room"];
export const SERVER_RULE_TYPES = [RULE_SERVER, "m.room.rule.server", "org.matrix.mjolnir.rule.server"];
export const ALL_RULE_TYPES = [...USER_RULE_TYPES, ...ROOM_RULE_TYPES, ...SERVER_RULE_TYPES];
export const SHORTCODE_EVENT_TYPE = "org.matrix.mjolnir.shortcode"; export const SHORTCODE_EVENT_TYPE = "org.matrix.mjolnir.shortcode";
export function ruleTypeToStable(rule: string, unstable = true): string|null {
if (USER_RULE_TYPES.includes(rule)) return unstable ? USER_RULE_TYPES[USER_RULE_TYPES.length - 1] : RULE_USER;
if (ROOM_RULE_TYPES.includes(rule)) return unstable ? ROOM_RULE_TYPES[ROOM_RULE_TYPES.length - 1] : RULE_ROOM;
if (SERVER_RULE_TYPES.includes(rule)) return unstable ? SERVER_RULE_TYPES[SERVER_RULE_TYPES.length - 1] : RULE_SERVER;
return null;
}
export enum ChangeType { export enum ChangeType {
Added = "ADDED", Added = "ADDED",
Removed = "REMOVED", Removed = "REMOVED",
Modified = "MODIFIED" Modified = "MODIFIED"
} }
@ -71,28 +51,28 @@ export interface ListRuleChange {
readonly previousState?: any, readonly previousState?: any,
} }
declare interface BanList { declare interface PolicyList {
// BanList.update is emitted when the BanList has pulled new rules from Matrix and informs listeners of any changes. // PolicyList.update is emitted when the PolicyList has pulled new rules from Matrix and informs listeners of any changes.
on(event: 'BanList.update', listener: (list: BanList, changes: ListRuleChange[]) => void): this on(event: 'PolicyList.update', listener: (list: PolicyList, changes: ListRuleChange[]) => void): this
emit(event: 'BanList.update', list: BanList, changes: ListRuleChange[]): boolean emit(event: 'PolicyList.update', list: PolicyList, changes: ListRuleChange[]): boolean
// BanList.batch is emitted when the BanList has created a batch from the events provided by `updateForEvent`. // PolicyList.batch is emitted when the PolicyList has created a batch from the events provided by `updateForEvent`.
on(event: 'BanList.batch', listener: (list: BanList) => void): this on(event: 'PolicyList.batch', listener: (list: PolicyList) => void): this
emit(event: 'BanList.batch', list: BanList): boolean emit(event: 'PolicyList.batch', list: PolicyList): boolean
} }
/** /**
* The BanList caches all of the rules that are active in a policy room so Mjolnir can refer to when applying bans etc. * The PolicyList caches all of the rules that are active in a policy room so Mjolnir can refer to when applying bans etc.
* This cannot be used to update events in the modeled room, it is a readonly model of the policy room. * This cannot be used to update events in the modeled room, it is a readonly model of the policy room.
*/ */
class BanList extends EventEmitter { class PolicyList extends EventEmitter {
private shortcode: string|null = null; private shortcode: string | null = null;
// A map of state events indexed first by state type and then state keys. // A map of state events indexed first by state type and then state keys.
private state: Map<string, Map<string, any>> = new Map(); private state: Map<string, Map<string, any>> = new Map();
// Batches new events from sync together before starting the process to update the list. // Batches new events from sync together before starting the process to update the list.
private readonly batcher: UpdateBatcher; private readonly batcher: UpdateBatcher;
/** /**
* Construct a BanList, does not synchronize with the room. * Construct a PolicyList, does not synchronize with the room.
* @param roomId The id of the policy room, i.e. a room containing MSC2313 policies. * @param roomId The id of the policy room, i.e. a room containing MSC2313 policies.
* @param roomRef A sharable/clickable matrix URL that refers to the room. * @param roomRef A sharable/clickable matrix URL that refers to the room.
* @param client A matrix client that is used to read the state of the room when `updateList` is called. * @param client A matrix client that is used to read the state of the room when `updateList` is called.
@ -120,7 +100,7 @@ class BanList extends EventEmitter {
} }
/** /**
* Store this state event as part of the active room state for this BanList (used to cache rules). * Store this state event as part of the active room state for this PolicyList (used to cache rules).
* The state type should be normalised if it is obsolete e.g. m.room.rule.user should be stored as m.policy.rule.user. * The state type should be normalised if it is obsolete e.g. m.room.rule.user should be stored as m.policy.rule.user.
* @param stateType The event type e.g. m.room.policy.user. * @param stateType The event type e.g. m.room.policy.user.
* @param stateKey The state key e.g. rule:@bad:matrix.org * @param stateKey The state key e.g. rule:@bad:matrix.org
@ -137,7 +117,7 @@ class BanList extends EventEmitter {
/** /**
* Return all the active rules of a given kind. * Return all the active rules of a given kind.
* @param kind e.g. RULE_SERVER (m.policy.rule.server). Rule types are always normalised when they are interned into the BanList. * @param kind e.g. RULE_SERVER (m.policy.rule.server). Rule types are always normalised when they are interned into the PolicyList.
* @returns The active ListRules for the ban list of that kind. * @returns The active ListRules for the ban list of that kind.
*/ */
private rulesOfKind(kind: string): ListRule[] { private rulesOfKind(kind: string): ListRule[] {
@ -149,7 +129,7 @@ class BanList extends EventEmitter {
// README! If you are refactoring this and/or introducing a mechanism to return the list of rules, // README! If you are refactoring this and/or introducing a mechanism to return the list of rules,
// please make sure that you *only* return rules with `m.ban` or create a different method // please make sure that you *only* return rules with `m.ban` or create a different method
// (we don't want to accidentally ban entities). // (we don't want to accidentally ban entities).
if (rule && rule.kind === kind && rule.recommendation === RECOMMENDATION_BAN) { if (rule && rule.kind === kind && rule.recommendation === Recommendation.Ban) {
rules.push(rule); rules.push(rule);
} }
} }
@ -160,8 +140,8 @@ class BanList extends EventEmitter {
public set listShortcode(newShortcode: string) { public set listShortcode(newShortcode: string) {
const currentShortcode = this.shortcode; const currentShortcode = this.shortcode;
this.shortcode = newShortcode; this.shortcode = newShortcode;
this.client.sendStateEvent(this.roomId, SHORTCODE_EVENT_TYPE, '', {shortcode: this.shortcode}).catch(err => { this.client.sendStateEvent(this.roomId, SHORTCODE_EVENT_TYPE, '', { shortcode: this.shortcode }).catch(err => {
LogService.error("BanList", extractRequestError(err)); LogService.error("PolicyList", extractRequestError(err));
if (this.shortcode === newShortcode) this.shortcode = currentShortcode; if (this.shortcode === newShortcode) this.shortcode = currentShortcode;
}); });
} }
@ -267,7 +247,7 @@ class BanList extends EventEmitter {
continue; continue;
} }
let kind: string|null = null; let kind: EntityType | null = null;
if (USER_RULE_TYPES.includes(event['type'])) { if (USER_RULE_TYPES.includes(event['type'])) {
kind = RULE_USER; kind = RULE_USER;
} else if (ROOM_RULE_TYPES.includes(event['type'])) { } else if (ROOM_RULE_TYPES.includes(event['type'])) {
@ -286,7 +266,7 @@ class BanList extends EventEmitter {
// as it may be someone deleting the older versions of the rules. // as it may be someone deleting the older versions of the rules.
if (previousState) { if (previousState) {
const logObsoleteRule = () => { const logObsoleteRule = () => {
LogService.info('BanList', `In BanList ${this.roomRef}, conflict between rules ${event['event_id']} (with obsolete type ${event['type']}) ` + LogService.info('PolicyList', `In PolicyList ${this.roomRef}, conflict between rules ${event['event_id']} (with obsolete type ${event['type']}) ` +
`and ${previousState['event_id']} (with standard type ${previousState['type']}). Ignoring rule with obsolete type.`); `and ${previousState['event_id']} (with standard type ${previousState['type']}). Ignoring rule with obsolete type.`);
} }
if (kind === RULE_USER && USER_RULE_TYPES.indexOf(event['type']) > USER_RULE_TYPES.indexOf(previousState['type'])) { if (kind === RULE_USER && USER_RULE_TYPES.indexOf(event['type']) > USER_RULE_TYPES.indexOf(previousState['type'])) {
@ -305,7 +285,7 @@ class BanList extends EventEmitter {
// in order to mark a rule as deleted. // in order to mark a rule as deleted.
// We always set state with the normalised state type via `kind` to de-duplicate rules. // We always set state with the normalised state type via `kind` to de-duplicate rules.
this.setState(kind, event['state_key'], event); this.setState(kind, event['state_key'], event);
const changeType: null|ChangeType = (() => { const changeType: null | ChangeType = (() => {
if (!previousState) { if (!previousState) {
return ChangeType.Added; return ChangeType.Added;
} else if (previousState['event_id'] === event['event_id']) { } else if (previousState['event_id'] === event['event_id']) {
@ -329,56 +309,52 @@ class BanList extends EventEmitter {
// and so will not have been used. Removing a rule like this therefore results in no change. // and so will not have been used. Removing a rule like this therefore results in no change.
if (changeType === ChangeType.Removed && previousState?.unsigned?.rule) { if (changeType === ChangeType.Removed && previousState?.unsigned?.rule) {
const sender = event.unsigned['redacted_because'] ? event.unsigned['redacted_because']['sender'] : event.sender; const sender = event.unsigned['redacted_because'] ? event.unsigned['redacted_because']['sender'] : event.sender;
changes.push({changeType, event, sender, rule: previousState.unsigned.rule, changes.push({
... previousState ? {previousState} : {} }); changeType, event, sender, rule: previousState.unsigned.rule,
...previousState ? { previousState } : {}
});
// Event has no content and cannot be parsed as a ListRule. // Event has no content and cannot be parsed as a ListRule.
continue; continue;
} }
// It's a rule - parse it // It's a rule - parse it
const content = event['content']; const rule = ListRule.parse(event);
if (!content) continue; if (!rule) {
// Invalid/unknown rule, just skip it.
const entity = content['entity'];
const recommendation = content['recommendation'];
const reason = content['reason'] || '<no reason>';
if (!entity || !recommendation) {
continue; continue;
} }
const rule = new ListRule(entity, recommendation, reason, kind);
event.unsigned.rule = rule; event.unsigned.rule = rule;
if (changeType) { if (changeType) {
changes.push({rule, changeType, event, sender: event.sender, ... previousState ? {previousState} : {} }); changes.push({ rule, changeType, event, sender: event.sender, ...previousState ? { previousState } : {} });
} }
} }
this.emit('BanList.update', this, changes); this.emit('PolicyList.update', this, changes);
return changes; return changes;
} }
/** /**
* Inform the `BanList` about a new event from the room it is modelling. * Inform the `PolicyList` about a new event from the room it is modelling.
* @param event An event from the room the `BanList` models to inform an instance about. * @param event An event from the room the `PolicyList` models to inform an instance about.
*/ */
public updateForEvent(event: { event_id: string }): void { public updateForEvent(event: { event_id: string }): void {
this.batcher.addToBatch(event.event_id) this.batcher.addToBatch(event.event_id)
} }
} }
export default BanList; export default PolicyList;
/** /**
* Helper class that emits a batch event on a `BanList` when it has made a batch * Helper class that emits a batch event on a `PolicyList` when it has made a batch
* out of the events given to `addToBatch`. * out of the events given to `addToBatch`.
*/ */
class UpdateBatcher { class UpdateBatcher {
// Whether we are waiting for more events to form a batch. // Whether we are waiting for more events to form a batch.
private isWaiting = false; private isWaiting = false;
// The latest (or most recent) event we have received. // The latest (or most recent) event we have received.
private latestEventId: string|null = null; private latestEventId: string | null = null;
private readonly waitPeriodMS = 200; // 200ms seems good enough. private readonly waitPeriodMS = 200; // 200ms seems good enough.
private readonly maxWaitMS = 3000; // 3s is long enough to wait while batching. private readonly maxWaitMS = 3000; // 3s is long enough to wait while batching.
constructor(private readonly banList: BanList) { constructor(private readonly banList: PolicyList) {
} }
@ -402,7 +378,7 @@ class UpdateBatcher {
await new Promise(resolve => setTimeout(resolve, this.waitPeriodMS)); await new Promise(resolve => setTimeout(resolve, this.waitPeriodMS));
} while ((Date.now() - start) < this.maxWaitMS && this.latestEventId !== eventId) } while ((Date.now() - start) < this.maxWaitMS && this.latestEventId !== eventId)
this.reset(); this.reset();
this.banList.emit('BanList.batch', this.banList); this.banList.emit('PolicyList.batch', this.banList);
} }
/** /**

View File

@ -13,10 +13,11 @@ 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 BanList, { ChangeType, ListRuleChange, RULE_ROOM, RULE_SERVER, RULE_USER } from "./BanList" import BanList, { ChangeType, ListRuleChange } from "./PolicyList"
import * as crypto from "crypto"; import * as crypto from "crypto";
import { LogService } from "matrix-bot-sdk"; import { LogService } from "matrix-bot-sdk";
import { ListRule } from "./ListRule"; import { EntityType, ListRule } from "./ListRule";
import PolicyList from "./PolicyList";
export const USER_MAY_INVITE = 'user_may_invite'; export const USER_MAY_INVITE = 'user_may_invite';
export const CHECK_EVENT_FOR_SPAM = 'check_event_for_spam'; export const CHECK_EVENT_FOR_SPAM = 'check_event_for_spam';
@ -25,7 +26,7 @@ export const CHECK_EVENT_FOR_SPAM = 'check_event_for_spam';
* Rules in the RuleServer format that have been produced from a single event. * Rules in the RuleServer format that have been produced from a single event.
*/ */
class EventRules { class EventRules {
constructor ( constructor(
readonly eventId: string, readonly eventId: string,
readonly roomId: string, readonly roomId: string,
readonly ruleServerRules: RuleServerRule[], readonly ruleServerRules: RuleServerRule[],
@ -108,7 +109,7 @@ export default class RuleServer {
* @returns The `EventRules` object describing which rules have been created based on the policy the event represents * @returns The `EventRules` object describing which rules have been created based on the policy the event represents
* or `undefined` if there are no `EventRules` associated with the event. * or `undefined` if there are no `EventRules` associated with the event.
*/ */
private getEventRules(roomId: string, eventId: string): EventRules|undefined { private getEventRules(roomId: string, eventId: string): EventRules | undefined {
return this.rulesByEvent.get(roomId)?.get(eventId); return this.rulesByEvent.get(roomId)?.get(eventId);
} }
@ -118,7 +119,7 @@ export default class RuleServer {
* @throws If there are already rules associated with the event specified in `eventRules.eventId`. * @throws If there are already rules associated with the event specified in `eventRules.eventId`.
*/ */
private addEventRules(eventRules: EventRules): void { private addEventRules(eventRules: EventRules): void {
const {roomId, eventId, token} = eventRules; const { roomId, eventId, token } = eventRules;
if (this.rulesByEvent.get(roomId)?.has(eventId)) { if (this.rulesByEvent.get(roomId)?.has(eventId)) {
throw new TypeError(`There is already an entry in the RuleServer for rules created from the event ${eventId}.`); throw new TypeError(`There is already an entry in the RuleServer for rules created from the event ${eventId}.`);
} }
@ -136,7 +137,7 @@ export default class RuleServer {
* @param eventRules The EventRules to stop serving from the rule server. * @param eventRules The EventRules to stop serving from the rule server.
*/ */
private stopEventRules(eventRules: EventRules): void { private stopEventRules(eventRules: EventRules): void {
const {eventId, roomId, token} = eventRules; const { eventId, roomId, token } = eventRules;
this.rulesByEvent.get(roomId)?.delete(eventId); this.rulesByEvent.get(roomId)?.delete(eventId);
// We expect that each row of `rulesByEvent` list of eventRules (represented by 1 row in `rulesByEvent`) to be relatively small (1-5) // We expect that each row of `rulesByEvent` list of eventRules (represented by 1 row in `rulesByEvent`) to be relatively small (1-5)
// as it can only contain eventRules added during the instant of time represented by one token. // as it can only contain eventRules added during the instant of time represented by one token.
@ -156,7 +157,7 @@ export default class RuleServer {
const eventRules = new EventRules(change.event.event_id, change.event.room_id, toRuleServerFormat(change.rule), this.currentToken); const eventRules = new EventRules(change.event.event_id, change.event.room_id, toRuleServerFormat(change.rule), this.currentToken);
this.addEventRules(eventRules); this.addEventRules(eventRules);
} else if (change.changeType === ChangeType.Modified) { } else if (change.changeType === ChangeType.Modified) {
const entry: EventRules|undefined = this.getEventRules(change.event.roomId, change.previousState.event_id); const entry: EventRules | undefined = this.getEventRules(change.event.roomId, change.previousState.event_id);
if (entry === undefined) { if (entry === undefined) {
LogService.error('RuleServer', `Could not find the rules for the previous modified state ${change.event['state_type']} ${change.event['state_key']} ${change.previousState?.event_id}`); LogService.error('RuleServer', `Could not find the rules for the previous modified state ${change.event['state_type']} ${change.event['state_key']} ${change.previousState?.event_id}`);
return; return;
@ -169,7 +170,7 @@ export default class RuleServer {
// 2) When an event has been "soft redacted" (ie we have a new event with the same state type and state_key with no content), // 2) When an event has been "soft redacted" (ie we have a new event with the same state type and state_key with no content),
// the events in the `previousState` and `event` slots of `change` will be distinct events. // the events in the `previousState` and `event` slots of `change` will be distinct events.
// In either case (of redaction or "soft redaction") we can use `previousState` to get the right event id to stop. // In either case (of redaction or "soft redaction") we can use `previousState` to get the right event id to stop.
const entry: EventRules|undefined = this.getEventRules(change.event.room_id, change.previousState.event_id); const entry: EventRules | undefined = this.getEventRules(change.event.room_id, change.previousState.event_id);
if (entry === undefined) { if (entry === undefined) {
LogService.error('RuleServer', `Could not find the rules for the previous modified state ${change.event['state_type']} ${change.event['state_key']} ${change.previousState?.event_id}`); LogService.error('RuleServer', `Could not find the rules for the previous modified state ${change.event['state_type']} ${change.event['state_key']} ${change.previousState?.event_id}`);
return; return;
@ -184,16 +185,16 @@ export default class RuleServer {
* as we won't be able to serve rules that have already been interned in the BanList. * as we won't be able to serve rules that have already been interned in the BanList.
* @param banList a BanList to watch for rule changes with. * @param banList a BanList to watch for rule changes with.
*/ */
public watch(banList: BanList): void { public watch(banList: PolicyList): void {
banList.on('BanList.update', this.banListUpdateListener); banList.on('PolicyList.update', this.banListUpdateListener);
} }
/** /**
* Remove all of the rules that have been created from the policies in this banList. * Remove all of the rules that have been created from the policies in this banList.
* @param banList The BanList to unwatch. * @param banList The BanList to unwatch.
*/ */
public unwatch(banList: BanList): void { public unwatch(banList: PolicyList): void {
banList.removeListener('BanList.update', this.banListUpdateListener); banList.removeListener('PolicyList.update', this.banListUpdateListener);
const listRules = this.rulesByEvent.get(banList.roomId); const listRules = this.rulesByEvent.get(banList.roomId);
this.nextToken(); this.nextToken();
if (listRules) { if (listRules) {
@ -221,8 +222,8 @@ export default class RuleServer {
* @param sinceToken A token that has previously been issued by this server. * @param sinceToken A token that has previously been issued by this server.
* @returns An object with the rules that have been started and stopped since the token and a new token to poll for more rules with. * @returns An object with the rules that have been started and stopped since the token and a new token to poll for more rules with.
*/ */
public getUpdates(sinceToken: string | null): {start: RuleServerRule[], stop: string[], reset?: boolean, since: string} { public getUpdates(sinceToken: string | null): { start: RuleServerRule[], stop: string[], reset?: boolean, since: string } {
const updatesSince = <T = EventRules|string>(token: number | null, policyStore: T[][]): T[] => { const updatesSince = <T = EventRules | string>(token: number | null, policyStore: T[][]): T[] => {
if (token === null) { if (token === null) {
// The client is requesting for the first time, we will give them everything. // The client is requesting for the first time, we will give them everything.
return policyStore.flat(); return policyStore.flat();
@ -234,7 +235,7 @@ export default class RuleServer {
} }
} }
const [serverId, since] = sinceToken ? sinceToken.split('::') : [null, null]; const [serverId, since] = sinceToken ? sinceToken.split('::') : [null, null];
const parsedSince: number | null = since ? parseInt(since, 10) : null; const parsedSince: number | null = since ? parseInt(since, 10) : null;
if (serverId && serverId !== this.serverId) { if (serverId && serverId !== this.serverId) {
// The server has restarted, but the client has not and still has rules we can no longer account for. // The server has restarted, but the client has not and still has rules we can no longer account for.
// So we have to resend them everything. // So we have to resend them everything.
@ -261,59 +262,59 @@ export default class RuleServer {
* @returns An array of rules that can be served from the rule server. * @returns An array of rules that can be served from the rule server.
*/ */
function toRuleServerFormat(policyRule: ListRule): RuleServerRule[] { function toRuleServerFormat(policyRule: ListRule): RuleServerRule[] {
function makeLiteral(literal: string) { function makeLiteral(literal: string) {
return {literal} return { literal }
} }
function makeGlob(glob: string) { function makeGlob(glob: string) {
return {glob} return { glob }
} }
function makeServerGlob(server: string) { function makeServerGlob(server: string) {
return {glob: `:${server}`} return { glob: `:${server}` }
} }
function makeRule(checks: Checks) { function makeRule(checks: Checks) {
return { return {
id: crypto.randomUUID(), id: crypto.randomUUID(),
checks: checks checks: checks
} }
} }
if (policyRule.kind === RULE_USER) { if (policyRule.kind === EntityType.RULE_USER) {
// Block any messages or invites from being sent by a matching local user // Block any messages or invites from being sent by a matching local user
// Block any messages or invitations from being received that were sent by a matching remote user. // Block any messages or invitations from being received that were sent by a matching remote user.
return [{ return [{
property: USER_MAY_INVITE, property: USER_MAY_INVITE,
user_id: [makeGlob(policyRule.entity)] user_id: [makeGlob(policyRule.entity)]
}, },
{ {
property: CHECK_EVENT_FOR_SPAM, property: CHECK_EVENT_FOR_SPAM,
sender: [makeGlob(policyRule.entity)] sender: [makeGlob(policyRule.entity)]
}].map(makeRule) }].map(makeRule)
} else if (policyRule.kind === RULE_ROOM) { } else if (policyRule.kind === EntityType.RULE_ROOM) {
// Block any messages being sent or received in the room, stop invitations being sent to the room and // Block any messages being sent or received in the room, stop invitations being sent to the room and
// stop anyone receiving invitations from the room. // stop anyone receiving invitations from the room.
return [{ return [{
property: USER_MAY_INVITE, property: USER_MAY_INVITE,
'room_id': [makeLiteral(policyRule.entity)] 'room_id': [makeLiteral(policyRule.entity)]
}, },
{ {
property: CHECK_EVENT_FOR_SPAM, property: CHECK_EVENT_FOR_SPAM,
'room_id': [makeLiteral(policyRule.entity)] 'room_id': [makeLiteral(policyRule.entity)]
}].map(makeRule) }].map(makeRule)
} else if (policyRule.kind === RULE_SERVER) { } else if (policyRule.kind === EntityType.RULE_SERVER) {
// Block any invitations from the server or any new messages from the server. // Block any invitations from the server or any new messages from the server.
return [{ return [{
property: USER_MAY_INVITE, property: USER_MAY_INVITE,
user_id: [makeServerGlob(policyRule.entity)] user_id: [makeServerGlob(policyRule.entity)]
}, },
{ {
property: CHECK_EVENT_FOR_SPAM, property: CHECK_EVENT_FOR_SPAM,
sender: [makeServerGlob(policyRule.entity)] sender: [makeServerGlob(policyRule.entity)]
}].map(makeRule) }].map(makeRule)
} else { } else {
LogService.info('RuleServer', `Ignoring unsupported policy rule type ${policyRule.kind}`); LogService.info('RuleServer', `Ignoring unsupported policy rule type ${policyRule.kind}`);
return [] return []
} }
} }

View File

@ -18,7 +18,6 @@ import { Protection } from "./IProtection";
import { NumberProtectionSetting } from "./ProtectionSettings"; import { NumberProtectionSetting } from "./ProtectionSettings";
import { Mjolnir } from "../Mjolnir"; import { Mjolnir } from "../Mjolnir";
import { LogLevel, LogService } from "matrix-bot-sdk"; import { LogLevel, LogService } from "matrix-bot-sdk";
import config from "../config";
// if this is exceeded, we'll ban the user for spam and redact their messages // if this is exceeded, we'll ban the user for spam and redact their messages
export const DEFAULT_MAX_PER_MINUTE = 10; export const DEFAULT_MAX_PER_MINUTE = 10;
@ -64,7 +63,7 @@ export class BasicFlooding extends Protection {
if (messageCount >= this.settings.maxPerMinute.value) { if (messageCount >= this.settings.maxPerMinute.value) {
await mjolnir.logMessage(LogLevel.WARN, "BasicFlooding", `Banning ${event['sender']} in ${roomId} for flooding (${messageCount} messages in the last minute)`, roomId); await mjolnir.logMessage(LogLevel.WARN, "BasicFlooding", `Banning ${event['sender']} in ${roomId} for flooding (${messageCount} messages in the last minute)`, roomId);
if (!config.noop) { if (!mjolnir.config.noop) {
await mjolnir.client.banUser(event['sender'], roomId, "spam"); await mjolnir.client.banUser(event['sender'], roomId, "spam");
} else { } else {
await mjolnir.logMessage(LogLevel.WARN, "BasicFlooding", `Tried to ban ${event['sender']} in ${roomId} but Mjolnir is running in no-op mode`, roomId); await mjolnir.logMessage(LogLevel.WARN, "BasicFlooding", `Tried to ban ${event['sender']} in ${roomId} but Mjolnir is running in no-op mode`, roomId);
@ -75,7 +74,7 @@ export class BasicFlooding extends Protection {
this.recentlyBanned.push(event['sender']); // flag to reduce spam this.recentlyBanned.push(event['sender']); // flag to reduce spam
// Redact all the things the user said too // Redact all the things the user said too
if (!config.noop) { if (!mjolnir.config.noop) {
for (const eventId of forUser.map(e => e.eventId)) { for (const eventId of forUser.map(e => e.eventId)) {
await mjolnir.client.redactEvent(roomId, eventId, "spam"); await mjolnir.client.redactEvent(roomId, eventId, "spam");
} }

View File

@ -17,7 +17,6 @@ limitations under the License.
import { Protection } from "./IProtection"; import { Protection } from "./IProtection";
import { Mjolnir } from "../Mjolnir"; import { Mjolnir } from "../Mjolnir";
import { LogLevel, LogService } from "matrix-bot-sdk"; import { LogLevel, LogService } from "matrix-bot-sdk";
import config from "../config";
import { isTrueJoinEvent } from "../utils"; import { isTrueJoinEvent } from "../utils";
export class FirstMessageIsImage extends Protection { export class FirstMessageIsImage extends Protection {
@ -58,7 +57,7 @@ export class FirstMessageIsImage extends Protection {
const isMedia = msgtype === 'm.image' || msgtype === 'm.video' || formattedBody.toLowerCase().includes('<img'); const isMedia = msgtype === 'm.image' || msgtype === 'm.video' || formattedBody.toLowerCase().includes('<img');
if (isMedia && this.justJoined[roomId].includes(event['sender'])) { if (isMedia && this.justJoined[roomId].includes(event['sender'])) {
await mjolnir.logMessage(LogLevel.WARN, "FirstMessageIsImage", `Banning ${event['sender']} for posting an image as the first thing after joining in ${roomId}.`); await mjolnir.logMessage(LogLevel.WARN, "FirstMessageIsImage", `Banning ${event['sender']} for posting an image as the first thing after joining in ${roomId}.`);
if (!config.noop) { if (!mjolnir.config.noop) {
await mjolnir.client.banUser(event['sender'], roomId, "spam"); await mjolnir.client.banUser(event['sender'], roomId, "spam");
} else { } else {
await mjolnir.logMessage(LogLevel.WARN, "FirstMessageIsImage", `Tried to ban ${event['sender']} in ${roomId} but Mjolnir is running in no-op mode`, roomId); await mjolnir.logMessage(LogLevel.WARN, "FirstMessageIsImage", `Tried to ban ${event['sender']} in ${roomId} but Mjolnir is running in no-op mode`, roomId);
@ -69,7 +68,7 @@ export class FirstMessageIsImage extends Protection {
this.recentlyBanned.push(event['sender']); // flag to reduce spam this.recentlyBanned.push(event['sender']); // flag to reduce spam
// Redact the event // Redact the event
if (!config.noop) { if (!mjolnir.config.noop) {
await mjolnir.client.redactEvent(roomId, event['event_id'], "spam"); await mjolnir.client.redactEvent(roomId, event['event_id'], "spam");
} else { } else {
await mjolnir.logMessage(LogLevel.WARN, "FirstMessageIsImage", `Tried to redact ${event['event_id']} in ${roomId} but Mjolnir is running in no-op mode`, roomId); await mjolnir.logMessage(LogLevel.WARN, "FirstMessageIsImage", `Tried to redact ${event['event_id']} in ${roomId} but Mjolnir is running in no-op mode`, roomId);

View File

@ -18,7 +18,6 @@ import {Protection} from "./IProtection";
import {Mjolnir} from "../Mjolnir"; import {Mjolnir} from "../Mjolnir";
import {NumberProtectionSetting} from "./ProtectionSettings"; import {NumberProtectionSetting} from "./ProtectionSettings";
import {LogLevel} from "matrix-bot-sdk"; import {LogLevel} from "matrix-bot-sdk";
import config from "../config";
const DEFAULT_MAX_PER_TIMESCALE = 50; const DEFAULT_MAX_PER_TIMESCALE = 50;
const DEFAULT_TIMESCALE_MINUTES = 60; const DEFAULT_TIMESCALE_MINUTES = 60;
@ -89,7 +88,7 @@ export class JoinWaveShortCircuit extends Protection {
if (++this.joinBuckets[roomId].numberOfJoins >= this.settings.maxPer.value) { if (++this.joinBuckets[roomId].numberOfJoins >= this.settings.maxPer.value) {
await mjolnir.logMessage(LogLevel.WARN, "JoinWaveShortCircuit", `Setting ${roomId} to invite-only as more than ${this.settings.maxPer.value} users have joined over the last ${this.settings.timescaleMinutes.value} minutes (since ${this.joinBuckets[roomId].lastBucketStart})`, roomId); await mjolnir.logMessage(LogLevel.WARN, "JoinWaveShortCircuit", `Setting ${roomId} to invite-only as more than ${this.settings.maxPer.value} users have joined over the last ${this.settings.timescaleMinutes.value} minutes (since ${this.joinBuckets[roomId].lastBucketStart})`, roomId);
if (!config.noop) { if (!mjolnir.config.noop) {
await mjolnir.client.sendStateEvent(roomId, "m.room.join_rules", "", {"join_rule": "invite"}) await mjolnir.client.sendStateEvent(roomId, "m.room.join_rules", "", {"join_rule": "invite"})
} else { } else {
await mjolnir.logMessage(LogLevel.WARN, "JoinWaveShortCircuit", `Tried to set ${roomId} to invite-only, but Mjolnir is running in no-op mode`, roomId); await mjolnir.logMessage(LogLevel.WARN, "JoinWaveShortCircuit", `Tried to set ${roomId} to invite-only, but Mjolnir is running in no-op mode`, roomId);

View File

@ -17,7 +17,6 @@ limitations under the License.
import { Protection } from "./IProtection"; import { Protection } from "./IProtection";
import { Mjolnir } from "../Mjolnir"; import { Mjolnir } from "../Mjolnir";
import { LogLevel, Permalinks, UserID } from "matrix-bot-sdk"; import { LogLevel, Permalinks, UserID } from "matrix-bot-sdk";
import config from "../config";
export class MessageIsMedia extends Protection { export class MessageIsMedia extends Protection {
@ -43,7 +42,7 @@ export class MessageIsMedia extends Protection {
if (isMedia) { if (isMedia) {
await mjolnir.logMessage(LogLevel.WARN, "MessageIsMedia", `Redacting event from ${event['sender']} for posting an image/video. ${Permalinks.forEvent(roomId, event['event_id'], [new UserID(await mjolnir.client.getUserId()).domain])}`); await mjolnir.logMessage(LogLevel.WARN, "MessageIsMedia", `Redacting event from ${event['sender']} for posting an image/video. ${Permalinks.forEvent(roomId, event['event_id'], [new UserID(await mjolnir.client.getUserId()).domain])}`);
// Redact the event // Redact the event
if (!config.noop) { if (!mjolnir.config.noop) {
await mjolnir.client.redactEvent(roomId, event['event_id'], "Images/videos are not permitted here"); await mjolnir.client.redactEvent(roomId, event['event_id'], "Images/videos are not permitted here");
} else { } else {
await mjolnir.logMessage(LogLevel.WARN, "MessageIsMedia", `Tried to redact ${event['event_id']} in ${roomId} but Mjolnir is running in no-op mode`, roomId); await mjolnir.logMessage(LogLevel.WARN, "MessageIsMedia", `Tried to redact ${event['event_id']} in ${roomId} but Mjolnir is running in no-op mode`, roomId);

View File

@ -17,7 +17,6 @@ limitations under the License.
import { Protection } from "./IProtection"; import { Protection } from "./IProtection";
import { Mjolnir } from "../Mjolnir"; import { Mjolnir } from "../Mjolnir";
import { LogLevel, Permalinks, UserID } from "matrix-bot-sdk"; import { LogLevel, Permalinks, UserID } from "matrix-bot-sdk";
import config from "../config";
export class MessageIsVoice extends Protection { export class MessageIsVoice extends Protection {
@ -40,7 +39,7 @@ export class MessageIsVoice extends Protection {
if (event['content']['org.matrix.msc3245.voice'] === undefined) return; if (event['content']['org.matrix.msc3245.voice'] === undefined) return;
await mjolnir.logMessage(LogLevel.INFO, "MessageIsVoice", `Redacting event from ${event['sender']} for posting a voice message. ${Permalinks.forEvent(roomId, event['event_id'], [new UserID(await mjolnir.client.getUserId()).domain])}`); await mjolnir.logMessage(LogLevel.INFO, "MessageIsVoice", `Redacting event from ${event['sender']} for posting a voice message. ${Permalinks.forEvent(roomId, event['event_id'], [new UserID(await mjolnir.client.getUserId()).domain])}`);
// Redact the event // Redact the event
if (!config.noop) { if (!mjolnir.config.noop) {
await mjolnir.client.redactEvent(roomId, event['event_id'], "Voice messages are not permitted here"); await mjolnir.client.redactEvent(roomId, event['event_id'], "Voice messages are not permitted here");
} else { } else {
await mjolnir.logMessage(LogLevel.WARN, "MessageIsVoice", `Tried to redact ${event['event_id']} in ${roomId} but Mjolnir is running in no-op mode`, roomId); await mjolnir.logMessage(LogLevel.WARN, "MessageIsVoice", `Tried to redact ${event['event_id']} in ${roomId} but Mjolnir is running in no-op mode`, roomId);

View File

@ -14,7 +14,6 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import config from "../config";
import { Protection } from "./IProtection"; import { Protection } from "./IProtection";
import { MXIDListProtectionSetting, NumberProtectionSetting } from "./ProtectionSettings"; import { MXIDListProtectionSetting, NumberProtectionSetting } from "./ProtectionSettings";
import { Mjolnir } from "../Mjolnir"; import { Mjolnir } from "../Mjolnir";
@ -83,7 +82,7 @@ export class TrustedReporters extends Protection {
if (met.length > 0) { if (met.length > 0) {
await mjolnir.client.sendMessage(config.managementRoom, { await mjolnir.client.sendMessage(mjolnir.config.managementRoom, {
msgtype: "m.notice", msgtype: "m.notice",
body: `message ${event.id} reported by ${[...reporters].join(', ')}. ` body: `message ${event.id} reported by ${[...reporters].join(', ')}. `
+ `actions: ${met.join(', ')}` + `actions: ${met.join(', ')}`

View File

@ -17,7 +17,6 @@ limitations under the License.
import { Protection } from "./IProtection"; import { Protection } from "./IProtection";
import { Mjolnir } from "../Mjolnir"; import { Mjolnir } from "../Mjolnir";
import { LogLevel, LogService } from "matrix-bot-sdk"; import { LogLevel, LogService } from "matrix-bot-sdk";
import config from "../config";
import { isTrueJoinEvent } from "../utils"; import { isTrueJoinEvent } from "../utils";
export class WordList extends Protection { export class WordList extends Protection {
@ -25,15 +24,10 @@ export class WordList extends Protection {
settings = {}; settings = {};
private justJoined: { [roomId: string]: { [username: string]: Date} } = {}; private justJoined: { [roomId: string]: { [username: string]: Date} } = {};
private badWords: RegExp; private badWords?: RegExp;
constructor() { constructor() {
super(); super();
// Create a mega-regex from all the tiny baby regexs
this.badWords = new RegExp(
"(" + config.protections.wordlist.words.join(")|(") + ")",
"i"
)
} }
public get name(): string { public get name(): string {
@ -47,7 +41,7 @@ export class WordList extends Protection {
public async handleEvent(mjolnir: Mjolnir, roomId: string, event: any): Promise<any> { public async handleEvent(mjolnir: Mjolnir, roomId: string, event: any): Promise<any> {
const content = event['content'] || {}; const content = event['content'] || {};
const minsBeforeTrusting = config.protections.wordlist.minutesBeforeTrusting; const minsBeforeTrusting = mjolnir.config.protections.wordlist.minutesBeforeTrusting;
if (minsBeforeTrusting > 0) { if (minsBeforeTrusting > 0) {
if (!this.justJoined[roomId]) this.justJoined[roomId] = {}; if (!this.justJoined[roomId]) this.justJoined[roomId] = {};
@ -89,19 +83,29 @@ export class WordList extends Protection {
return return
} }
} }
if (this.badWords === null) {
// Create a mega-regex from all the tiny baby regexs
try {
this.badWords = new RegExp(
"(" + mjolnir.config.protections.wordlist.words.join(")|(") + ")",
"i"
);
} catch (ex) {
await mjolnir.logMessage(LogLevel.ERROR, "WordList", `Could not produce a regex from the word list:\n${ex}.`)
}
}
// Perform the test // Perform the test
if (message && this.badWords.test(message)) { if (message && this.badWords!.test(message)) {
await mjolnir.logMessage(LogLevel.WARN, "WordList", `Banning ${event['sender']} for word list violation in ${roomId}.`); await mjolnir.logMessage(LogLevel.WARN, "WordList", `Banning ${event['sender']} for word list violation in ${roomId}.`);
if (!config.noop) { if (!mjolnir.config.noop) {
await mjolnir.client.banUser(event['sender'], roomId, "Word list violation"); await mjolnir.client.banUser(event['sender'], roomId, "Word list violation");
} else { } else {
await mjolnir.logMessage(LogLevel.WARN, "WordList", `Tried to ban ${event['sender']} in ${roomId} but Mjolnir is running in no-op mode`, roomId); await mjolnir.logMessage(LogLevel.WARN, "WordList", `Tried to ban ${event['sender']} in ${roomId} but Mjolnir is running in no-op mode`, roomId);
} }
// Redact the event // Redact the event
if (!config.noop) { if (!mjolnir.config.noop) {
await mjolnir.client.redactEvent(roomId, event['event_id'], "spam"); await mjolnir.client.redactEvent(roomId, event['event_id'], "spam");
} else { } else {
await mjolnir.logMessage(LogLevel.WARN, "WordList", `Tried to redact ${event['event_id']} in ${roomId} but Mjolnir is running in no-op mode`, roomId); await mjolnir.logMessage(LogLevel.WARN, "WordList", `Tried to redact ${event['event_id']} in ${roomId} but Mjolnir is running in no-op mode`, roomId);

View File

@ -14,7 +14,6 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import { extractRequestError, LogLevel, LogService, Permalinks } from "matrix-bot-sdk"; import { extractRequestError, LogLevel, LogService, Permalinks } from "matrix-bot-sdk";
import config from "../config";
import { Mjolnir } from "../Mjolnir"; import { Mjolnir } from "../Mjolnir";
/** /**
@ -43,7 +42,7 @@ export class UnlistedUserRedactionQueue {
const permalink = Permalinks.forEvent(roomId, event['event_id']); const permalink = Permalinks.forEvent(roomId, event['event_id']);
try { try {
LogService.info("AutomaticRedactionQueue", `Redacting event because the user is listed as bad: ${permalink}`) LogService.info("AutomaticRedactionQueue", `Redacting event because the user is listed as bad: ${permalink}`)
if (!config.noop) { if (!mjolnir.config.noop) {
await mjolnir.client.redactEvent(roomId, event['event_id']); await mjolnir.client.redactEvent(roomId, event['event_id']);
} else { } else {
await mjolnir.logMessage(LogLevel.WARN, "AutomaticRedactionQueue", `Tried to redact ${permalink} but Mjolnir is running in no-op mode`); await mjolnir.logMessage(LogLevel.WARN, "AutomaticRedactionQueue", `Tried to redact ${permalink} but Mjolnir is running in no-op mode`);

View File

@ -20,8 +20,6 @@ import { htmlToText } from "html-to-text";
import { htmlEscape } from "../utils"; import { htmlEscape } from "../utils";
import { JSDOM } from 'jsdom'; import { JSDOM } from 'jsdom';
import { EventEmitter } from 'events'; import { EventEmitter } from 'events';
import config from "../config";
import { Mjolnir } from "../Mjolnir"; import { Mjolnir } from "../Mjolnir";
/// Regexp, used to extract the action label from an action reaction /// Regexp, used to extract the action label from an action reaction
@ -115,7 +113,7 @@ export class ReportManager extends EventEmitter {
*/ */
public async handleServerAbuseReport({ roomId, reporterId, event, reason }: { roomId: string, reporterId: string, event: any, reason?: string }) { public async handleServerAbuseReport({ roomId, reporterId, event, reason }: { roomId: string, reporterId: string, event: any, reason?: string }) {
this.emit("report.new", { roomId: roomId, reporterId: reporterId, event: event, reason: reason }); this.emit("report.new", { roomId: roomId, reporterId: reporterId, event: event, reason: reason });
if (config.displayReports) { if (this.mjolnir.config.displayReports) {
return this.displayManager.displayReportAndUI({ kind: Kind.SERVER_ABUSE_REPORT, event, reporterId, reason, moderationRoomId: this.mjolnir.managementRoomId }); return this.displayManager.displayReportAndUI({ kind: Kind.SERVER_ABUSE_REPORT, event, reporterId, reason, moderationRoomId: this.mjolnir.managementRoomId });
} }
} }

View File

@ -18,7 +18,7 @@ import { Mjolnir, REPORT_POLL_EVENT_TYPE } from "../Mjolnir";
import { ReportManager } from './ReportManager'; import { ReportManager } from './ReportManager';
import { LogLevel } from "matrix-bot-sdk"; import { LogLevel } from "matrix-bot-sdk";
class InvalidStateError extends Error {} class InvalidStateError extends Error { }
/** /**
* A class to poll synapse's report endpoint, so we can act on new reports * A class to poll synapse's report endpoint, so we can act on new reports
@ -108,7 +108,7 @@ export class ReportPoller {
if (response.next_token !== undefined) { if (response.next_token !== undefined) {
this.from = response.next_token; this.from = response.next_token;
try { try {
await this.mjolnir.client.setAccountData(REPORT_POLL_EVENT_TYPE, { from: response.next_token }); await this.mjolnir.client.setAccountData(REPORT_POLL_EVENT_TYPE, { from: response.next_token });
} catch (ex) { } catch (ex) {
await this.mjolnir.logMessage(LogLevel.ERROR, "getAbuseReports", `failed to update progress: ${ex}`); await this.mjolnir.logMessage(LogLevel.ERROR, "getAbuseReports", `failed to update progress: ${ex}`);
} }

View File

@ -28,7 +28,6 @@ import {
setRequestFn, setRequestFn,
} from "matrix-bot-sdk"; } from "matrix-bot-sdk";
import { Mjolnir } from "./Mjolnir"; import { Mjolnir } from "./Mjolnir";
import config from "./config";
import { ClientRequest, IncomingMessage } from "http"; import { ClientRequest, IncomingMessage } from "http";
import { default as parseDuration } from "parse-duration"; import { default as parseDuration } from "parse-duration";
@ -78,7 +77,7 @@ export async function redactUserMessagesIn(mjolnir: Mjolnir, userIdOrGlob: strin
await getMessagesByUserIn(mjolnir.client, userIdOrGlob, targetRoomId, limit, async (eventsToRedact) => { await getMessagesByUserIn(mjolnir.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 mjolnir.logMessage(LogLevel.DEBUG, "utils#redactUserMessagesIn", `Redacting ${victimEvent['event_id']} in ${targetRoomId}`, targetRoomId);
if (!config.noop) { if (!mjolnir.config.noop) {
await mjolnir.client.redactEvent(targetRoomId, victimEvent['event_id']); await mjolnir.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 mjolnir.logMessage(LogLevel.WARN, "utils#redactUserMessagesIn", `Tried to redact ${victimEvent['event_id']} in ${targetRoomId} but Mjolnir is running in no-op mode`, targetRoomId);

View File

@ -15,13 +15,11 @@ limitations under the License.
*/ */
import { Server } from "http"; import { Server } from "http";
import * as express from "express"; import * as express from "express";
import { LogService, MatrixClient } from "matrix-bot-sdk"; import { LogService, MatrixClient } from "matrix-bot-sdk";
import config from "../config";
import RuleServer from "../models/RuleServer"; import RuleServer from "../models/RuleServer";
import { ReportManager } from "../report/ReportManager"; import { ReportManager } from "../report/ReportManager";
import { IConfig } from "../config";
/** /**
@ -35,7 +33,7 @@ export class WebAPIs {
private webController: express.Express = express(); private webController: express.Express = express();
private httpServer?: Server; private httpServer?: Server;
constructor(private reportManager: ReportManager, private readonly ruleServer: RuleServer|null) { constructor(private reportManager: ReportManager, private readonly config: IConfig, private readonly ruleServer: RuleServer|null) {
// Setup JSON parsing. // Setup JSON parsing.
this.webController.use(express.json()); this.webController.use(express.json());
} }
@ -44,14 +42,14 @@ export class WebAPIs {
* Start accepting requests to the Web API. * Start accepting requests to the Web API.
*/ */
public async start() { public async start() {
if (!config.web.enabled) { if (!this.config.web.enabled) {
return; return;
} }
this.httpServer = this.webController.listen(config.web.port, config.web.address); this.httpServer = this.webController.listen(this.config.web.port, this.config.web.address);
// Configure /report API. // configure /report API.
if (config.web.abuseReporting.enabled) { if (this.config.web.abuseReporting.enabled) {
console.log(`Configuring ${API_PREFIX}/report/:room_id/:event_id...`); console.log(`configuring ${API_PREFIX}/report/:room_id/:event_id...`);
this.webController.options(`${API_PREFIX}/report/:room_id/:event_id`, async (request, response) => { this.webController.options(`${API_PREFIX}/report/:room_id/:event_id`, async (request, response) => {
// reply with CORS options // reply with CORS options
response.header("Access-Control-Allow-Origin", "*"); response.header("Access-Control-Allow-Origin", "*");
@ -68,15 +66,15 @@ export class WebAPIs {
response.header("Access-Control-Allow-Methods", "POST, OPTIONS"); response.header("Access-Control-Allow-Methods", "POST, OPTIONS");
await this.handleReport({ request, response, roomId: request.params.room_id, eventId: request.params.event_id }) await this.handleReport({ request, response, roomId: request.params.room_id, eventId: request.params.event_id })
}); });
console.log(`Configuring ${API_PREFIX}/report/:room_id/:event_id... DONE`); console.log(`configuring ${API_PREFIX}/report/:room_id/:event_id... DONE`);
} }
// Configure ruleServer API. // configure ruleServer API.
// FIXME: Doesn't this need some kind of access control? // FIXME: Doesn't this need some kind of access control?
// See https://github.com/matrix-org/mjolnir/issues/139#issuecomment-1012221479. // See https://github.com/matrix-org/mjolnir/issues/139#issuecomment-1012221479.
if (config.web.ruleServer?.enabled) { if (this.config.web.ruleServer?.enabled) {
const updatesUrl = `${API_PREFIX}/ruleserver/updates`; const updatesUrl = `${API_PREFIX}/ruleserver/updates`;
LogService.info("WebAPIs", `Configuring ${updatesUrl}...`); LogService.info("WebAPIs", `configuring ${updatesUrl}...`);
if (!this.ruleServer) { if (!this.ruleServer) {
throw new Error("The rule server to use has not been configured for the WebAPIs."); throw new Error("The rule server to use has not been configured for the WebAPIs.");
} }
@ -84,7 +82,7 @@ export class WebAPIs {
this.webController.get(updatesUrl, async (request, response) => { this.webController.get(updatesUrl, async (request, response) => {
await this.handleRuleServerUpdate(ruleServer, { request, response, since: request.query.since as string}); await this.handleRuleServerUpdate(ruleServer, { request, response, since: request.query.since as string});
}); });
LogService.info("WebAPIs", `Configuring ${updatesUrl}... DONE`); LogService.info("WebAPIs", `configuring ${updatesUrl}... DONE`);
} }
} }
@ -163,7 +161,7 @@ export class WebAPIs {
// so we are not extending the abilities of Mjölnir // so we are not extending the abilities of Mjölnir
// 3. We are avoiding the use of the Synapse Admin API to ensure that // 3. We are avoiding the use of the Synapse Admin API to ensure that
// this feature can work with all homeservers, not just Synapse. // this feature can work with all homeservers, not just Synapse.
let reporterClient = new MatrixClient(config.rawHomeserverUrl, accessToken); let reporterClient = new MatrixClient(this.config.rawHomeserverUrl, accessToken);
reporterClient.start = () => { reporterClient.start = () => {
throw new Error("We MUST NEVER call start on the reporter client"); throw new Error("We MUST NEVER call start on the reporter client");
}; };

View File

@ -18,18 +18,23 @@ import * as expect from "expect";
import { Mjolnir } from "../../src/Mjolnir"; import { Mjolnir } from "../../src/Mjolnir";
import { DEFAULT_LIST_EVENT_TYPE } from "../../src/commands/SetDefaultBanListCommand"; import { DEFAULT_LIST_EVENT_TYPE } from "../../src/commands/SetDefaultBanListCommand";
import { parseArguments } from "../../src/commands/UnbanBanCommand"; import { parseArguments } from "../../src/commands/UnbanBanCommand";
import { RULE_ROOM, RULE_SERVER, RULE_USER } from "../../src/models/BanList"; import config from "../../src/config";
import { RULE_ROOM, RULE_SERVER, RULE_USER } from "../../src/models/ListRule";
function createTestMjolnir(defaultShortcode: string = null): Mjolnir { function createTestMjolnir(defaultShortcode: string|null = null): Mjolnir {
const client = { const client = {
// Mock `MatrixClient.getAccountData` .
getAccountData: (eventType: string): Promise<any> => { getAccountData: (eventType: string): Promise<any> => {
if (eventType === DEFAULT_LIST_EVENT_TYPE && defaultShortcode) { if (eventType === DEFAULT_LIST_EVENT_TYPE || defaultShortcode) {
return Promise.resolve({shortcode: defaultShortcode}); return Promise.resolve({shortcode: defaultShortcode});
} }
throw new Error("Unknown event type"); throw new Error(`Unknown event type ${eventType}, expected ${DEFAULT_LIST_EVENT_TYPE}`);
}, },
}; };
return <Mjolnir>{client}; return <Mjolnir>{
client,
config,
};
} }
function createFakeEvent(command: string): any { function createFakeEvent(command: string): any {
@ -55,11 +60,11 @@ describe("UnbanBanCommand", () => {
const command = "!mjolnir ban test example.org"; const command = "!mjolnir ban test example.org";
const bits = await parseArguments("!a", createFakeEvent(command), mjolnir, command.split(' ')); const bits = await parseArguments("!a", createFakeEvent(command), mjolnir, command.split(' '));
expect(bits).toBeTruthy(); expect(bits).toBeTruthy();
expect(bits.reason).toBeFalsy(); expect(bits!.reason).toBeFalsy();
expect(bits.ruleType).toBe(RULE_SERVER); expect(bits!.ruleType).toBe(RULE_SERVER);
expect(bits.entity).toBe("example.org"); expect(bits!.entity).toBe("example.org");
expect(bits.list).toBeDefined(); expect(bits!.list).toBeDefined();
expect(bits.list.listShortcode).toBe("test"); expect(bits!.list!.listShortcode).toBe("test");
}); });
it("should be able to detect servers with ban reasons", async () => { it("should be able to detect servers with ban reasons", async () => {
@ -72,11 +77,11 @@ describe("UnbanBanCommand", () => {
const command = "!mjolnir ban test example.org reason here"; const command = "!mjolnir ban test example.org reason here";
const bits = await parseArguments("!a", createFakeEvent(command), mjolnir, command.split(' ')); const bits = await parseArguments("!a", createFakeEvent(command), mjolnir, command.split(' '));
expect(bits).toBeTruthy(); expect(bits).toBeTruthy();
expect(bits.reason).toBe("reason here"); expect(bits!.reason).toBe("reason here");
expect(bits.ruleType).toBe(RULE_SERVER); expect(bits!.ruleType).toBe(RULE_SERVER);
expect(bits.entity).toBe("example.org"); expect(bits!.entity).toBe("example.org");
expect(bits.list).toBeDefined(); expect(bits!.list).toBeDefined();
expect(bits.list.listShortcode).toBe("test"); expect(bits!.list!.listShortcode).toBe("test");
}); });
it("should be able to detect servers with globs", async () => { it("should be able to detect servers with globs", async () => {
@ -89,11 +94,11 @@ describe("UnbanBanCommand", () => {
const command = "!mjolnir ban test *.example.org --force"; const command = "!mjolnir ban test *.example.org --force";
const bits = await parseArguments("!a", createFakeEvent(command), mjolnir, command.split(' ')); const bits = await parseArguments("!a", createFakeEvent(command), mjolnir, command.split(' '));
expect(bits).toBeTruthy(); expect(bits).toBeTruthy();
expect(bits.reason).toBeFalsy(); expect(bits!.reason).toBeFalsy();
expect(bits.ruleType).toBe(RULE_SERVER); expect(bits!.ruleType).toBe(RULE_SERVER);
expect(bits.entity).toBe("*.example.org"); expect(bits!.entity).toBe("*.example.org");
expect(bits.list).toBeDefined(); expect(bits!.list).toBeDefined();
expect(bits.list.listShortcode).toBe("test"); expect(bits!.list!.listShortcode).toBe("test");
}); });
it("should be able to detect servers with the type specified", async () => { it("should be able to detect servers with the type specified", async () => {
@ -106,11 +111,11 @@ describe("UnbanBanCommand", () => {
const command = "!mjolnir ban test server @*.example.org --force"; const command = "!mjolnir ban test server @*.example.org --force";
const bits = await parseArguments("!a", createFakeEvent(command), mjolnir, command.split(' ')); const bits = await parseArguments("!a", createFakeEvent(command), mjolnir, command.split(' '));
expect(bits).toBeTruthy(); expect(bits).toBeTruthy();
expect(bits.reason).toBeFalsy(); expect(bits!.reason).toBeFalsy();
expect(bits.ruleType).toBe(RULE_SERVER); expect(bits!.ruleType).toBe(RULE_SERVER);
expect(bits.entity).toBe("@*.example.org"); expect(bits!.entity).toBe("@*.example.org");
expect(bits.list).toBeDefined(); expect(bits!.list).toBeDefined();
expect(bits.list.listShortcode).toBe("test"); expect(bits!.list!.listShortcode).toBe("test");
}); });
it("should be able to detect room IDs", async () => { it("should be able to detect room IDs", async () => {
@ -123,11 +128,11 @@ describe("UnbanBanCommand", () => {
const command = "!mjolnir ban test !example.org"; const command = "!mjolnir ban test !example.org";
const bits = await parseArguments("!a", createFakeEvent(command), mjolnir, command.split(' ')); const bits = await parseArguments("!a", createFakeEvent(command), mjolnir, command.split(' '));
expect(bits).toBeTruthy(); expect(bits).toBeTruthy();
expect(bits.reason).toBeFalsy(); expect(bits!.reason).toBeFalsy();
expect(bits.ruleType).toBe(RULE_ROOM); expect(bits!.ruleType).toBe(RULE_ROOM);
expect(bits.entity).toBe("!example.org"); expect(bits!.entity).toBe("!example.org");
expect(bits.list).toBeDefined(); expect(bits!.list).toBeDefined();
expect(bits.list.listShortcode).toBe("test"); expect(bits!.list!.listShortcode).toBe("test");
}); });
it("should be able to detect room IDs with ban reasons", async () => { it("should be able to detect room IDs with ban reasons", async () => {
@ -140,11 +145,11 @@ describe("UnbanBanCommand", () => {
const command = "!mjolnir ban test !example.org reason here"; const command = "!mjolnir ban test !example.org reason here";
const bits = await parseArguments("!a", createFakeEvent(command), mjolnir, command.split(' ')); const bits = await parseArguments("!a", createFakeEvent(command), mjolnir, command.split(' '));
expect(bits).toBeTruthy(); expect(bits).toBeTruthy();
expect(bits.reason).toBe("reason here"); expect(bits!.reason).toBe("reason here");
expect(bits.ruleType).toBe(RULE_ROOM); expect(bits!.ruleType).toBe(RULE_ROOM);
expect(bits.entity).toBe("!example.org"); expect(bits!.entity).toBe("!example.org");
expect(bits.list).toBeDefined(); expect(bits!.list).toBeDefined();
expect(bits.list.listShortcode).toBe("test"); expect(bits!.list!.listShortcode).toBe("test");
}); });
it("should be able to detect room IDs with globs", async () => { it("should be able to detect room IDs with globs", async () => {
@ -157,11 +162,11 @@ describe("UnbanBanCommand", () => {
const command = "!mjolnir ban test !*.example.org --force"; const command = "!mjolnir ban test !*.example.org --force";
const bits = await parseArguments("!a", createFakeEvent(command), mjolnir, command.split(' ')); const bits = await parseArguments("!a", createFakeEvent(command), mjolnir, command.split(' '));
expect(bits).toBeTruthy(); expect(bits).toBeTruthy();
expect(bits.reason).toBeFalsy(); expect(bits!.reason).toBeFalsy();
expect(bits.ruleType).toBe(RULE_ROOM); expect(bits!.ruleType).toBe(RULE_ROOM);
expect(bits.entity).toBe("!*.example.org"); expect(bits!.entity).toBe("!*.example.org");
expect(bits.list).toBeDefined(); expect(bits!.list).toBeDefined();
expect(bits.list.listShortcode).toBe("test"); expect(bits!.list!.listShortcode).toBe("test");
}); });
it("should be able to detect room aliases", async () => { it("should be able to detect room aliases", async () => {
@ -174,11 +179,11 @@ describe("UnbanBanCommand", () => {
const command = "!mjolnir ban test #example.org"; const command = "!mjolnir ban test #example.org";
const bits = await parseArguments("!a", createFakeEvent(command), mjolnir, command.split(' ')); const bits = await parseArguments("!a", createFakeEvent(command), mjolnir, command.split(' '));
expect(bits).toBeTruthy(); expect(bits).toBeTruthy();
expect(bits.reason).toBeFalsy(); expect(bits!.reason).toBeFalsy();
expect(bits.ruleType).toBe(RULE_ROOM); expect(bits!.ruleType).toBe(RULE_ROOM);
expect(bits.entity).toBe("#example.org"); expect(bits!.entity).toBe("#example.org");
expect(bits.list).toBeDefined(); expect(bits!.list).toBeDefined();
expect(bits.list.listShortcode).toBe("test"); expect(bits!.list!.listShortcode).toBe("test");
}); });
it("should be able to detect room aliases with ban reasons", async () => { it("should be able to detect room aliases with ban reasons", async () => {
@ -191,11 +196,11 @@ describe("UnbanBanCommand", () => {
const command = "!mjolnir ban test #example.org reason here"; const command = "!mjolnir ban test #example.org reason here";
const bits = await parseArguments("!a", createFakeEvent(command), mjolnir, command.split(' ')); const bits = await parseArguments("!a", createFakeEvent(command), mjolnir, command.split(' '));
expect(bits).toBeTruthy(); expect(bits).toBeTruthy();
expect(bits.reason).toBe("reason here"); expect(bits!.reason).toBe("reason here");
expect(bits.ruleType).toBe(RULE_ROOM); expect(bits!.ruleType).toBe(RULE_ROOM);
expect(bits.entity).toBe("#example.org"); expect(bits!.entity).toBe("#example.org");
expect(bits.list).toBeDefined(); expect(bits!.list).toBeDefined();
expect(bits.list.listShortcode).toBe("test"); expect(bits!.list!.listShortcode).toBe("test");
}); });
it("should be able to detect room aliases with globs", async () => { it("should be able to detect room aliases with globs", async () => {
@ -208,11 +213,11 @@ describe("UnbanBanCommand", () => {
const command = "!mjolnir ban test #*.example.org --force"; const command = "!mjolnir ban test #*.example.org --force";
const bits = await parseArguments("!a", createFakeEvent(command), mjolnir, command.split(' ')); const bits = await parseArguments("!a", createFakeEvent(command), mjolnir, command.split(' '));
expect(bits).toBeTruthy(); expect(bits).toBeTruthy();
expect(bits.reason).toBeFalsy(); expect(bits!.reason).toBeFalsy();
expect(bits.ruleType).toBe(RULE_ROOM); expect(bits!.ruleType).toBe(RULE_ROOM);
expect(bits.entity).toBe("#*.example.org"); expect(bits!.entity).toBe("#*.example.org");
expect(bits.list).toBeDefined(); expect(bits!.list).toBeDefined();
expect(bits.list.listShortcode).toBe("test"); expect(bits!.list!.listShortcode).toBe("test");
}); });
it("should be able to detect rooms with the type specified", async () => { it("should be able to detect rooms with the type specified", async () => {
@ -225,11 +230,11 @@ describe("UnbanBanCommand", () => {
const command = "!mjolnir ban test room @*.example.org --force"; const command = "!mjolnir ban test room @*.example.org --force";
const bits = await parseArguments("!a", createFakeEvent(command), mjolnir, command.split(' ')); const bits = await parseArguments("!a", createFakeEvent(command), mjolnir, command.split(' '));
expect(bits).toBeTruthy(); expect(bits).toBeTruthy();
expect(bits.reason).toBeFalsy(); expect(bits!.reason).toBeFalsy();
expect(bits.ruleType).toBe(RULE_ROOM); expect(bits!.ruleType).toBe(RULE_ROOM);
expect(bits.entity).toBe("@*.example.org"); expect(bits!.entity).toBe("@*.example.org");
expect(bits.list).toBeDefined(); expect(bits!.list).toBeDefined();
expect(bits.list.listShortcode).toBe("test"); expect(bits!.list!.listShortcode).toBe("test");
}); });
it("should be able to detect user IDs", async () => { it("should be able to detect user IDs", async () => {
@ -242,11 +247,11 @@ describe("UnbanBanCommand", () => {
const command = "!mjolnir ban test @example.org"; const command = "!mjolnir ban test @example.org";
const bits = await parseArguments("!a", createFakeEvent(command), mjolnir, command.split(' ')); const bits = await parseArguments("!a", createFakeEvent(command), mjolnir, command.split(' '));
expect(bits).toBeTruthy(); expect(bits).toBeTruthy();
expect(bits.reason).toBeFalsy(); expect(bits!.reason).toBeFalsy();
expect(bits.ruleType).toBe(RULE_USER); expect(bits!.ruleType).toBe(RULE_USER);
expect(bits.entity).toBe("@example.org"); expect(bits!.entity).toBe("@example.org");
expect(bits.list).toBeDefined(); expect(bits!.list).toBeDefined();
expect(bits.list.listShortcode).toBe("test"); expect(bits!.list!.listShortcode).toBe("test");
}); });
it("should be able to detect user IDs with ban reasons", async () => { it("should be able to detect user IDs with ban reasons", async () => {
@ -259,11 +264,11 @@ describe("UnbanBanCommand", () => {
const command = "!mjolnir ban test @example.org reason here"; const command = "!mjolnir ban test @example.org reason here";
const bits = await parseArguments("!a", createFakeEvent(command), mjolnir, command.split(' ')); const bits = await parseArguments("!a", createFakeEvent(command), mjolnir, command.split(' '));
expect(bits).toBeTruthy(); expect(bits).toBeTruthy();
expect(bits.reason).toBe("reason here"); expect(bits!.reason).toBe("reason here");
expect(bits.ruleType).toBe(RULE_USER); expect(bits!.ruleType).toBe(RULE_USER);
expect(bits.entity).toBe("@example.org"); expect(bits!.entity).toBe("@example.org");
expect(bits.list).toBeDefined(); expect(bits!.list).toBeDefined();
expect(bits.list.listShortcode).toBe("test"); expect(bits!.list!.listShortcode).toBe("test");
}); });
it("should be able to detect user IDs with globs", async () => { it("should be able to detect user IDs with globs", async () => {
@ -276,11 +281,11 @@ describe("UnbanBanCommand", () => {
const command = "!mjolnir ban test @*.example.org --force"; const command = "!mjolnir ban test @*.example.org --force";
const bits = await parseArguments("!a", createFakeEvent(command), mjolnir, command.split(' ')); const bits = await parseArguments("!a", createFakeEvent(command), mjolnir, command.split(' '));
expect(bits).toBeTruthy(); expect(bits).toBeTruthy();
expect(bits.reason).toBeFalsy(); expect(bits!.reason).toBeFalsy();
expect(bits.ruleType).toBe(RULE_USER); expect(bits!.ruleType).toBe(RULE_USER);
expect(bits.entity).toBe("@*.example.org"); expect(bits!.entity).toBe("@*.example.org");
expect(bits.list).toBeDefined(); expect(bits!.list).toBeDefined();
expect(bits.list.listShortcode).toBe("test"); expect(bits!.list!.listShortcode).toBe("test");
}); });
it("should be able to detect user IDs with the type specified", async () => { it("should be able to detect user IDs with the type specified", async () => {
@ -293,11 +298,11 @@ describe("UnbanBanCommand", () => {
const command = "!mjolnir ban test user #*.example.org --force"; const command = "!mjolnir ban test user #*.example.org --force";
const bits = await parseArguments("!a", createFakeEvent(command), mjolnir, command.split(' ')); const bits = await parseArguments("!a", createFakeEvent(command), mjolnir, command.split(' '));
expect(bits).toBeTruthy(); expect(bits).toBeTruthy();
expect(bits.reason).toBeFalsy(); expect(bits!.reason).toBeFalsy();
expect(bits.ruleType).toBe(RULE_USER); expect(bits!.ruleType).toBe(RULE_USER);
expect(bits.entity).toBe("#*.example.org"); expect(bits!.entity).toBe("#*.example.org");
expect(bits.list).toBeDefined(); expect(bits!.list).toBeDefined();
expect(bits.list.listShortcode).toBe("test"); expect(bits!.list!.listShortcode).toBe("test");
}); });
it("should error if wildcards used without --force", async () => { it("should error if wildcards used without --force", async () => {
@ -324,11 +329,11 @@ describe("UnbanBanCommand", () => {
const command = "!mjolnir ban test user #*.example.org reason here --force"; const command = "!mjolnir ban test user #*.example.org reason here --force";
const bits = await parseArguments("!a", createFakeEvent(command), mjolnir, command.split(' ')); const bits = await parseArguments("!a", createFakeEvent(command), mjolnir, command.split(' '));
expect(bits).toBeTruthy(); expect(bits).toBeTruthy();
expect(bits.reason).toBe("reason here"); expect(bits!.reason).toBe("reason here");
expect(bits.ruleType).toBe(RULE_USER); expect(bits!.ruleType).toBe(RULE_USER);
expect(bits.entity).toBe("#*.example.org"); expect(bits!.entity).toBe("#*.example.org");
expect(bits.list).toBeDefined(); expect(bits!.list).toBeDefined();
expect(bits.list.listShortcode).toBe("test"); expect(bits!.list!.listShortcode).toBe("test");
}); });
describe("[without default list]", () => { describe("[without default list]", () => {
@ -370,11 +375,11 @@ describe("UnbanBanCommand", () => {
const command = "!mjolnir ban user test @example:example.org"; const command = "!mjolnir ban user test @example:example.org";
const bits = await parseArguments("!a", createFakeEvent(command), mjolnir, command.split(' ')); const bits = await parseArguments("!a", createFakeEvent(command), mjolnir, command.split(' '));
expect(bits).toBeTruthy(); expect(bits).toBeTruthy();
expect(bits.reason).toBeFalsy(); expect(bits!.reason).toBeFalsy();
expect(bits.ruleType).toBe(RULE_USER); expect(bits!.ruleType).toBe(RULE_USER);
expect(bits.entity).toBe("@example:example.org"); expect(bits!.entity).toBe("@example:example.org");
expect(bits.list).toBeDefined(); expect(bits!.list).toBeDefined();
expect(bits.list.listShortcode).toBe("test"); expect(bits!.list!.listShortcode).toBe("test");
}); });
it("should not error if a list (without type) is specified", async () => { it("should not error if a list (without type) is specified", async () => {
@ -387,11 +392,11 @@ describe("UnbanBanCommand", () => {
const command = "!mjolnir ban test @example:example.org"; const command = "!mjolnir ban test @example:example.org";
const bits = await parseArguments("!a", createFakeEvent(command), mjolnir, command.split(' ')); const bits = await parseArguments("!a", createFakeEvent(command), mjolnir, command.split(' '));
expect(bits).toBeTruthy(); expect(bits).toBeTruthy();
expect(bits.reason).toBeFalsy(); expect(bits!.reason).toBeFalsy();
expect(bits.ruleType).toBe(RULE_USER); expect(bits!.ruleType).toBe(RULE_USER);
expect(bits.entity).toBe("@example:example.org"); expect(bits!.entity).toBe("@example:example.org");
expect(bits.list).toBeDefined(); expect(bits!.list).toBeDefined();
expect(bits.list.listShortcode).toBe("test"); expect(bits!.list!.listShortcode).toBe("test");
}); });
it("should not error if a list (with type reversed) is specified", async () => { it("should not error if a list (with type reversed) is specified", async () => {
@ -404,11 +409,11 @@ describe("UnbanBanCommand", () => {
const command = "!mjolnir ban test user @example:example.org"; const command = "!mjolnir ban test user @example:example.org";
const bits = await parseArguments("!a", createFakeEvent(command), mjolnir, command.split(' ')); const bits = await parseArguments("!a", createFakeEvent(command), mjolnir, command.split(' '));
expect(bits).toBeTruthy(); expect(bits).toBeTruthy();
expect(bits.reason).toBeFalsy(); expect(bits!.reason).toBeFalsy();
expect(bits.ruleType).toBe(RULE_USER); expect(bits!.ruleType).toBe(RULE_USER);
expect(bits.entity).toBe("@example:example.org"); expect(bits!.entity).toBe("@example:example.org");
expect(bits.list).toBeDefined(); expect(bits!.list).toBeDefined();
expect(bits.list.listShortcode).toBe("test"); expect(bits!.list!.listShortcode).toBe("test");
}); });
}); });
@ -423,11 +428,11 @@ describe("UnbanBanCommand", () => {
const command = "!mjolnir ban user @example:example.org"; const command = "!mjolnir ban user @example:example.org";
const bits = await parseArguments("!a", createFakeEvent(command), mjolnir, command.split(' ')); const bits = await parseArguments("!a", createFakeEvent(command), mjolnir, command.split(' '));
expect(bits).toBeTruthy(); expect(bits).toBeTruthy();
expect(bits.reason).toBeFalsy(); expect(bits!.reason).toBeFalsy();
expect(bits.ruleType).toBe(RULE_USER); expect(bits!.ruleType).toBe(RULE_USER);
expect(bits.entity).toBe("@example:example.org"); expect(bits!.entity).toBe("@example:example.org");
expect(bits.list).toBeDefined(); expect(bits!.list).toBeDefined();
expect(bits.list.listShortcode).toBe("test"); expect(bits!.list!.listShortcode).toBe("test");
}); });
it("should use the default list if no list (without type) is specified", async () => { it("should use the default list if no list (without type) is specified", async () => {
@ -440,11 +445,11 @@ describe("UnbanBanCommand", () => {
const command = "!mjolnir ban @example:example.org"; const command = "!mjolnir ban @example:example.org";
const bits = await parseArguments("!a", createFakeEvent(command), mjolnir, command.split(' ')); const bits = await parseArguments("!a", createFakeEvent(command), mjolnir, command.split(' '));
expect(bits).toBeTruthy(); expect(bits).toBeTruthy();
expect(bits.reason).toBeFalsy(); expect(bits!.reason).toBeFalsy();
expect(bits.ruleType).toBe(RULE_USER); expect(bits!.ruleType).toBe(RULE_USER);
expect(bits.entity).toBe("@example:example.org"); expect(bits!.entity).toBe("@example:example.org");
expect(bits.list).toBeDefined(); expect(bits!.list).toBeDefined();
expect(bits.list.listShortcode).toBe("test"); expect(bits!.list!.listShortcode).toBe("test");
}); });
it("should use the specified list if a list (with type) is specified", async () => { it("should use the specified list if a list (with type) is specified", async () => {
@ -457,11 +462,11 @@ describe("UnbanBanCommand", () => {
const command = "!mjolnir ban user other @example:example.org"; const command = "!mjolnir ban user other @example:example.org";
const bits = await parseArguments("!a", createFakeEvent(command), mjolnir, command.split(' ')); const bits = await parseArguments("!a", createFakeEvent(command), mjolnir, command.split(' '));
expect(bits).toBeTruthy(); expect(bits).toBeTruthy();
expect(bits.reason).toBeFalsy(); expect(bits!.reason).toBeFalsy();
expect(bits.ruleType).toBe(RULE_USER); expect(bits!.ruleType).toBe(RULE_USER);
expect(bits.entity).toBe("@example:example.org"); expect(bits!.entity).toBe("@example:example.org");
expect(bits.list).toBeDefined(); expect(bits!.list).toBeDefined();
expect(bits.list.listShortcode).toBe("other"); expect(bits!.list!.listShortcode).toBe("other");
}); });
it("should use the specified list if a list (without type) is specified", async () => { it("should use the specified list if a list (without type) is specified", async () => {
@ -474,11 +479,11 @@ describe("UnbanBanCommand", () => {
const command = "!mjolnir ban other @example:example.org"; const command = "!mjolnir ban other @example:example.org";
const bits = await parseArguments("!a", createFakeEvent(command), mjolnir, command.split(' ')); const bits = await parseArguments("!a", createFakeEvent(command), mjolnir, command.split(' '));
expect(bits).toBeTruthy(); expect(bits).toBeTruthy();
expect(bits.reason).toBeFalsy(); expect(bits!.reason).toBeFalsy();
expect(bits.ruleType).toBe(RULE_USER); expect(bits!.ruleType).toBe(RULE_USER);
expect(bits.entity).toBe("@example:example.org"); expect(bits!.entity).toBe("@example:example.org");
expect(bits.list).toBeDefined(); expect(bits!.list).toBeDefined();
expect(bits.list.listShortcode).toBe("other"); expect(bits!.list!.listShortcode).toBe("other");
}); });
it("should not error if a list (with type reversed) is specified", async () => { it("should not error if a list (with type reversed) is specified", async () => {
@ -491,11 +496,11 @@ describe("UnbanBanCommand", () => {
const command = "!mjolnir ban other user @example:example.org"; const command = "!mjolnir ban other user @example:example.org";
const bits = await parseArguments("!a", createFakeEvent(command), mjolnir, command.split(' ')); const bits = await parseArguments("!a", createFakeEvent(command), mjolnir, command.split(' '));
expect(bits).toBeTruthy(); expect(bits).toBeTruthy();
expect(bits.reason).toBeFalsy(); expect(bits!.reason).toBeFalsy();
expect(bits.ruleType).toBe(RULE_USER); expect(bits!.ruleType).toBe(RULE_USER);
expect(bits.entity).toBe("@example:example.org"); expect(bits!.entity).toBe("@example:example.org");
expect(bits.list).toBeDefined(); expect(bits!.list).toBeDefined();
expect(bits.list.listShortcode).toBe("other"); expect(bits!.list!.listShortcode).toBe("other");
}); });
}); });
}); });

View File

@ -1,12 +1,12 @@
import { strict as assert } from "assert"; import { strict as assert } from "assert";
import config from "../../src/config";
import { newTestUser } from "./clientHelper"; import { newTestUser } from "./clientHelper";
import { LogService, MatrixClient, Permalinks, UserID } from "matrix-bot-sdk"; import { LogService, MatrixClient, Permalinks, UserID } from "matrix-bot-sdk";
import BanList, { ALL_RULE_TYPES, ChangeType, ListRuleChange, RULE_SERVER, RULE_USER, SERVER_RULE_TYPES } from "../../src/models/BanList"; import PolicyList, { ChangeType, ListRuleChange } from "../../src/models/PolicyList";
import { ServerAcl } from "../../src/models/ServerAcl"; import { ServerAcl } from "../../src/models/ServerAcl";
import { getFirstReaction } from "./commands/commandUtils"; import { getFirstReaction } from "./commands/commandUtils";
import { getMessagesByUserIn } from "../../src/utils"; import { getMessagesByUserIn } from "../../src/utils";
import { Mjolnir } from "../../src/Mjolnir";
import { ALL_RULE_TYPES, RULE_SERVER, RULE_USER, SERVER_RULE_TYPES } from "../../src/models/ListRule";
/** /**
* Create a policy rule in a policy room. * Create a policy rule in a policy room.
@ -18,7 +18,7 @@ import { getMessagesByUserIn } from "../../src/utils";
* @param template The template to use for the policy rule event. * @param template The template to use for the policy rule event.
* @returns The event id of the newly created policy rule. * @returns The event id of the newly created policy rule.
*/ */
async function createPolicyRule(client: MatrixClient, policyRoomId: string, policyType: string, entity: string, reason: string, template = {recommendation: 'm.ban'}) { async function createPolicyRule(client: MatrixClient, policyRoomId: string, policyType: string, entity: string, reason: string, template = { recommendation: 'm.ban' }) {
return await client.sendStateEvent(policyRoomId, policyType, `rule:${entity}`, { return await client.sendStateEvent(policyRoomId, policyType, `rule:${entity}`, {
entity, entity,
reason, reason,
@ -26,36 +26,36 @@ async function createPolicyRule(client: MatrixClient, policyRoomId: string, poli
}); });
} }
describe("Test: Updating the BanList", function () { describe("Test: Updating the PolicyList", function() {
it("Calculates what has changed correctly.", async function () { it("Calculates what has changed correctly.", async function() {
this.timeout(10000); this.timeout(10000);
const mjolnir = config.RUNTIME.client! const mjolnir: Mjolnir = this.mjolnir!
const moderator = await newTestUser({ name: { contains: "moderator" }}); const moderator = await newTestUser({ name: { contains: "moderator" } });
const banListId = await mjolnir.createRoom({ invite: [await moderator.getUserId()]}); const banListId = await mjolnir.client.createRoom({ invite: [await moderator.getUserId()] });
const banList = new BanList(banListId, banListId, mjolnir); const banList = new PolicyList(banListId, banListId, mjolnir.client);
mjolnir.setUserPowerLevel(await moderator.getUserId(), banListId, 100); mjolnir.client.setUserPowerLevel(await moderator.getUserId(), banListId, 100);
assert.equal(banList.allRules.length, 0); assert.equal(banList.allRules.length, 0);
// Test adding a new rule // Test adding a new rule
await createPolicyRule(mjolnir, banListId, RULE_USER, '@added:localhost:9999', ''); await createPolicyRule(mjolnir.client, banListId, RULE_USER, '@added:localhost:9999', '');
let changes: ListRuleChange[] = await banList.updateList(); let changes: ListRuleChange[] = await banList.updateList();
assert.equal(changes.length, 1, 'There should only be one change'); assert.equal(changes.length, 1, 'There should only be one change');
assert.equal(changes[0].changeType, ChangeType.Added); assert.equal(changes[0].changeType, ChangeType.Added);
assert.equal(changes[0].sender, await mjolnir.getUserId()); assert.equal(changes[0].sender, await mjolnir.client.getUserId());
assert.equal(banList.userRules.length, 1); assert.equal(banList.userRules.length, 1);
assert.equal(banList.allRules.length, 1); assert.equal(banList.allRules.length, 1);
// Test modifiying a rule // Test modifiying a rule
let originalEventId = await createPolicyRule(mjolnir, banListId, RULE_USER, '@modified:localhost:9999', ''); let originalEventId = await createPolicyRule(mjolnir.client, banListId, RULE_USER, '@modified:localhost:9999', '');
await banList.updateList(); await banList.updateList();
let modifyingEventId = await createPolicyRule(mjolnir, banListId, RULE_USER, '@modified:localhost:9999', 'modified reason'); let modifyingEventId = await createPolicyRule(mjolnir.client, banListId, RULE_USER, '@modified:localhost:9999', 'modified reason');
changes = await banList.updateList(); changes = await banList.updateList();
assert.equal(changes.length, 1); assert.equal(changes.length, 1);
assert.equal(changes[0].changeType, ChangeType.Modified); assert.equal(changes[0].changeType, ChangeType.Modified);
assert.equal(changes[0].previousState['event_id'], originalEventId, 'There should be a previous state event for a modified rule'); assert.equal(changes[0].previousState['event_id'], originalEventId, 'There should be a previous state event for a modified rule');
assert.equal(changes[0].event['event_id'], modifyingEventId); assert.equal(changes[0].event['event_id'], modifyingEventId);
let modifyingAgainEventId = await createPolicyRule(mjolnir, banListId, RULE_USER, '@modified:localhost:9999', 'modified again'); let modifyingAgainEventId = await createPolicyRule(mjolnir.client, banListId, RULE_USER, '@modified:localhost:9999', 'modified again');
changes = await banList.updateList(); changes = await banList.updateList();
assert.equal(changes.length, 1); assert.equal(changes.length, 1);
assert.equal(changes[0].changeType, ChangeType.Modified); assert.equal(changes[0].changeType, ChangeType.Modified);
@ -64,10 +64,10 @@ describe("Test: Updating the BanList", function () {
assert.equal(banList.userRules.length, 2, 'There should be two rules, one for @modified:localhost:9999 and one for @added:localhost:9999'); assert.equal(banList.userRules.length, 2, 'There should be two rules, one for @modified:localhost:9999 and one for @added:localhost:9999');
// Test redacting a rule // Test redacting a rule
const redactThis = await createPolicyRule(mjolnir, banListId, RULE_USER, '@redacted:localhost:9999', ''); const redactThis = await createPolicyRule(mjolnir.client, banListId, RULE_USER, '@redacted:localhost:9999', '');
await banList.updateList(); await banList.updateList();
assert.equal(banList.userRules.filter(r => r.entity === '@redacted:localhost:9999').length, 1); assert.equal(banList.userRules.filter(r => r.entity === '@redacted:localhost:9999').length, 1);
await mjolnir.redactEvent(banListId, redactThis); await mjolnir.client.redactEvent(banListId, redactThis);
changes = await banList.updateList(); changes = await banList.updateList();
assert.equal(changes.length, 1); assert.equal(changes.length, 1);
assert.equal(changes[0].changeType, ChangeType.Removed); assert.equal(changes[0].changeType, ChangeType.Removed);
@ -79,10 +79,10 @@ describe("Test: Updating the BanList", function () {
// Test soft redaction of a rule // Test soft redaction of a rule
const softRedactedEntity = '@softredacted:localhost:9999' const softRedactedEntity = '@softredacted:localhost:9999'
await createPolicyRule(mjolnir, banListId, RULE_USER, softRedactedEntity, ''); await createPolicyRule(mjolnir.client, banListId, RULE_USER, softRedactedEntity, '');
await banList.updateList(); await banList.updateList();
assert.equal(banList.userRules.filter(r => r.entity === softRedactedEntity).length, 1); assert.equal(banList.userRules.filter(r => r.entity === softRedactedEntity).length, 1);
await mjolnir.sendStateEvent(banListId, RULE_USER, `rule:${softRedactedEntity}`, {}); await mjolnir.client.sendStateEvent(banListId, RULE_USER, `rule:${softRedactedEntity}`, {});
changes = await banList.updateList(); changes = await banList.updateList();
assert.equal(changes.length, 1); assert.equal(changes.length, 1);
assert.equal(changes[0].changeType, ChangeType.Removed); assert.equal(changes[0].changeType, ChangeType.Removed);
@ -92,25 +92,25 @@ describe("Test: Updating the BanList", function () {
assert.equal(banList.userRules.filter(r => r.entity === softRedactedEntity).length, 0, 'The rule should have been removed'); assert.equal(banList.userRules.filter(r => r.entity === softRedactedEntity).length, 0, 'The rule should have been removed');
// Now test a double soft redaction just to make sure stuff doesn't explode // Now test a double soft redaction just to make sure stuff doesn't explode
await mjolnir.sendStateEvent(banListId, RULE_USER, `rule:${softRedactedEntity}`, {}); await mjolnir.client.sendStateEvent(banListId, RULE_USER, `rule:${softRedactedEntity}`, {});
changes = await banList.updateList(); changes = await banList.updateList();
assert.equal(changes.length, 0, "It shouldn't detect a double soft redaction as a change, it should be seen as adding an invalid rule."); assert.equal(changes.length, 0, "It shouldn't detect a double soft redaction as a change, it should be seen as adding an invalid rule.");
assert.equal(banList.userRules.filter(r => r.entity === softRedactedEntity).length, 0, 'The rule should have been removed'); assert.equal(banList.userRules.filter(r => r.entity === softRedactedEntity).length, 0, 'The rule should have been removed');
// Test that different (old) rule types will be modelled as the latest event type. // Test that different (old) rule types will be modelled as the latest event type.
originalEventId = await createPolicyRule(mjolnir, banListId, 'org.matrix.mjolnir.rule.user', '@old:localhost:9999', ''); originalEventId = await createPolicyRule(mjolnir.client, banListId, 'org.matrix.mjolnir.rule.user', '@old:localhost:9999', '');
changes = await banList.updateList(); changes = await banList.updateList();
assert.equal(changes.length, 1); assert.equal(changes.length, 1);
assert.equal(changes[0].changeType, ChangeType.Added); assert.equal(changes[0].changeType, ChangeType.Added);
assert.equal(banList.userRules.filter(r => r.entity === '@old:localhost:9999').length, 1); assert.equal(banList.userRules.filter(r => r.entity === '@old:localhost:9999').length, 1);
modifyingEventId = await createPolicyRule(mjolnir, banListId, 'm.room.rule.user', '@old:localhost:9999', 'modified reason'); modifyingEventId = await createPolicyRule(mjolnir.client, banListId, 'm.room.rule.user', '@old:localhost:9999', 'modified reason');
changes = await banList.updateList(); changes = await banList.updateList();
assert.equal(changes.length, 1); assert.equal(changes.length, 1);
assert.equal(changes[0].changeType, ChangeType.Modified); assert.equal(changes[0].changeType, ChangeType.Modified);
assert.equal(changes[0].event['event_id'], modifyingEventId); assert.equal(changes[0].event['event_id'], modifyingEventId);
assert.equal(changes[0].previousState['event_id'], originalEventId, 'There should be a previous state event for a modified rule'); assert.equal(changes[0].previousState['event_id'], originalEventId, 'There should be a previous state event for a modified rule');
assert.equal(banList.userRules.filter(r => r.entity === '@old:localhost:9999').length, 1); assert.equal(banList.userRules.filter(r => r.entity === '@old:localhost:9999').length, 1);
modifyingAgainEventId = await createPolicyRule(mjolnir, banListId, RULE_USER, '@old:localhost:9999', 'changes again'); modifyingAgainEventId = await createPolicyRule(mjolnir.client, banListId, RULE_USER, '@old:localhost:9999', 'changes again');
changes = await banList.updateList(); changes = await banList.updateList();
assert.equal(changes.length, 1); assert.equal(changes.length, 1);
assert.equal(changes[0].changeType, ChangeType.Modified); assert.equal(changes[0].changeType, ChangeType.Modified);
@ -118,21 +118,21 @@ describe("Test: Updating the BanList", function () {
assert.equal(changes[0].previousState['event_id'], modifyingEventId, 'There should be a previous state event for a modified rule'); assert.equal(changes[0].previousState['event_id'], modifyingEventId, 'There should be a previous state event for a modified rule');
assert.equal(banList.userRules.filter(r => r.entity === '@old:localhost:9999').length, 1); assert.equal(banList.userRules.filter(r => r.entity === '@old:localhost:9999').length, 1);
}) })
it("Will remove rules with old types when they are 'soft redacted' with a different but more recent event type.", async function () { it("Will remove rules with old types when they are 'soft redacted' with a different but more recent event type.", async function() {
this.timeout(3000); this.timeout(3000);
const mjolnir = config.RUNTIME.client! const mjolnir: Mjolnir = this.mjolnir!
const moderator = await newTestUser({ name: { contains: "moderator" }}); const moderator = await newTestUser({ name: { contains: "moderator" }} );
const banListId = await mjolnir.createRoom({ invite: [await moderator.getUserId()]}); const banListId = await mjolnir.client.createRoom({ invite: [await moderator.getUserId()] });
const banList = new BanList(banListId, banListId, mjolnir); const banList = new PolicyList(banListId, banListId, mjolnir.client);
mjolnir.setUserPowerLevel(await moderator.getUserId(), banListId, 100); mjolnir.client.setUserPowerLevel(await moderator.getUserId(), banListId, 100);
const entity = '@old:localhost:9999'; const entity = '@old:localhost:9999';
let originalEventId = await createPolicyRule(mjolnir, banListId, 'm.room.rule.user', entity, ''); let originalEventId = await createPolicyRule(mjolnir.client, banListId, 'm.room.rule.user', entity, '');
let changes = await banList.updateList(); let changes = await banList.updateList();
assert.equal(changes.length, 1); assert.equal(changes.length, 1);
assert.equal(changes[0].changeType, ChangeType.Added); assert.equal(changes[0].changeType, ChangeType.Added);
assert.equal(banList.userRules.filter(rule => rule.entity === entity).length, 1, 'There should be a rule stored that we just added...') assert.equal(banList.userRules.filter(rule => rule.entity === entity).length, 1, 'There should be a rule stored that we just added...')
let softRedactingEventId = await mjolnir.sendStateEvent(banListId, RULE_USER, `rule:${entity}`, {}); let softRedactingEventId = await mjolnir.client.sendStateEvent(banListId, RULE_USER, `rule:${entity}`, {});
changes = await banList.updateList(); changes = await banList.updateList();
assert.equal(changes.length, 1); assert.equal(changes.length, 1);
assert.equal(changes[0].changeType, ChangeType.Removed); assert.equal(changes[0].changeType, ChangeType.Removed);
@ -140,21 +140,21 @@ describe("Test: Updating the BanList", function () {
assert.equal(changes[0].previousState['event_id'], originalEventId, 'There should be a previous state event for a modified rule'); assert.equal(changes[0].previousState['event_id'], originalEventId, 'There should be a previous state event for a modified rule');
assert.equal(banList.userRules.filter(rule => rule.entity === entity).length, 0, 'The rule should no longer be stored.'); assert.equal(banList.userRules.filter(rule => rule.entity === entity).length, 0, 'The rule should no longer be stored.');
}) })
it("A rule of the most recent type won't be deleted when an old rule is deleted for the same entity.", async function () { it("A rule of the most recent type won't be deleted when an old rule is deleted for the same entity.", async function() {
this.timeout(3000); this.timeout(3000);
const mjolnir = config.RUNTIME.client! const mjolnir: Mjolnir = this.mjolnir!
const moderator = await newTestUser({ name: { contains: "moderator" }}); const moderator = await newTestUser({ name: { contains: "moderator" } });
const banListId = await mjolnir.createRoom({ invite: [await moderator.getUserId()]}); const banListId = await mjolnir.client.createRoom({ invite: [await moderator.getUserId()] });
const banList = new BanList(banListId, banListId, mjolnir); const banList = new PolicyList(banListId, banListId, mjolnir.client);
mjolnir.setUserPowerLevel(await moderator.getUserId(), banListId, 100); mjolnir.client.setUserPowerLevel(await moderator.getUserId(), banListId, 100);
const entity = '@old:localhost:9999'; const entity = '@old:localhost:9999';
let originalEventId = await createPolicyRule(mjolnir, banListId, 'm.room.rule.user', entity, ''); let originalEventId = await createPolicyRule(mjolnir.client, banListId, 'm.room.rule.user', entity, '');
let changes = await banList.updateList(); let changes = await banList.updateList();
assert.equal(changes.length, 1); assert.equal(changes.length, 1);
assert.equal(changes[0].changeType, ChangeType.Added); assert.equal(changes[0].changeType, ChangeType.Added);
assert.equal(banList.userRules.filter(rule => rule.entity === entity).length, 1, 'There should be a rule stored that we just added...') assert.equal(banList.userRules.filter(rule => rule.entity === entity).length, 1, 'There should be a rule stored that we just added...')
let updatedEventId = await createPolicyRule(mjolnir, banListId, RULE_USER, entity, ''); let updatedEventId = await createPolicyRule(mjolnir.client, banListId, RULE_USER, entity, '');
changes = await banList.updateList(); changes = await banList.updateList();
// If in the future you change this and it fails, it's really subjective whether this constitutes a modification, since the only thing that has changed // If in the future you change this and it fails, it's really subjective whether this constitutes a modification, since the only thing that has changed
// is the rule type. The actual content is identical. // is the rule type. The actual content is identical.
@ -165,13 +165,13 @@ describe("Test: Updating the BanList", function () {
assert.equal(banList.userRules.filter(rule => rule.entity === entity).length, 1, 'Only the latest version of the rule gets returned.'); assert.equal(banList.userRules.filter(rule => rule.entity === entity).length, 1, 'Only the latest version of the rule gets returned.');
// Now we delete the old version of the rule without consequence. // Now we delete the old version of the rule without consequence.
await mjolnir.sendStateEvent(banListId, 'm.room.rule.user', `rule:${entity}`, {}); await mjolnir.client.sendStateEvent(banListId, 'm.room.rule.user', `rule:${entity}`, {});
changes = await banList.updateList(); changes = await banList.updateList();
assert.equal(changes.length, 0); assert.equal(changes.length, 0);
assert.equal(banList.userRules.filter(rule => rule.entity === entity).length, 1, 'The rule should still be active.'); assert.equal(banList.userRules.filter(rule => rule.entity === entity).length, 1, 'The rule should still be active.');
// And we can still delete the new version of the rule. // And we can still delete the new version of the rule.
let softRedactingEventId = await mjolnir.sendStateEvent(banListId, RULE_USER, `rule:${entity}`, {}); let softRedactingEventId = await mjolnir.client.sendStateEvent(banListId, RULE_USER, `rule:${entity}`, {});
changes = await banList.updateList(); changes = await banList.updateList();
assert.equal(changes.length, 1); assert.equal(changes.length, 1);
assert.equal(changes[0].changeType, ChangeType.Removed); assert.equal(changes[0].changeType, ChangeType.Removed);
@ -179,12 +179,12 @@ describe("Test: Updating the BanList", function () {
assert.equal(changes[0].previousState['event_id'], updatedEventId, 'There should be a previous state event for a modified rule'); assert.equal(changes[0].previousState['event_id'], updatedEventId, 'There should be a previous state event for a modified rule');
assert.equal(banList.userRules.filter(rule => rule.entity === entity).length, 0, 'The rule should no longer be stored.'); assert.equal(banList.userRules.filter(rule => rule.entity === entity).length, 0, 'The rule should no longer be stored.');
}) })
it('Test: BanList Supports all entity types.', async function () { it('Test: PolicyList Supports all entity types.', async function () {
const mjolnir = config.RUNTIME.client! const mjolnir: Mjolnir = this.mjolnir!
const banListId = await mjolnir.createRoom(); const banListId = await mjolnir.client.createRoom();
const banList = new BanList(banListId, banListId, mjolnir); const banList = new PolicyList(banListId, banListId, mjolnir.client);
for (let i = 0; i < ALL_RULE_TYPES.length; i++) { for (let i = 0; i < ALL_RULE_TYPES.length; i++) {
await createPolicyRule(mjolnir, banListId, ALL_RULE_TYPES[i], `*${i}*`, ''); await createPolicyRule(mjolnir.client, banListId, ALL_RULE_TYPES[i], `*${i}*`, '');
} }
let changes: ListRuleChange[] = await banList.updateList(); let changes: ListRuleChange[] = await banList.updateList();
assert.equal(changes.length, ALL_RULE_TYPES.length); assert.equal(changes.length, ALL_RULE_TYPES.length);
@ -192,31 +192,31 @@ describe("Test: Updating the BanList", function () {
}) })
}); });
describe('Test: We do not respond to recommendations other than m.ban in the banlist', function () { describe('Test: We do not respond to recommendations other than m.ban in the PolicyList', function() {
it('Will not respond to a rule that has a different recommendation to m.ban (or the unstable equivalent).', async function () { it('Will not respond to a rule that has a different recommendation to m.ban (or the unstable equivalent).', async function() {
const mjolnir = config.RUNTIME.client! const mjolnir: Mjolnir = this.mjolnir!
const banListId = await mjolnir.createRoom(); const banListId = await mjolnir.client.createRoom();
const banList = new BanList(banListId, banListId, mjolnir); const banList = new PolicyList(banListId, banListId, mjolnir.client);
await createPolicyRule(mjolnir, banListId, RULE_SERVER, 'exmaple.org', '', {recommendation: 'something that is not m.ban'}); await createPolicyRule(mjolnir.client, banListId, RULE_SERVER, 'exmaple.org', '', { recommendation: 'something that is not m.ban' });
let changes: ListRuleChange[] = await banList.updateList(); let changes: ListRuleChange[] = await banList.updateList();
assert.equal(changes.length, 1, 'There should only be one change'); assert.equal(changes.length, 1, 'There should only be one change');
assert.equal(changes[0].changeType, ChangeType.Added); assert.equal(changes[0].changeType, ChangeType.Added);
assert.equal(changes[0].sender, await mjolnir.getUserId()); assert.equal(changes[0].sender, await mjolnir.client.getUserId());
// We really don't want things that aren't m.ban to end up being accessible in these APIs. // We really don't want things that aren't m.ban to end up being accessible in these APIs.
assert.equal(banList.serverRules.length, 0); assert.equal(banList.serverRules.length, 0, `We should have an empty serverRules, got ${JSON.stringify(banList.serverRules)}`);
assert.equal(banList.allRules.length, 0); assert.equal(banList.allRules.length, 0, `We should have an empty allRules, got ${JSON.stringify(banList.allRules)}`);
}) })
}) })
describe('Test: We will not be able to ban ourselves via ACL.', function () { describe('Test: We will not be able to ban ourselves via ACL.', function() {
it('We do not ban ourselves when we put ourselves into the policy list.', async function () { it('We do not ban ourselves when we put ourselves into the policy list.', async function() {
const mjolnir = config.RUNTIME.client! const mjolnir: Mjolnir = this.mjolnir
const serverName = new UserID(await mjolnir.getUserId()).domain; const serverName = new UserID(await mjolnir.client.getUserId()).domain;
const banListId = await mjolnir.createRoom(); const banListId = await mjolnir.client.createRoom();
const banList = new BanList(banListId, banListId, mjolnir); const banList = new PolicyList(banListId, banListId, mjolnir.client);
await createPolicyRule(mjolnir, banListId, RULE_SERVER, serverName, ''); await createPolicyRule(mjolnir.client, banListId, RULE_SERVER, serverName, '');
await createPolicyRule(mjolnir, banListId, RULE_SERVER, 'evil.com', ''); await createPolicyRule(mjolnir.client, banListId, RULE_SERVER, 'evil.com', '');
await createPolicyRule(mjolnir, banListId, RULE_SERVER, '*', ''); await createPolicyRule(mjolnir.client, banListId, RULE_SERVER, '*', '');
// We should still intern the matching rules rule. // We should still intern the matching rules rule.
let changes: ListRuleChange[] = await banList.updateList(); let changes: ListRuleChange[] = await banList.updateList();
assert.equal(banList.serverRules.length, 3); assert.equal(banList.serverRules.length, 3);
@ -229,62 +229,65 @@ describe('Test: We will not be able to ban ourselves via ACL.', function () {
}) })
describe('Test: ACL updates will batch when rules are added in succession.', function () { describe('Test: ACL updates will batch when rules are added in succession.', function() {
it('Will batch ACL updates if we spam rules into a BanList', async function () { it('Will batch ACL updates if we spam rules into a PolicyList', async function() {
const mjolnir = config.RUNTIME.client! const mjolnir: Mjolnir = this.mjolnir!
const serverName: string = new UserID(await mjolnir.getUserId()).domain const serverName: string = new UserID(await mjolnir.client.getUserId()).domain
const moderator = await newTestUser({ name: { contains: "moderator" }}); const moderator = await newTestUser({ name: { contains: "moderator" } });
moderator.joinRoom(this.mjolnir.managementRoomId); moderator.joinRoom(this.mjolnir.client.managementRoomId);
const mjolnirId = await mjolnir.getUserId(); const mjolnirId = await mjolnir.client.getUserId();
// Setup some protected rooms so we can check their ACL state later. // Setup some protected rooms so we can check their ACL state later.
const protectedRooms: string[] = []; const protectedRooms: string[] = [];
for (let i = 0; i < 10; i++) { for (let i = 0; i < 10; i++) {
const room = await moderator.createRoom({ invite: [mjolnirId]}); const room = await moderator.createRoom({ invite: [mjolnirId] });
await mjolnir.joinRoom(room); await mjolnir.client.joinRoom(room);
await moderator.setUserPowerLevel(mjolnirId, room, 100); await moderator.setUserPowerLevel(mjolnirId, room, 100);
await this.mjolnir!.addProtectedRoom(room); await mjolnir.addProtectedRoom(room);
protectedRooms.push(room); protectedRooms.push(room);
} }
// If a previous test hasn't cleaned up properly, these rooms will be populated by bogus ACLs at this point. // If a previous test hasn't cleaned up properly, these rooms will be populated by bogus ACLs at this point.
await this.mjolnir!.syncLists(); await mjolnir.syncLists();
await Promise.all(protectedRooms.map(async room => { await Promise.all(protectedRooms.map(async room => {
// We're going to need timeline pagination I'm afraid. // We're going to need timeline pagination I'm afraid.
const roomAcl = await mjolnir.getRoomStateEvent(room, "m.room.server_acl", ""); const roomAcl = await mjolnir.client.getRoomStateEvent(room, "m.room.server_acl", "");
assert.equal(roomAcl?.deny?.length ?? 0, 0, 'There should be no entries in the deny ACL.'); assert.equal(roomAcl?.deny?.length ?? 0, 0, 'There should be no entries in the deny ACL.');
})); }));
// Flood the watched list with banned servers, which should prompt Mjolnir to update server ACL in protected rooms. // Flood the watched list with banned servers, which should prompt Mjolnir to update server ACL in protected rooms.
const banListId = await moderator.createRoom({ invite: [mjolnirId] }); const banListId = await moderator.createRoom({ invite: [mjolnirId] });
mjolnir.joinRoom(banListId); mjolnir.client.joinRoom(banListId);
this.mjolnir!.watchList(Permalinks.forRoom(banListId)); mjolnir.watchList(Permalinks.forRoom(banListId));
const acl = new ServerAcl(serverName).denyIpAddresses().allowServer("*"); const acl = new ServerAcl(serverName).denyIpAddresses().allowServer("*");
for (let i = 0; i < 200; i++) { const evilServerCount = 200;
for (let i = 0; i < evilServerCount; i++) {
const badServer = `${i}.evil.com`; const badServer = `${i}.evil.com`;
acl.denyServer(badServer); acl.denyServer(badServer);
await createPolicyRule(moderator, banListId, RULE_SERVER, badServer, `Rule #${i}`); await createPolicyRule(moderator, banListId, RULE_SERVER, badServer, `Rule #${i}`);
// Give them a bit of a spread over time. // Give them a bit of a spread over time.
await new Promise(resolve => setTimeout(resolve, 5)); await new Promise(resolve => setTimeout(resolve, 5));
} }
// give the events a chance to appear in the response to `/state`, since this is a problem.
await new Promise(resolve => setTimeout(resolve, 2000));
// We do this because it should force us to wait until all the ACL events have been applied. // We do this because it should force us to wait until all the ACL events have been applied.
// Even if that does mean the last few events will not go through batching... // Even if that does mean the last few events will not go through batching...
await this.mjolnir!.syncLists(); await mjolnir.syncLists();
// At this point we check that the state within Mjolnir is internally consistent, this is just because debugging the following
// is a pita.
const list: PolicyList = this.mjolnir.policyLists[0]!;
assert.equal(list.serverRules.length, evilServerCount, `There should be ${evilServerCount} rules in here`);
// Check each of the protected rooms for ACL events and make sure they were batched and are correct. // Check each of the protected rooms for ACL events and make sure they were batched and are correct.
await Promise.all(protectedRooms.map(async room => { await Promise.all(protectedRooms.map(async room => {
const roomAcl = await mjolnir.getRoomStateEvent(room, "m.room.server_acl", ""); const roomAcl = await mjolnir.client.getRoomStateEvent(room, "m.room.server_acl", "");
if (!acl.matches(roomAcl)) { if (!acl.matches(roomAcl)) {
assert.fail(`Room ${room} doesn't have the correct ACL: ${JSON.stringify(roomAcl, null, 2)}`) assert.fail(`Room ${room} doesn't have the correct ACL: ${JSON.stringify(roomAcl, null, 2)}`)
} }
let aclEventCount = 0; let aclEventCount = 0;
await getMessagesByUserIn(mjolnir, mjolnirId, room, 100, events => { await getMessagesByUserIn(mjolnir.client, mjolnirId, room, 100, events => {
events.forEach(event => event.type === 'm.room.server_acl' ? aclEventCount += 1 : null); events.forEach(event => event.type === 'm.room.server_acl' ? aclEventCount += 1 : null);
}); });
LogService.debug('BanListTest', `aclEventCount: ${aclEventCount}`); LogService.debug('PolicyListTest', `aclEventCount: ${aclEventCount}`);
// If there's less than two then it means the ACL was updated by this test calling `this.mjolnir!.syncLists()` // If there's less than two then it means the ACL was updated by this test calling `this.mjolnir!.syncLists()`
// and not the listener that detects changes to ban lists (that we want to test!). // and not the listener that detects changes to ban lists (that we want to test!).
// It used to be 10, but it was too low, 30 seems better for CI. // It used to be 10, but it was too low, 30 seems better for CI.
@ -293,36 +296,36 @@ describe('Test: ACL updates will batch when rules are added in succession.', fun
}) })
}) })
describe('Test: unbaning entities via the BanList.', function () { describe('Test: unbaning entities via the PolicyList.', function() {
afterEach(function() { this.moderator?.stop(); }); afterEach(function() { this.moderator?.stop(); });
it('Will remove rules that have legacy types', async function () { it('Will remove rules that have legacy types', async function() {
this.timeout(20000) this.timeout(20000)
const mjolnir = config.RUNTIME.client! const mjolnir: Mjolnir = this.mjolnir!
const serverName: string = new UserID(await mjolnir.getUserId()).domain const serverName: string = new UserID(await mjolnir.client.getUserId()).domain
const moderator = await newTestUser({ name: { contains: "moderator" }}); const moderator = await newTestUser({ name: { contains: "moderator" } });
this.moderator = moderator; this.moderator = moderator;
moderator.joinRoom(this.mjolnir.managementRoomId); moderator.joinRoom(mjolnir.managementRoomId);
const mjolnirId = await mjolnir.getUserId(); const mjolnirId = await mjolnir.client.getUserId();
// We'll make 1 protected room to test ACLs in. // We'll make 1 protected room to test ACLs in.
const protectedRoom = await moderator.createRoom({ invite: [mjolnirId]}); const protectedRoom = await moderator.createRoom({ invite: [mjolnirId] });
await mjolnir.joinRoom(protectedRoom); await mjolnir.client.joinRoom(protectedRoom);
await moderator.setUserPowerLevel(mjolnirId, protectedRoom, 100); await moderator.setUserPowerLevel(mjolnirId, protectedRoom, 100);
await this.mjolnir!.addProtectedRoom(protectedRoom); await mjolnir.addProtectedRoom(protectedRoom);
// If a previous test hasn't cleaned up properly, these rooms will be populated by bogus ACLs at this point. // If a previous test hasn't cleaned up properly, these rooms will be populated by bogus ACLs at this point.
await this.mjolnir!.syncLists(); await mjolnir.syncLists();
const roomAcl = await mjolnir.getRoomStateEvent(protectedRoom, "m.room.server_acl", ""); const roomAcl = await mjolnir.client.getRoomStateEvent(protectedRoom, "m.room.server_acl", "");
assert.equal(roomAcl?.deny?.length ?? 0, 0, 'There should be no entries in the deny ACL.'); assert.equal(roomAcl?.deny?.length ?? 0, 0, 'There should be no entries in the deny ACL.');
// Create some legacy rules on a BanList. // Create some legacy rules on a PolicyList.
const banListId = await moderator.createRoom({ invite: [mjolnirId] }); const banListId = await moderator.createRoom({ invite: [mjolnirId] });
await moderator.setUserPowerLevel(await mjolnir.getUserId(), banListId, 100); await moderator.setUserPowerLevel(await mjolnir.client.getUserId(), banListId, 100);
await moderator.sendStateEvent(banListId, 'org.matrix.mjolnir.shortcode', '', { shortcode: "unban-test"}); await moderator.sendStateEvent(banListId, 'org.matrix.mjolnir.shortcode', '', { shortcode: "unban-test" });
await mjolnir.joinRoom(banListId); await mjolnir.client.joinRoom(banListId);
this.mjolnir!.watchList(Permalinks.forRoom(banListId)); this.mjolnir!.watchList(Permalinks.forRoom(banListId));
// we use this to compare changes. // we use this to compare changes.
const banList = new BanList(banListId, banListId, moderator); const banList = new PolicyList(banListId, banListId, moderator);
// we need two because we need to test the case where an entity has all rule types in the list // we need two because we need to test the case where an entity has all rule types in the list
// and another one that only has one (so that we would hit 404 while looking up state) // and another one that only has one (so that we would hit 404 while looking up state)
const olderBadServer = "old.evil.com" const olderBadServer = "old.evil.com"
@ -338,7 +341,7 @@ describe('Test: unbaning entities via the BanList.', function () {
// Check that we have setup our test properly and therefore evil.com is banned. // Check that we have setup our test properly and therefore evil.com is banned.
const acl = new ServerAcl(serverName).denyIpAddresses().allowServer("*").denyServer(olderBadServer).denyServer(newerBadServer); const acl = new ServerAcl(serverName).denyIpAddresses().allowServer("*").denyServer(olderBadServer).denyServer(newerBadServer);
const protectedAcl = await mjolnir.getRoomStateEvent(protectedRoom, "m.room.server_acl", ""); const protectedAcl = await mjolnir.client.getRoomStateEvent(protectedRoom, "m.room.server_acl", "");
if (!acl.matches(protectedAcl)) { if (!acl.matches(protectedAcl)) {
assert.fail(`Room ${protectedRoom} doesn't have the correct ACL: ${JSON.stringify(roomAcl, null, 2)}`); assert.fail(`Room ${protectedRoom} doesn't have the correct ACL: ${JSON.stringify(roomAcl, null, 2)}`);
} }
@ -348,7 +351,7 @@ describe('Test: unbaning entities via the BanList.', function () {
await moderator.start(); await moderator.start();
for (const server of [olderBadServer, newerBadServer]) { for (const server of [olderBadServer, newerBadServer]) {
await getFirstReaction(moderator, this.mjolnir.managementRoomId, '✅', async () => { await getFirstReaction(moderator, this.mjolnir.managementRoomId, '✅', async () => {
return await moderator.sendMessage(this.mjolnir.managementRoomId, {msgtype: 'm.text', body: `!mjolnir unban unban-test server ${server}`}); return await moderator.sendMessage(this.mjolnir.managementRoomId, { msgtype: 'm.text', body: `!mjolnir unban unban-test server ${server}` });
}); });
} }
} finally { } finally {
@ -360,43 +363,43 @@ describe('Test: unbaning entities via the BanList.', function () {
// Confirm that the server is unbanned. // Confirm that the server is unbanned.
await banList.updateList(); await banList.updateList();
assert.equal(banList.allRules.length, 0); assert.equal(banList.allRules.length, 0);
const aclAfter = await mjolnir.getRoomStateEvent(protectedRoom, "m.room.server_acl", ""); const aclAfter = await mjolnir.client.getRoomStateEvent(protectedRoom, "m.room.server_acl", "");
assert.equal(aclAfter.deny.length, 0, 'Should be no servers denied anymore'); assert.equal(aclAfter.deny.length, 0, 'Should be no servers denied anymore');
}) })
}) })
describe('Test: should apply bans to the most recently active rooms first', function () { describe('Test: should apply bans to the most recently active rooms first', function() {
it('Applies bans to the most recently active rooms first', async function () { it('Applies bans to the most recently active rooms first', async function() {
this.timeout(180000) this.timeout(180000)
const mjolnir = config.RUNTIME.client! const mjolnir: Mjolnir = this.mjolnir!
const serverName: string = new UserID(await mjolnir.getUserId()).domain const serverName: string = new UserID(await mjolnir.client.getUserId()).domain
const moderator = await newTestUser({ name: { contains: "moderator" }}); const moderator = await newTestUser({ name: { contains: "moderator" } });
moderator.joinRoom(this.mjolnir.managementRoomId); moderator.joinRoom(mjolnir.managementRoomId);
const mjolnirId = await mjolnir.getUserId(); const mjolnirId = await mjolnir.client.getUserId();
// Setup some protected rooms so we can check their ACL state later. // Setup some protected rooms so we can check their ACL state later.
const protectedRooms: string[] = []; const protectedRooms: string[] = [];
for (let i = 0; i < 10; i++) { for (let i = 0; i < 10; i++) {
const room = await moderator.createRoom({ invite: [mjolnirId]}); const room = await moderator.createRoom({ invite: [mjolnirId] });
await mjolnir.joinRoom(room); await mjolnir.client.joinRoom(room);
await moderator.setUserPowerLevel(mjolnirId, room, 100); await moderator.setUserPowerLevel(mjolnirId, room, 100);
await this.mjolnir!.addProtectedRoom(room); await mjolnir.addProtectedRoom(room);
protectedRooms.push(room); protectedRooms.push(room);
} }
// If a previous test hasn't cleaned up properly, these rooms will be populated by bogus ACLs at this point. // If a previous test hasn't cleaned up properly, these rooms will be populated by bogus ACLs at this point.
await this.mjolnir!.syncLists(); await mjolnir.syncLists();
await Promise.all(protectedRooms.map(async room => { await Promise.all(protectedRooms.map(async room => {
const roomAcl = await mjolnir.getRoomStateEvent(room, "m.room.server_acl", "").catch(e => e.statusCode === 404 ? {deny: []} : Promise.reject(e)); const roomAcl = await mjolnir.client.getRoomStateEvent(room, "m.room.server_acl", "").catch(e => e.statusCode === 404 ? { deny: [] } : Promise.reject(e));
assert.equal(roomAcl?.deny?.length ?? 0, 0, 'There should be no entries in the deny ACL.'); assert.equal(roomAcl?.deny?.length ?? 0, 0, 'There should be no entries in the deny ACL.');
})); }));
// Flood the watched list with banned servers, which should prompt Mjolnir to update server ACL in protected rooms. // Flood the watched list with banned servers, which should prompt Mjolnir to update server ACL in protected rooms.
const banListId = await moderator.createRoom({ invite: [mjolnirId] }); const banListId = await moderator.createRoom({ invite: [mjolnirId] });
mjolnir.joinRoom(banListId); mjolnir.client.joinRoom(banListId);
this.mjolnir!.watchList(Permalinks.forRoom(banListId)); mjolnir.watchList(Permalinks.forRoom(banListId));
await this.mjolnir!.syncLists(); await mjolnir.syncLists();
// shuffle protected rooms https://stackoverflow.com/a/12646864, we do this so we can create activity "randomly" in them. // shuffle protected rooms https://stackoverflow.com/a/12646864, we do this so we can create activity "randomly" in them.
for (let i = protectedRooms.length - 1; i > 0; i--) { for (let i = protectedRooms.length - 1; i > 0; i--) {
@ -405,13 +408,13 @@ describe('Test: should apply bans to the most recently active rooms first', func
} }
// create some activity in the same order. // create some activity in the same order.
for (const roomId of protectedRooms.slice().reverse()) { for (const roomId of protectedRooms.slice().reverse()) {
await mjolnir.sendMessage(roomId, {body: `activity`, msgtype: 'm.text'}); await mjolnir.client.sendMessage(roomId, { body: `activity`, msgtype: 'm.text' });
await new Promise(resolve => setTimeout(resolve, 100)); await new Promise(resolve => setTimeout(resolve, 100));
} }
// check the rooms are in the expected order // check the rooms are in the expected order
for (let i = 0; i < protectedRooms.length; i++) { for (let i = 0; i < protectedRooms.length; i++) {
assert.equal(this.mjolnir!.protectedRoomsByActivity()[i], protectedRooms[i]); assert.equal(mjolnir.protectedRoomsByActivity()[i], protectedRooms[i]);
} }
const badServer = `evil.com`; const badServer = `evil.com`;
@ -420,18 +423,18 @@ describe('Test: should apply bans to the most recently active rooms first', func
await createPolicyRule(moderator, banListId, RULE_SERVER, badServer, `Rule ${badServer}`); await createPolicyRule(moderator, banListId, RULE_SERVER, badServer, `Rule ${badServer}`);
// Wait until all the ACL events have been applied. // Wait until all the ACL events have been applied.
await this.mjolnir!.syncLists(); await mjolnir.syncLists();
for (let i = 0; i < protectedRooms.length; i++) { for (let i = 0; i < protectedRooms.length; i++) {
assert.equal(this.mjolnir!.protectedRoomsByActivity()[i], protectedRooms.at(-i - 1)); assert.equal(mjolnir.protectedRoomsByActivity()[i], protectedRooms.at(-i - 1));
} }
// Check that the most recently active rooms got the ACL update first. // Check that the most recently active rooms got the ACL update first.
let last_event_ts = 0; let last_event_ts = 0;
for (const roomId of protectedRooms) { for (const roomId of protectedRooms) {
let roomAclEvent: null|any; let roomAclEvent: null | any;
// Can't be the best way to get the whole event, but ok. // Can't be the best way to get the whole event, but ok.
await getMessagesByUserIn(mjolnir, mjolnirId, roomId, 1, events => roomAclEvent = events[0]); await getMessagesByUserIn(mjolnir.client, mjolnirId, roomId, 1, events => roomAclEvent = events[0]);
const roomAcl = roomAclEvent!.content; const roomAcl = roomAclEvent!.content;
if (!acl.matches(roomAcl)) { if (!acl.matches(roomAcl)) {
assert.fail(`Room ${roomId} doesn't have the correct ACL: ${JSON.stringify(roomAcl, null, 2)}`) assert.fail(`Room ${roomId} doesn't have the correct ACL: ${JSON.stringify(roomAcl, null, 2)}`)

View File

@ -1,6 +1,6 @@
import { HmacSHA1 } from "crypto-js"; import { HmacSHA1 } from "crypto-js";
import { getRequestFn, LogService, MatrixClient, MemoryStorageProvider, PantalaimonClient } from "matrix-bot-sdk"; import { getRequestFn, LogService, MatrixClient, MemoryStorageProvider, PantalaimonClient } from "matrix-bot-sdk";
import config from "../../src/config"; import config from '../../src/config';
const REGISTRATION_ATTEMPTS = 10; const REGISTRATION_ATTEMPTS = 10;
const REGISTRATION_RETRY_BASE_DELAY_MS = 100; const REGISTRATION_RETRY_BASE_DELAY_MS = 100;

View File

@ -14,7 +14,7 @@ export const mochaHooks = {
// Sometimes it takes a little longer to register users. // Sometimes it takes a little longer to register users.
this.timeout(10000) this.timeout(10000)
this.managementRoomAlias = config.managementRoom; this.managementRoomAlias = config.managementRoom;
this.mjolnir = await makeMjolnir(); this.mjolnir = await makeMjolnir(config);
config.RUNTIME.client = this.mjolnir.client; config.RUNTIME.client = this.mjolnir.client;
await Promise.all([ await Promise.all([
this.mjolnir.client.setAccountData('org.matrix.mjolnir.protected_rooms', { rooms: [] }), this.mjolnir.client.setAccountData('org.matrix.mjolnir.protected_rooms', { rooms: [] }),

View File

@ -3,8 +3,9 @@
*/ */
import { makeMjolnir } from "./mjolnirSetupUtils"; import { makeMjolnir } from "./mjolnirSetupUtils";
import config from '../../src/config';
(async () => { (async () => {
let mjolnir = await makeMjolnir(); let mjolnir = await makeMjolnir(config);
await mjolnir.start(); await mjolnir.start();
})(); })();

View File

@ -22,9 +22,9 @@ import {
RichConsoleLogger RichConsoleLogger
} from "matrix-bot-sdk"; } from "matrix-bot-sdk";
import { Mjolnir} from '../../src/Mjolnir'; import { Mjolnir} from '../../src/Mjolnir';
import config from "../../src/config";
import { overrideRatelimitForUser, registerUser } from "./clientHelper"; import { overrideRatelimitForUser, registerUser } from "./clientHelper";
import { patchMatrixClient } from "../../src/utils"; import { patchMatrixClient } from "../../src/utils";
import { IConfig } from "../../src/config";
/** /**
* Ensures that a room exists with the alias, if it does not exist we create it. * Ensures that a room exists with the alias, if it does not exist we create it.
@ -48,7 +48,7 @@ export async function ensureAliasedRoomExists(client: MatrixClient, alias: strin
} }
} }
async function configureMjolnir() { async function configureMjolnir(config: IConfig) {
try { try {
await registerUser(config.pantalaimon.username, config.pantalaimon.username, config.pantalaimon.password, true) await registerUser(config.pantalaimon.username, config.pantalaimon.username, config.pantalaimon.password, true)
} catch (e) { } catch (e) {
@ -72,8 +72,8 @@ let globalMjolnir: Mjolnir | null;
/** /**
* Return a test instance of Mjolnir. * Return a test instance of Mjolnir.
*/ */
export async function makeMjolnir(): Promise<Mjolnir> { export async function makeMjolnir(config: IConfig): Promise<Mjolnir> {
await configureMjolnir(); await configureMjolnir(config);
LogService.setLogger(new RichConsoleLogger()); LogService.setLogger(new RichConsoleLogger());
LogService.setLevel(LogLevel.fromString(config.logLevel, LogLevel.DEBUG)); LogService.setLevel(LogLevel.fromString(config.logLevel, LogLevel.DEBUG));
LogService.info("test/mjolnirSetupUtils", "Starting bot..."); LogService.info("test/mjolnirSetupUtils", "Starting bot...");
@ -82,7 +82,7 @@ export async function makeMjolnir(): Promise<Mjolnir> {
await overrideRatelimitForUser(await client.getUserId()); await overrideRatelimitForUser(await client.getUserId());
patchMatrixClient(); patchMatrixClient();
await ensureAliasedRoomExists(client, config.managementRoom); await ensureAliasedRoomExists(client, config.managementRoom);
let mj = await Mjolnir.setupMjolnirFromConfig(client); let mj = await Mjolnir.setupMjolnirFromConfig(client, config);
globalClient = client; globalClient = client;
globalMjolnir = mj; globalMjolnir = mj;
return mj; return mj;