Merge branch 'main' into gnuxie/log-message

This commit is contained in:
gnuxie 2022-08-18 09:05:32 +01:00
commit 37df55150f
55 changed files with 1155 additions and 1099 deletions

View File

@ -34,9 +34,9 @@ dataPath: "/data/storage"
# If true (the default), Mjolnir will only accept invites from users present in managementRoom.
autojoinOnlyIfManager: true
# If `autojoinOnlyIfManager` is false, only the members in this group can invite
# If `autojoinOnlyIfManager` is false, only the members in this space can invite
# the bot to new rooms.
acceptInvitesFromGroup: "+example:example.org"
acceptInvitesFromSpace: "!example:example.org"
# Whether Mjolnir should report ignored invites to the management room (if autojoinOnlyIfManager is true).
recordIgnoredInvites: false

View File

@ -32,9 +32,9 @@ dataPath: "./test/harness/mjolnir-data/"
# to new rooms.
autojoinOnlyIfManager: true
# If `autojoinOnlyIfManager` is false, only the members in this group can invite
# If `autojoinOnlyIfManager` is false, only the members in this space can invite
# the bot to new rooms.
acceptInvitesFromGroup: '+example:example.org'
acceptInvitesFromSpace: '!example:example.org'
# If the bot is invited to a room and it won't accept the invite (due to the
# conditions above), report it to the management room. Defaults to disabled (no

View File

@ -18,10 +18,10 @@
"version": "sed -i '/# version automated/s/[0-9][0-9]*\\.[0-9][0-9]*\\.[0-9][0-9]*/'$npm_package_version'/' synapse_antispam/setup.py && git add synapse_antispam/setup.py && cat synapse_antispam/setup.py"
},
"devDependencies": {
"@types/config": "0.0.41",
"@types/crypto-js": "^4.0.2",
"@types/html-to-text": "^8.0.1",
"@types/humanize-duration": "^3.27.1",
"@types/js-yaml": "^4.0.5",
"@types/jsdom": "^16.2.11",
"@types/mocha": "^9.0.0",
"@types/node": "^16.7.10",
@ -36,7 +36,6 @@
"typescript-formatter": "^7.2"
},
"dependencies": {
"config": "^3.3.6",
"express": "^4.17",
"html-to-text": "^8.0.0",
"humanize-duration": "^3.27.1",
@ -45,7 +44,8 @@
"jsdom": "^16.6.0",
"matrix-bot-sdk": "^0.5.19",
"parse-duration": "^1.0.2",
"shell-quote": "^1.7.3"
"shell-quote": "^1.7.3",
"yaml": "^2.1.1"
},
"engines": {
"node": ">=16.0.0"

View File

@ -27,19 +27,17 @@ import {
TextualMessageEventContent
} 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 { RoomUpdateError } from "./models/RoomUpdateError";
import { COMMAND_PREFIX, handleCommand } from "./commands/CommandHandler";
import { applyUserBans } from "./actions/ApplyBan";
import config from "./config";
import ErrorCache, { ERROR_KIND_FATAL, ERROR_KIND_PERMISSION } from "./ErrorCache";
import { Protection } from "./protections/IProtection";
import { PROTECTIONS } from "./protections/protections";
import { ConsequenceType, Consequence } from "./protections/consequence";
import { ProtectionSettingValidationError } from "./protections/ProtectionSettings";
import { UnlistedUserRedactionQueue } from "./queues/UnlistedUserRedactionQueue";
import { Healthz } from "./health/healthz";
import { EventRedactionQueue, RedactUserInRoom } from "./queues/EventRedactionQueue";
import { htmlEscape } from "./utils";
import { ReportManager } from "./report/ReportManager";
@ -50,6 +48,8 @@ import RuleServer from "./models/RuleServer";
import { RoomMemberManager } from "./RoomMembers";
import { ProtectedRoomActivityTracker } from "./queues/ProtectedRoomActivityTracker";
import { ThrottlingQueue } from "./queues/ThrottlingQueue";
import { IConfig } from "./config";
import PolicyList, { ListRuleChange } from "./models/PolicyList";
const levelToFn = {
[LogLevel.DEBUG.toString()]: LogService.debug,
@ -103,6 +103,15 @@ export class Mjolnir {
private webapis: WebAPIs;
private protectedRoomActivityTracker: ProtectedRoomActivityTracker;
public taskQueue: ThrottlingQueue;
/**
* Used to provide mutual exclusion when synchronizing rooms with the state of a policy list.
* This is because requests operating with rules from an older version of the list that are slow
* could race & give the room an inconsistent state. An example is if we add multiple m.policy.rule.server rules,
* which would cause several requests to a room to send a new m.room.server_acl event.
* These requests could finish in any order, which has left rooms with an inconsistent server_acl event
* until Mjolnir synchronises the room with its policy lists again, which can be in the region of hours.
*/
public aclChain: Promise<void> = Promise.resolve();
/*
* Config-enabled polling of reports in Synapse, so Mjolnir can react to reports
*/
@ -114,7 +123,7 @@ export class Mjolnir {
* @param {string} options.managementRoom The room to report ignored invitations to if `recordIgnoredInvites` is true.
* @param {boolean} options.recordIgnoredInvites Whether to report invites that will be ignored to the `managementRoom`.
* @param {boolean} options.autojoinOnlyIfManager Whether to only accept an invitation by a user present in the `managementRoom`.
* @param {string} options.acceptInvitesFromGroup A group of users to accept invites from, ignores invites form users not in this group.
* @param {string} options.acceptInvitesFromSpace A space of users to accept invites from, ignores invites form users not in this space.
*/
private static addJoinOnInviteListener(mjolnir: Mjolnir, client: MatrixClient, options: { [key: string]: any }) {
client.on("room.invite", async (roomId: string, inviteEvent: any) => {
@ -138,9 +147,18 @@ export class Mjolnir {
const managers = await client.getJoinedRoomMembers(mjolnir.managementRoomId);
if (!managers.includes(membershipEvent.sender)) return reportInvite(); // ignore invite
} else {
const groupMembers = await client.unstableApis.getGroupUsers(options.acceptInvitesFromGroup);
const userIds = groupMembers.map(m => m.user_id);
if (!userIds.includes(membershipEvent.sender)) return reportInvite(); // ignore invite
const spaceId = await client.resolveRoom(options.acceptInvitesFromSpace);
const spaceUserIds = await client.getJoinedRoomMembers(spaceId)
.catch(async e => {
if (e.body?.errcode === "M_FORBIDDEN") {
await mjolnir.logMessage(LogLevel.ERROR, 'Mjolnir', `Mjolnir is not in the space configured for acceptInvitesFromSpace, did you invite it?`);
await client.joinRoom(spaceId);
return await client.getJoinedRoomMembers(spaceId);
} else {
return Promise.reject(e);
}
});
if (!spaceUserIds.includes(membershipEvent.sender)) return reportInvite(); // ignore invite
}
return client.joinRoom(roomId);
@ -152,8 +170,8 @@ export class Mjolnir {
* @param {MatrixClient} client The client for Mjolnir to use.
* @returns A new Mjolnir instance that can be started without further setup.
*/
static async setupMjolnirFromConfig(client: MatrixClient): Promise<Mjolnir> {
const banLists: BanList[] = [];
static async setupMjolnirFromConfig(client: MatrixClient, config: IConfig): Promise<Mjolnir> {
const policyLists: PolicyList[] = [];
const protectedRooms: { [roomId: string]: string } = {};
const joinedRooms = await client.getJoinedRooms();
// Ensure we're also joined to the rooms we're protecting
@ -178,7 +196,7 @@ export class Mjolnir {
}
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.");
Mjolnir.addJoinOnInviteListener(mjolnir, client, config);
return mjolnir;
@ -187,18 +205,19 @@ export class Mjolnir {
constructor(
public readonly client: MatrixClient,
public readonly managementRoomId: string,
public readonly config: IConfig,
/*
* 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).
*/
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.
public readonly ruleServer: RuleServer|null,
public readonly ruleServer: RuleServer | null,
) {
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()));
}
@ -266,7 +285,7 @@ export class Mjolnir {
console.log("Creating Web APIs");
const reportManager = new ReportManager(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) {
this.reportPoller = new ReportPoller(this, reportManager);
}
@ -275,8 +294,8 @@ export class Mjolnir {
this.taskQueue = new ThrottlingQueue(this, config.backgroundDelayMS);
}
public get lists(): BanList[] {
return this.banLists;
public get lists(): PolicyList[] {
return this.policyLists;
}
public get state(): string {
@ -343,23 +362,22 @@ export class Mjolnir {
} catch (e) {
LogService.warn("Mjolnir", extractRequestError(e));
}
await this.buildWatchedBanLists();
await this.buildWatchedPolicyLists();
this.applyUnprotectedRooms();
if (config.verifyPermissionsOnStartup) {
if (this.config.verifyPermissionsOnStartup) {
await this.logMessage(LogLevel.INFO, "Mjolnir@startup", "Checking permissions...");
await this.verifyPermissions(config.verboseLogging);
await this.verifyPermissions(this.config.verboseLogging);
}
this.currentState = STATE_SYNCING;
if (config.syncOnStartup) {
if (this.config.syncOnStartup) {
await this.logMessage(LogLevel.INFO, "Mjolnir@startup", "Syncing lists...");
await this.syncLists(config.verboseLogging);
await this.syncLists(this.config.verboseLogging);
await this.registerProtections();
}
this.currentState = STATE_RUNNING;
Healthz.isHealthy = true;
await this.logMessage(LogLevel.INFO, "Mjolnir@startup", "Startup complete. Now monitoring rooms.");
} catch (err) {
try {
@ -389,13 +407,13 @@ export class Mjolnir {
if (!additionalRoomIds) additionalRoomIds = [];
if (!Array.isArray(additionalRoomIds)) additionalRoomIds = [additionalRoomIds];
if (config.verboseLogging || LogLevel.INFO.includes(level)) {
if (this.config.verboseLogging || LogLevel.INFO.includes(level)) {
let clientMessage = message;
if (level === LogLevel.WARN) clientMessage = `⚠ | ${message}`;
if (level === LogLevel.ERROR) clientMessage = `‼ | ${message}`;
const managementRoomId = await this.client.resolveRoom(config.managementRoom);
const roomIds = [managementRoomId, ...additionalRoomIds];
const client = this.client;
const roomIds = [this.managementRoomId, ...additionalRoomIds];
let evContent: TextualMessageEventContent = {
body: message,
@ -407,7 +425,7 @@ export class Mjolnir {
evContent = await replaceRoomIdsWithPills(this, clientMessage, new Set(roomIds), "m.notice");
}
await this.client.sendMessage(managementRoomId, evContent);
await client.sendMessage(this.managementRoomId, evContent);
}
levelToFn[level.toString()](module, message);
@ -432,7 +450,7 @@ export class Mjolnir {
const rooms = (additionalProtectedRooms?.rooms ?? []);
rooms.push(roomId);
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) {
@ -454,7 +472,7 @@ export class Mjolnir {
}
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 oldRoomIdsSet = new Set(this.protectedJoinedRoomIds);
@ -480,7 +498,7 @@ export class Mjolnir {
this.applyUnprotectedRooms();
if (withSync) {
await this.syncLists(config.verboseLogging);
await this.syncLists(this.config.verboseLogging);
}
}
@ -553,12 +571,12 @@ export class Mjolnir {
const validatedSettings: { [setting: string]: any } = {}
for (let [key, value] of Object.entries(savedSettings)) {
if (
// is this a setting name with a known parser?
key in settingDefinitions
// is the datatype of this setting's value what we expect?
&& typeof(settingDefinitions[key].value) === typeof(value)
// is this setting's value valid for the setting?
&& settingDefinitions[key].validate(value)
// is this a setting name with a known parser?
key in settingDefinitions
// is the datatype of this setting's value what we expect?
&& typeof (settingDefinitions[key].value) === typeof (value)
// is this setting's value valid for the setting?
&& settingDefinitions[key].validate(value)
) {
validatedSettings[key] = value;
} else {
@ -592,8 +610,8 @@ export class Mjolnir {
if (!(key in protection.settings)) {
throw new ProtectionSettingValidationError(`Failed to find protection setting by name: ${key}`);
}
if (typeof(protection.settings[key].value) !== typeof(value)) {
throw new ProtectionSettingValidationError(`Invalid type for protection setting: ${key} (${typeof(value)})`);
if (typeof (protection.settings[key].value) !== typeof (value)) {
throw new ProtectionSettingValidationError(`Invalid type for protection setting: ${key} (${typeof (value)})`);
}
if (!protection.settings[key].validate(value)) {
throw new ProtectionSettingValidationError(`Invalid value for protection setting: ${key} (${value})`);
@ -643,16 +661,16 @@ export class Mjolnir {
}
/**
* Helper for constructing `BanList`s and making sure they have the right listeners set up.
* @param roomId The room id for the `BanList`.
* @param roomRef A reference (matrix.to URL) for the `BanList`.
* Helper for constructing `PolicyList`s and making sure they have the right listeners set up.
* @param roomId The room id for the `PolicyList`.
* @param roomRef A reference (matrix.to URL) for the `PolicyList`.
*/
private async addBanList(roomId: string, roomRef: string): Promise<BanList> {
const list = new BanList(roomId, roomRef, this.client);
private async addPolicyList(roomId: string, roomRef: string): Promise<PolicyList> {
const list = new PolicyList(roomId, roomRef, this.client);
this.ruleServer?.watch(list);
list.on('BanList.batch', this.syncWithBanList.bind(this));
list.on('PolicyList.batch', this.syncWithPolicyList.bind(this));
await list.updateList();
this.banLists.push(list);
this.policyLists.push(list);
return list;
}
@ -666,7 +684,7 @@ export class Mjolnir {
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 permalink = Permalinks.parseUrl(roomRef);
if (!permalink.roomIdOrAlias) return null;
@ -676,38 +694,38 @@ export class Mjolnir {
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, {
references: this.banLists.map(b => b.roomRef),
references: this.policyLists.map(b => b.roomRef),
});
await this.warnAboutUnprotectedBanListRoom(roomId);
await this.warnAboutUnprotectedPolicyListRoom(roomId);
return list;
}
public async unwatchList(roomRef: string): Promise<BanList | null> {
public async unwatchList(roomRef: string): Promise<PolicyList | null> {
const permalink = Permalinks.parseUrl(roomRef);
if (!permalink.roomIdOrAlias) return null;
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) {
this.banLists.splice(this.banLists.indexOf(list), 1);
this.policyLists.splice(this.policyLists.indexOf(list), 1);
this.ruleServer?.unwatch(list);
}
await this.client.setAccountData(WATCHED_LISTS_EVENT_TYPE, {
references: this.banLists.map(b => b.roomRef),
references: this.policyLists.map(b => b.roomRef),
});
return list;
}
public async warnAboutUnprotectedBanListRoom(roomId: string) {
if (!config.protectAllJoinedRooms) return; // doesn't matter
public async warnAboutUnprotectedPolicyListRoom(roomId: string) {
if (!this.config.protectAllJoinedRooms) return; // doesn't matter
if (this.explicitlyProtectedRoomIds.includes(roomId)) return; // explicitly protected
const createEvent = new CreateEvent(await this.client.getRoomStateEvent(roomId, "m.room.create", ""));
@ -734,8 +752,8 @@ export class Mjolnir {
}
}
private async buildWatchedBanLists() {
this.banLists = [];
private async buildWatchedPolicyLists() {
this.policyLists = [];
const joinedRooms = await this.client.getJoinedRooms();
let watchedListsEvent: { references?: string[] } | null = null;
@ -754,8 +772,8 @@ export class Mjolnir {
await this.client.joinRoom(permalink.roomIdOrAlias, permalink.viaServers);
}
await this.warnAboutUnprotectedBanListRoom(roomId);
await this.addBanList(roomId, roomRef);
await this.warnAboutUnprotectedPolicyListRoom(roomId);
await this.addPolicyList(roomId, roomRef);
}
}
@ -881,15 +899,15 @@ export class Mjolnir {
* @param verbose Whether to report any errors to the management room.
*/
public async syncLists(verbose = true) {
for (const list of this.banLists) {
for (const list of this.policyLists) {
const changes = await list.updateList();
await this.printBanlistChanges(changes, list, true);
}
let hadErrors = false;
const [aclErrors, banErrors] = await Promise.all([
applyServerAcls(this.banLists, this.protectedRoomsByActivity(), this),
applyUserBans(this.banLists, this.protectedRoomsByActivity(), this)
applyServerAcls(this.policyLists, this.protectedRoomsByActivity(), this),
applyUserBans(this.policyLists, this.protectedRoomsByActivity(), this)
]);
const redactionErrors = await this.processRedactionQueue();
hadErrors = hadErrors || await this.printActionResult(aclErrors, "Errors updating server ACLs:");
@ -911,16 +929,16 @@ export class Mjolnir {
/**
* Pulls any changes to the rules that are in a policy room and updates all protected rooms
* with those changes. Does not fail if there are errors updating the room, these are reported to the management room.
* @param 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.
*/
private async syncWithBanList(banList: BanList): Promise<void> {
const changes = await banList.updateList();
private async syncWithPolicyList(policyList: PolicyList): Promise<void> {
const changes = await policyList.updateList();
let hadErrors = false;
const [aclErrors, banErrors] = await Promise.all([
applyServerAcls(this.banLists, this.protectedRoomsByActivity(), this),
applyUserBans(this.banLists, this.protectedRoomsByActivity(), this)
applyServerAcls(this.policyLists, this.protectedRoomsByActivity(), this),
applyUserBans(this.policyLists, this.protectedRoomsByActivity(), this)
]);
const redactionErrors = await this.processRedactionQueue();
hadErrors = hadErrors || await this.printActionResult(aclErrors, "Errors updating server ACLs:");
@ -938,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.
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) {
@ -988,10 +1006,10 @@ export class Mjolnir {
// Check for updated ban lists before checking protected rooms - the ban lists might be protected
// themselves.
const banList = this.banLists.find(list => list.roomId === roomId);
if (banList !== undefined) {
const policyList = this.policyLists.find(list => list.roomId === roomId);
if (policyList !== undefined) {
if (ALL_BAN_LIST_RULE_TYPES.includes(event['type']) || event['type'] === 'm.room.redaction') {
banList.updateForEvent(event)
policyList.updateForEvent(event)
}
}
@ -1036,7 +1054,7 @@ export class Mjolnir {
// we cannot eagerly ban users (that is to ban them when they have never been a member)
// as they can be force joined to a room they might not have known existed.
// Only apply bans and then redactions in the room we are currently looking at.
const banErrors = await applyUserBans(this.banLists, [roomId], this);
const banErrors = await applyUserBans(this.policyLists, [roomId], this);
const redactionErrors = await this.processRedactionQueue(roomId);
await this.printActionResult(banErrors);
await this.printActionResult(redactionErrors);
@ -1050,7 +1068,7 @@ export class 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).
*/
private async printBanlistChanges(changes: ListRuleChange[], list: BanList, ignoreSelf = false): Promise<boolean> {
private async printBanlistChanges(changes: ListRuleChange[], list: PolicyList, ignoreSelf = false): Promise<boolean> {
if (ignoreSelf) {
const sender = await this.client.getUserId();
changes = changes.filter(change => change.sender !== sender);

View File

@ -14,11 +14,10 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import BanList from "../models/BanList";
import PolicyList from "../models/PolicyList";
import { ServerAcl } from "../models/ServerAcl";
import { RoomUpdateError } from "../models/RoomUpdateError";
import { Mjolnir } from "../Mjolnir";
import config from "../config";
import { LogLevel, UserID } from "matrix-bot-sdk";
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
* room IDs that could not be updated and their error.
* Does not update the banLists before taking their rules to build the server ACL.
* @param {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 {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;
// 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.`);
}
if (config.verboseLogging) {
if (mjolnir.config.verboseLogging) {
// We specifically use sendNotice to avoid having to escape HTML
await mjolnir.client.sendNotice(mjolnir.managementRoomId, `Constructed server ACL:\n${JSON.stringify(finalAcl, null, 2)}`);
}
@ -70,7 +79,7 @@ export async function applyServerAcls(lists: BanList[], roomIds: string[], mjoln
// We specifically use sendNotice to avoid having to escape HTML
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);
} else {
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) {
const message = e.message || (e.body ? e.body.error : '<no message>');
const kind = message && message.includes("You don't have permission to post that to the room") ? ERROR_KIND_PERMISSION : ERROR_KIND_FATAL;
errors.push({roomId, errorMessage: message, errorKind: kind});
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.
*/
import BanList from "../models/BanList";
import PolicyList from "../models/PolicyList";
import { RoomUpdateError } from "../models/RoomUpdateError";
import { Mjolnir } from "../Mjolnir";
import config from "../config";
import { LogLevel } from "matrix-bot-sdk";
import { ERROR_KIND_FATAL, ERROR_KIND_PERMISSION } from "../ErrorCache";
/**
* Applies the member bans represented by the ban lists to the provided rooms, returning the
* room IDs that could not be updated and their error.
* @param {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 {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.
const errors: RoomUpdateError[] = [];
for (const roomId of roomIds) {
@ -38,15 +37,15 @@ export async function applyUserBans(lists: BanList[], roomIds: string[], mjolnir
let members: { userId: string, membership: string }[];
if (config.fasterMembershipChecks) {
if (mjolnir.config.fasterMembershipChecks) {
const memberIds = await mjolnir.client.getJoinedRoomMembers(roomId);
members = memberIds.map(u => {
return {userId: u, membership: "join"};
return { userId: u, membership: "join" };
});
} else {
const state = await mjolnir.client.getRoomState(roomId);
members = state.filter(s => s['type'] === 'm.room.member' && !!s['state_key']).map(s => {
return {userId: s['state_key'], membership: s['content'] ? s['content']['membership'] : 'leave'};
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
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);
if (mjolnir.automaticRedactGlobs.find(g => g.test(userRule.reason.toLowerCase()))) {
mjolnir.queueRedactUserMessagesIn(member.userId, roomId);

View File

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

View File

@ -15,7 +15,7 @@ limitations under the License.
*/
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";
// !mjolnir list create <shortcode> <alias localpart>
@ -48,7 +48,7 @@ export async function execCreateListCommand(roomId: string, event: any, mjolnir:
preset: "public_chat",
room_alias_name: aliasLocalpart,
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,
});

View File

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

View File

@ -16,8 +16,7 @@ limitations under the License.
import { Mjolnir } from "../Mjolnir";
import { RichReply } from "matrix-bot-sdk";
import { RECOMMENDATION_BAN, recommendationToStable } from "../models/ListRule";
import { RULE_SERVER, RULE_USER, ruleTypeToStable } from "../models/BanList";
import { EntityType, Recommendation } from "../models/ListRule";
// !mjolnir import <room ID> <shortcode>
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`);
const recommendation = recommendationToStable(RECOMMENDATION_BAN);
const ruleContent = {
entity: stateEvent['state_key'],
recommendation,
recommendation: Recommendation.Ban,
reason: reason,
};
const stateKey = `rule:${ruleContent.entity}`;
let stableRule = ruleTypeToStable(RULE_USER);
let stableRule = EntityType.RULE_USER;
if (stableRule) {
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`);
const recommendation = recommendationToStable(RECOMMENDATION_BAN);
const ruleContent = {
entity: server,
recommendation,
recommendation: Recommendation.Ban,
reason: reason,
};
const stateKey = `rule:${ruleContent.entity}`;
let stableRule = ruleTypeToStable(RULE_SERVER);
let stableRule = EntityType.RULE_SERVER;
if (stableRule) {
await mjolnir.client.sendStateEvent(list.roomId, stableRule, stateKey, ruleContent);
}

View File

@ -16,7 +16,6 @@ limitations under the License.
import { Mjolnir } from "../Mjolnir";
import { LogLevel, MatrixGlob, RichReply } from "matrix-bot-sdk";
import config from "../config";
// !mjolnir kick <user|filter> [room] [reason]
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();
}
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";
const reply = RichReply.createFor(roomId, event, replyMessage, replyMessage);
reply["msgtype"] = "m.notice";
@ -60,7 +59,7 @@ export async function execKickCommand(roomId: string, event: any, mjolnir: Mjoln
if (kickRule.test(victim)) {
await mjolnir.logMessage(LogLevel.DEBUG, "KickCommand", `Removing ${victim} in ${protectedRoomId}`, protectedRoomId);
if (!config.noop) {
if (!mjolnir.config.noop) {
try {
await mjolnir.taskQueue.push(async () => {
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.
*/
import config from "../config";
import { Mjolnir } from "../Mjolnir";
import { RichReply } from "matrix-bot-sdk";
// !mjolnir make admin <room> [<user ID>]
export async function execMakeRoomAdminCommand(roomId: string, event: any, mjolnir: Mjolnir, parts: string[]) {
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 reply = RichReply.createFor(roomId, event, message, message);
reply['msgtype'] = "m.notice";

View File

@ -31,6 +31,6 @@ export async function execSetDefaultListCommand(roomId: string, event: any, mjol
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'], '✅');
}

View File

@ -15,21 +15,20 @@ limitations under the License.
*/
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 { RECOMMENDATION_BAN, recommendationToStable } from "../models/ListRule";
import config from "../config";
import { Recommendation, RULE_ROOM, RULE_SERVER, RULE_USER, USER_RULE_TYPES } from "../models/ListRule";
import { DEFAULT_LIST_EVENT_TYPE } from "./SetDefaultBanListCommand";
interface Arguments {
list: BanList | null;
list: PolicyList | null;
entity: string;
ruleType: string | null;
reason: string;
}
// 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;
try {
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 ruleType: string | null = null;
let entity: string | null = null;
let list: BanList | null = null;
let list: PolicyList | null = null;
let force = false;
while (argumentIndex < 7 && argumentIndex < parts.length) {
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 (!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";
}
@ -119,10 +118,9 @@ export async function execBanCommand(roomId: string, event: any, mjolnir: Mjolni
const bits = await parseArguments(roomId, event, mjolnir, parts);
if (!bits) return; // error already handled
const recommendation = recommendationToStable(RECOMMENDATION_BAN);
const ruleContent = {
entity: bits.entity,
recommendation,
recommendation: Recommendation.Ban,
reason: bits.reason || '<no reason supplied>',
};
const stateKey = `rule:${bits.entity}`;
@ -151,7 +149,7 @@ export async function execUnbanCommand(roomId: string, event: any, mjolnir: Mjol
if (rule.test(victim)) {
await mjolnir.logMessage(LogLevel.DEBUG, "UnbanBanCommand", `Unbanning ${victim} in ${protectedRoomId}`, protectedRoomId);
if (!config.noop) {
if (!mjolnir.config.noop) {
await mjolnir.client.unbanUser(victim, protectedRoomId);
} else {
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) {
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

@ -14,7 +14,8 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import * as config from "config";
import * as fs from "fs";
import { load } from "js-yaml";
import { MatrixClient } from "matrix-bot-sdk";
/**
@ -25,7 +26,7 @@ import { MatrixClient } from "matrix-bot-sdk";
// The object is magically generated by external lib `config`
// from the file specified by `NODE_ENV`, e.g. production.yaml
// or harness.yaml.
interface IConfig {
export interface IConfig {
homeserverUrl: string;
rawHomeserverUrl: string;
accessToken: string;
@ -35,7 +36,7 @@ interface IConfig {
password: string;
};
dataPath: string;
acceptInvitesFromGroup: string;
acceptInvitesFromSpace: string;
autojoinOnlyIfManager: boolean;
recordIgnoredInvites: boolean;
managementRoom: string;
@ -114,7 +115,7 @@ const defaultConfig: IConfig = {
password: "",
},
dataPath: "/data/storage",
acceptInvitesFromGroup: '+example:example.org',
acceptInvitesFromSpace: '!noop:example.org',
autojoinOnlyIfManager: false,
recordIgnoredInvites: false,
managementRoom: "!noop:example.org",
@ -168,5 +169,9 @@ const defaultConfig: IConfig = {
},
};
const finalConfig = <IConfig>Object.assign({}, defaultConfig, config);
export default finalConfig;
export function read(): IConfig {
const content = fs.readFileSync(`./config/${process.env.NODE_ENV || 'default'}.yaml`, "utf8");
const parsed = load(content);
const config = {...defaultConfig, ...(parsed as object)} as IConfig;
return config;
}

View File

@ -14,28 +14,31 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import config from "../config";
import * as http from "http";
import { LogService } from "matrix-bot-sdk";
import { IConfig } from "../config";
// allowed to use the global configuration since this is only intended to be used by `src/index.ts`.
export class Healthz {
private static healthCode: number;
private healthCode: number;
public static set isHealthy(val: boolean) {
Healthz.healthCode = val ? config.health.healthz.healthyStatus : config.health.healthz.unhealthyStatus;
constructor(private config: IConfig) { }
public set isHealthy(val: boolean) {
this.healthCode = val ? this.config.health.healthz.healthyStatus : this.config.health.healthz.unhealthyStatus;
}
public static get isHealthy(): boolean {
return Healthz.healthCode === config.health.healthz.healthyStatus;
public get isHealthy(): boolean {
return this.healthCode === this.config.health.healthz.healthyStatus;
}
public static listen() {
public listen() {
const server = http.createServer((req, res) => {
res.writeHead(Healthz.healthCode);
res.end(`health code: ${Healthz.healthCode}`);
res.writeHead(this.healthCode);
res.end(`health code: ${this.healthCode}`);
});
server.listen(config.health.healthz.port, config.health.healthz.address, () => {
LogService.info("Healthz", `Listening for health requests on ${config.health.healthz.address}:${config.health.healthz.port}`);
server.listen(this.config.health.healthz.port, this.config.health.healthz.address, () => {
LogService.info("Healthz", `Listening for health requests on ${this.config.health.healthz.address}:${this.config.health.healthz.port}`);
});
}
}

View File

@ -23,24 +23,28 @@ import {
RichConsoleLogger,
SimpleFsStorageProvider
} from "matrix-bot-sdk";
import config from "./config";
import { read as configRead } from "./config";
import { Healthz } from "./health/healthz";
import { Mjolnir } from "./Mjolnir";
import { patchMatrixClient } from "./utils";
config.RUNTIME = {};
LogService.setLogger(new RichConsoleLogger());
LogService.setLevel(LogLevel.fromString(config.logLevel, LogLevel.DEBUG));
LogService.info("index", "Starting bot...");
Healthz.isHealthy = false; // start off unhealthy
if (config.health.healthz.enabled) {
Healthz.listen();
}
(async function () {
const config = configRead();
config.RUNTIME = {};
LogService.setLogger(new RichConsoleLogger());
LogService.setLevel(LogLevel.fromString(config.logLevel, LogLevel.DEBUG));
LogService.info("index", "Starting bot...");
const healthz = new Healthz(config);
healthz.isHealthy = false; // start off unhealthy
if (config.health.healthz.enabled) {
healthz.listen();
}
let bot: Mjolnir | null = null;
try {
const storagePath = path.isAbsolute(config.dataPath) ? config.dataPath : path.join(__dirname, '../', config.dataPath);
@ -56,13 +60,14 @@ if (config.health.healthz.enabled) {
patchMatrixClient();
config.RUNTIME.client = client;
bot = await Mjolnir.setupMjolnirFromConfig(client);
bot = await Mjolnir.setupMjolnirFromConfig(client, config);
} catch (err) {
console.error(`Failed to setup mjolnir from the config ${config.dataPath}: ${err}`);
throw err;
}
try {
await bot.start();
healthz.isHealthy = true;
} catch (err) {
console.error(`Mjolnir failed to start: ${err}`);
throw err;

View File

@ -16,32 +16,238 @@ limitations under the License.
import { MatrixGlob } from "matrix-bot-sdk";
export const RECOMMENDATION_BAN = "m.ban";
export const RECOMMENDATION_BAN_TYPES = [RECOMMENDATION_BAN, "org.matrix.mjolnir.ban"];
export enum EntityType {
/// `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 {
if (RECOMMENDATION_BAN_TYPES.includes(recommendation)) return unstable ? RECOMMENDATION_BAN_TYPES[RECOMMENDATION_BAN_TYPES.length - 1] : RECOMMENDATION_BAN;
return null;
/// `entity` is to be parsed as a glob of room IDs/aliases
RULE_ROOM = "m.policy.rule.room",
/// `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;
constructor(public readonly entity: string, private action: string, public readonly reason: string, public readonly kind: string) {
constructor(
/**
* 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);
}
/**
* The recommendation for this rule, or `null` if there is no recommendation or the recommendation is invalid.
* Recommendations are normalised to their stable types.
* Determine whether this rule should apply to a given entity.
*/
public get recommendation(): string|null {
if (RECOMMENDATION_BAN_TYPES.includes(this.action)) return RECOMMENDATION_BAN;
return null;
}
public isMatch(entity: string): boolean {
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 { EventEmitter } from "events";
import { ListRule, RECOMMENDATION_BAN } 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];
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 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 {
Added = "ADDED",
Removed = "REMOVED",
Added = "ADDED",
Removed = "REMOVED",
Modified = "MODIFIED"
}
@ -71,28 +51,28 @@ export interface ListRuleChange {
readonly previousState?: any,
}
declare interface BanList {
// BanList.update is emitted when the BanList has pulled new rules from Matrix and informs listeners of any changes.
on(event: 'BanList.update', listener: (list: BanList, changes: ListRuleChange[]) => void): this
emit(event: 'BanList.update', list: BanList, changes: ListRuleChange[]): boolean
// BanList.batch is emitted when the BanList has created a batch from the events provided by `updateForEvent`.
on(event: 'BanList.batch', listener: (list: BanList) => void): this
emit(event: 'BanList.batch', list: BanList): boolean
declare interface PolicyList {
// PolicyList.update is emitted when the PolicyList has pulled new rules from Matrix and informs listeners of any changes.
on(event: 'PolicyList.update', listener: (list: PolicyList, changes: ListRuleChange[]) => void): this
emit(event: 'PolicyList.update', list: PolicyList, changes: ListRuleChange[]): boolean
// PolicyList.batch is emitted when the PolicyList has created a batch from the events provided by `updateForEvent`.
on(event: 'PolicyList.batch', listener: (list: PolicyList) => void): this
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.
*/
class BanList extends EventEmitter {
private shortcode: string|null = null;
class PolicyList extends EventEmitter {
private shortcode: string | null = null;
// A map of state events indexed first by state type and then state keys.
private state: Map<string, Map<string, any>> = new Map();
// Batches new events from sync together before starting the process to update the list.
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 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.
@ -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.
* @param stateType The event type e.g. m.room.policy.user.
* @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.
* @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.
*/
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,
// please make sure that you *only* return rules with `m.ban` or create a different method
// (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);
}
}
@ -160,8 +140,8 @@ class BanList extends EventEmitter {
public set listShortcode(newShortcode: string) {
const currentShortcode = this.shortcode;
this.shortcode = newShortcode;
this.client.sendStateEvent(this.roomId, SHORTCODE_EVENT_TYPE, '', {shortcode: this.shortcode}).catch(err => {
LogService.error("BanList", extractRequestError(err));
this.client.sendStateEvent(this.roomId, SHORTCODE_EVENT_TYPE, '', { shortcode: this.shortcode }).catch(err => {
LogService.error("PolicyList", extractRequestError(err));
if (this.shortcode === newShortcode) this.shortcode = currentShortcode;
});
}
@ -267,7 +247,7 @@ class BanList extends EventEmitter {
continue;
}
let kind: string|null = null;
let kind: EntityType | null = null;
if (USER_RULE_TYPES.includes(event['type'])) {
kind = RULE_USER;
} 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.
if (previousState) {
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.`);
}
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.
// We always set state with the normalised state type via `kind` to de-duplicate rules.
this.setState(kind, event['state_key'], event);
const changeType: null|ChangeType = (() => {
const changeType: null | ChangeType = (() => {
if (!previousState) {
return ChangeType.Added;
} 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.
if (changeType === ChangeType.Removed && previousState?.unsigned?.rule) {
const sender = event.unsigned['redacted_because'] ? event.unsigned['redacted_because']['sender'] : event.sender;
changes.push({changeType, event, sender, rule: previousState.unsigned.rule,
... previousState ? {previousState} : {} });
changes.push({
changeType, event, sender, rule: previousState.unsigned.rule,
...previousState ? { previousState } : {}
});
// Event has no content and cannot be parsed as a ListRule.
continue;
}
// It's a rule - parse it
const content = event['content'];
if (!content) continue;
const entity = content['entity'];
const recommendation = content['recommendation'];
const reason = content['reason'] || '<no reason>';
if (!entity || !recommendation) {
const rule = ListRule.parse(event);
if (!rule) {
// Invalid/unknown rule, just skip it.
continue;
}
const rule = new ListRule(entity, recommendation, reason, kind);
event.unsigned.rule = rule;
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;
}
/**
* Inform the `BanList` 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.
* Inform the `PolicyList` about a new event from the room it is modelling.
* @param event An event from the room the `PolicyList` models to inform an instance about.
*/
public updateForEvent(event: { event_id: string }): void {
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`.
*/
class UpdateBatcher {
// Whether we are waiting for more events to form a batch.
private isWaiting = false;
// 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 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));
} while ((Date.now() - start) < this.maxWaitMS && this.latestEventId !== eventId)
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
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 { 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 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.
*/
class EventRules {
constructor (
constructor(
readonly eventId: string,
readonly roomId: string,
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
* 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);
}
@ -118,7 +119,7 @@ export default class RuleServer {
* @throws If there are already rules associated with the event specified in `eventRules.eventId`.
*/
private addEventRules(eventRules: EventRules): void {
const {roomId, eventId, token} = eventRules;
const { roomId, eventId, token } = eventRules;
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}.`);
}
@ -136,7 +137,7 @@ export default class RuleServer {
* @param eventRules The EventRules to stop serving from the rule server.
*/
private stopEventRules(eventRules: EventRules): void {
const {eventId, roomId, token} = eventRules;
const { eventId, roomId, token } = eventRules;
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)
// 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);
this.addEventRules(eventRules);
} 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) {
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;
@ -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),
// 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.
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) {
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;
@ -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.
* @param banList a BanList to watch for rule changes with.
*/
public watch(banList: BanList): void {
banList.on('BanList.update', this.banListUpdateListener);
public watch(banList: PolicyList): void {
banList.on('PolicyList.update', this.banListUpdateListener);
}
/**
* Remove all of the rules that have been created from the policies in this banList.
* @param banList The BanList to unwatch.
*/
public unwatch(banList: BanList): void {
banList.removeListener('BanList.update', this.banListUpdateListener);
public unwatch(banList: PolicyList): void {
banList.removeListener('PolicyList.update', this.banListUpdateListener);
const listRules = this.rulesByEvent.get(banList.roomId);
this.nextToken();
if (listRules) {
@ -221,8 +222,8 @@ export default class RuleServer {
* @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.
*/
public getUpdates(sinceToken: string | null): {start: RuleServerRule[], stop: string[], reset?: boolean, since: string} {
const updatesSince = <T = EventRules|string>(token: number | null, policyStore: T[][]): T[] => {
public getUpdates(sinceToken: string | null): { start: RuleServerRule[], stop: string[], reset?: boolean, since: string } {
const updatesSince = <T = EventRules | string>(token: number | null, policyStore: T[][]): T[] => {
if (token === null) {
// The client is requesting for the first time, we will give them everything.
return policyStore.flat();
@ -234,7 +235,7 @@ export default class RuleServer {
}
}
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) {
// 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.
@ -261,59 +262,59 @@ export default class RuleServer {
* @returns An array of rules that can be served from the rule server.
*/
function toRuleServerFormat(policyRule: ListRule): RuleServerRule[] {
function makeLiteral(literal: string) {
return {literal}
}
function makeLiteral(literal: string) {
return { literal }
}
function makeGlob(glob: string) {
return {glob}
}
function makeGlob(glob: string) {
return { glob }
}
function makeServerGlob(server: string) {
return {glob: `:${server}`}
}
function makeServerGlob(server: string) {
return { glob: `:${server}` }
}
function makeRule(checks: Checks) {
return {
id: crypto.randomUUID(),
checks: checks
}
}
function makeRule(checks: Checks) {
return {
id: crypto.randomUUID(),
checks: checks
}
}
if (policyRule.kind === RULE_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.
return [{
property: USER_MAY_INVITE,
user_id: [makeGlob(policyRule.entity)]
},
{
property: CHECK_EVENT_FOR_SPAM,
sender: [makeGlob(policyRule.entity)]
}].map(makeRule)
} else if (policyRule.kind === RULE_ROOM) {
// 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.
return [{
property: USER_MAY_INVITE,
'room_id': [makeLiteral(policyRule.entity)]
},
{
property: CHECK_EVENT_FOR_SPAM,
'room_id': [makeLiteral(policyRule.entity)]
}].map(makeRule)
} else if (policyRule.kind === RULE_SERVER) {
// Block any invitations from the server or any new messages from the server.
return [{
property: USER_MAY_INVITE,
user_id: [makeServerGlob(policyRule.entity)]
},
{
property: CHECK_EVENT_FOR_SPAM,
sender: [makeServerGlob(policyRule.entity)]
}].map(makeRule)
} else {
LogService.info('RuleServer', `Ignoring unsupported policy rule type ${policyRule.kind}`);
return []
}
if (policyRule.kind === EntityType.RULE_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.
return [{
property: USER_MAY_INVITE,
user_id: [makeGlob(policyRule.entity)]
},
{
property: CHECK_EVENT_FOR_SPAM,
sender: [makeGlob(policyRule.entity)]
}].map(makeRule)
} 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
// stop anyone receiving invitations from the room.
return [{
property: USER_MAY_INVITE,
'room_id': [makeLiteral(policyRule.entity)]
},
{
property: CHECK_EVENT_FOR_SPAM,
'room_id': [makeLiteral(policyRule.entity)]
}].map(makeRule)
} else if (policyRule.kind === EntityType.RULE_SERVER) {
// Block any invitations from the server or any new messages from the server.
return [{
property: USER_MAY_INVITE,
user_id: [makeServerGlob(policyRule.entity)]
},
{
property: CHECK_EVENT_FOR_SPAM,
sender: [makeServerGlob(policyRule.entity)]
}].map(makeRule)
} else {
LogService.info('RuleServer', `Ignoring unsupported policy rule type ${policyRule.kind}`);
return []
}
}

View File

@ -18,7 +18,6 @@ import { Protection } from "./IProtection";
import { NumberProtectionSetting } from "./ProtectionSettings";
import { Mjolnir } from "../Mjolnir";
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
export const DEFAULT_MAX_PER_MINUTE = 10;
@ -64,7 +63,7 @@ export class BasicFlooding extends Protection {
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);
if (!config.noop) {
if (!mjolnir.config.noop) {
await mjolnir.client.banUser(event['sender'], roomId, "spam");
} else {
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
// Redact all the things the user said too
if (!config.noop) {
if (!mjolnir.config.noop) {
for (const eventId of forUser.map(e => e.eventId)) {
await mjolnir.client.redactEvent(roomId, eventId, "spam");
}

View File

@ -17,7 +17,6 @@ limitations under the License.
import { Protection } from "./IProtection";
import { Mjolnir } from "../Mjolnir";
import { LogLevel, LogService } from "matrix-bot-sdk";
import config from "../config";
import { isTrueJoinEvent } from "../utils";
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');
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}.`);
if (!config.noop) {
if (!mjolnir.config.noop) {
await mjolnir.client.banUser(event['sender'], roomId, "spam");
} else {
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
// Redact the event
if (!config.noop) {
if (!mjolnir.config.noop) {
await mjolnir.client.redactEvent(roomId, event['event_id'], "spam");
} else {
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 {NumberProtectionSetting} from "./ProtectionSettings";
import {LogLevel} from "matrix-bot-sdk";
import config from "../config";
const DEFAULT_MAX_PER_TIMESCALE = 50;
const DEFAULT_TIMESCALE_MINUTES = 60;
@ -89,7 +88,7 @@ export class JoinWaveShortCircuit extends Protection {
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);
if (!config.noop) {
if (!mjolnir.config.noop) {
await mjolnir.client.sendStateEvent(roomId, "m.room.join_rules", "", {"join_rule": "invite"})
} else {
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 { Mjolnir } from "../Mjolnir";
import { LogLevel, Permalinks, UserID } from "matrix-bot-sdk";
import config from "../config";
export class MessageIsMedia extends Protection {
@ -43,7 +42,7 @@ export class MessageIsMedia extends Protection {
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])}`);
// Redact the event
if (!config.noop) {
if (!mjolnir.config.noop) {
await mjolnir.client.redactEvent(roomId, event['event_id'], "Images/videos are not permitted here");
} else {
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 { Mjolnir } from "../Mjolnir";
import { LogLevel, Permalinks, UserID } from "matrix-bot-sdk";
import config from "../config";
export class MessageIsVoice extends Protection {
@ -40,7 +39,7 @@ export class MessageIsVoice extends Protection {
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])}`);
// Redact the event
if (!config.noop) {
if (!mjolnir.config.noop) {
await mjolnir.client.redactEvent(roomId, event['event_id'], "Voice messages are not permitted here");
} else {
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.
*/
import config from "../config";
import { Protection } from "./IProtection";
import { MXIDListProtectionSetting, NumberProtectionSetting } from "./ProtectionSettings";
import { Mjolnir } from "../Mjolnir";
@ -83,7 +82,7 @@ export class TrustedReporters extends Protection {
if (met.length > 0) {
await mjolnir.client.sendMessage(config.managementRoom, {
await mjolnir.client.sendMessage(mjolnir.config.managementRoom, {
msgtype: "m.notice",
body: `message ${event.id} reported by ${[...reporters].join(', ')}. `
+ `actions: ${met.join(', ')}`

View File

@ -17,7 +17,6 @@ limitations under the License.
import { Protection } from "./IProtection";
import { Mjolnir } from "../Mjolnir";
import { LogLevel, LogService } from "matrix-bot-sdk";
import config from "../config";
import { isTrueJoinEvent } from "../utils";
export class WordList extends Protection {
@ -25,15 +24,10 @@ export class WordList extends Protection {
settings = {};
private justJoined: { [roomId: string]: { [username: string]: Date} } = {};
private badWords: RegExp;
private badWords?: RegExp;
constructor() {
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 {
@ -47,7 +41,7 @@ export class WordList extends Protection {
public async handleEvent(mjolnir: Mjolnir, roomId: string, event: any): Promise<any> {
const content = event['content'] || {};
const minsBeforeTrusting = config.protections.wordlist.minutesBeforeTrusting;
const minsBeforeTrusting = mjolnir.config.protections.wordlist.minutesBeforeTrusting;
if (minsBeforeTrusting > 0) {
if (!this.justJoined[roomId]) this.justJoined[roomId] = {};
@ -89,19 +83,29 @@ export class WordList extends Protection {
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
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}.`);
if (!config.noop) {
if (!mjolnir.config.noop) {
await mjolnir.client.banUser(event['sender'], roomId, "Word list violation");
} else {
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
if (!config.noop) {
if (!mjolnir.config.noop) {
await mjolnir.client.redactEvent(roomId, event['event_id'], "spam");
} else {
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

@ -39,6 +39,7 @@ export class ProtectedRoomActivityTracker {
*/
public addProtectedRoom(roomId: string): void {
this.protectedRoomActivities.set(roomId, /* epoch */ 0);
this.activeRoomsCache = null;
}
/**
@ -47,6 +48,7 @@ export class ProtectedRoomActivityTracker {
*/
public removeProtectedRoom(roomId: string): void {
this.protectedRoomActivities.delete(roomId);
this.activeRoomsCache = null;
}
/**

View File

@ -14,7 +14,6 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import { extractRequestError, LogLevel, LogService, Permalinks } from "matrix-bot-sdk";
import config from "../config";
import { Mjolnir } from "../Mjolnir";
/**
@ -43,7 +42,7 @@ export class UnlistedUserRedactionQueue {
const permalink = Permalinks.forEvent(roomId, event['event_id']);
try {
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']);
} else {
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 { JSDOM } from 'jsdom';
import { EventEmitter } from 'events';
import config from "../config";
import { Mjolnir } from "../Mjolnir";
/// 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 }) {
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 });
}
}

View File

@ -18,7 +18,7 @@ import { Mjolnir, REPORT_POLL_EVENT_TYPE } from "../Mjolnir";
import { ReportManager } from './ReportManager';
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
@ -68,7 +68,11 @@ export class ReportPoller {
response_ = await this.mjolnir.client.doRequest(
"GET",
"/_synapse/admin/v1/event_reports",
{ from: this.from.toString() }
{
// short for direction: forward; i.e. show newest last
dir: "f",
from: this.from.toString()
}
);
} catch (ex) {
await this.mjolnir.logMessage(LogLevel.ERROR, "getAbuseReports", `failed to poll events: ${ex}`);
@ -108,7 +112,7 @@ export class ReportPoller {
if (response.next_token !== undefined) {
this.from = response.next_token;
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) {
await this.mjolnir.logMessage(LogLevel.ERROR, "getAbuseReports", `failed to update progress: ${ex}`);
}

View File

@ -28,7 +28,6 @@ import {
setRequestFn,
} from "matrix-bot-sdk";
import { Mjolnir } from "./Mjolnir";
import config from "./config";
import { ClientRequest, IncomingMessage } from "http";
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) => {
for (const victimEvent of eventsToRedact) {
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']);
} 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);

View File

@ -15,13 +15,11 @@ limitations under the License.
*/
import { Server } from "http";
import * as express from "express";
import { LogService, MatrixClient } from "matrix-bot-sdk";
import config from "../config";
import RuleServer from "../models/RuleServer";
import { ReportManager } from "../report/ReportManager";
import { IConfig } from "../config";
/**
@ -35,7 +33,7 @@ export class WebAPIs {
private webController: express.Express = express();
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.
this.webController.use(express.json());
}
@ -44,14 +42,14 @@ export class WebAPIs {
* Start accepting requests to the Web API.
*/
public async start() {
if (!config.web.enabled) {
if (!this.config.web.enabled) {
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.
if (config.web.abuseReporting.enabled) {
console.log(`Configuring ${API_PREFIX}/report/:room_id/:event_id...`);
// configure /report API.
if (this.config.web.abuseReporting.enabled) {
console.log(`configuring ${API_PREFIX}/report/:room_id/:event_id...`);
this.webController.options(`${API_PREFIX}/report/:room_id/:event_id`, async (request, response) => {
// reply with CORS options
response.header("Access-Control-Allow-Origin", "*");
@ -68,15 +66,15 @@ export class WebAPIs {
response.header("Access-Control-Allow-Methods", "POST, OPTIONS");
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?
// 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`;
LogService.info("WebAPIs", `Configuring ${updatesUrl}...`);
LogService.info("WebAPIs", `configuring ${updatesUrl}...`);
if (!this.ruleServer) {
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) => {
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
// 3. We are avoiding the use of the Synapse Admin API to ensure that
// 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 = () => {
throw new Error("We MUST NEVER call start on the reporter client");
};

View File

@ -18,18 +18,24 @@ import * as expect from "expect";
import { Mjolnir } from "../../src/Mjolnir";
import { DEFAULT_LIST_EVENT_TYPE } from "../../src/commands/SetDefaultBanListCommand";
import { parseArguments } from "../../src/commands/UnbanBanCommand";
import { RULE_ROOM, RULE_SERVER, RULE_USER } from "../../src/models/BanList";
import { read as configRead } 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 config = configRead();
const client = {
// Mock `MatrixClient.getAccountData` .
getAccountData: (eventType: string): Promise<any> => {
if (eventType === DEFAULT_LIST_EVENT_TYPE && defaultShortcode) {
if (eventType === DEFAULT_LIST_EVENT_TYPE || 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 {
@ -55,11 +61,11 @@ describe("UnbanBanCommand", () => {
const command = "!mjolnir ban test example.org";
const bits = await parseArguments("!a", createFakeEvent(command), mjolnir, command.split(' '));
expect(bits).toBeTruthy();
expect(bits.reason).toBeFalsy();
expect(bits.ruleType).toBe(RULE_SERVER);
expect(bits.entity).toBe("example.org");
expect(bits.list).toBeDefined();
expect(bits.list.listShortcode).toBe("test");
expect(bits!.reason).toBeFalsy();
expect(bits!.ruleType).toBe(RULE_SERVER);
expect(bits!.entity).toBe("example.org");
expect(bits!.list).toBeDefined();
expect(bits!.list!.listShortcode).toBe("test");
});
it("should be able to detect servers with ban reasons", async () => {
@ -72,11 +78,11 @@ describe("UnbanBanCommand", () => {
const command = "!mjolnir ban test example.org reason here";
const bits = await parseArguments("!a", createFakeEvent(command), mjolnir, command.split(' '));
expect(bits).toBeTruthy();
expect(bits.reason).toBe("reason here");
expect(bits.ruleType).toBe(RULE_SERVER);
expect(bits.entity).toBe("example.org");
expect(bits.list).toBeDefined();
expect(bits.list.listShortcode).toBe("test");
expect(bits!.reason).toBe("reason here");
expect(bits!.ruleType).toBe(RULE_SERVER);
expect(bits!.entity).toBe("example.org");
expect(bits!.list).toBeDefined();
expect(bits!.list!.listShortcode).toBe("test");
});
it("should be able to detect servers with globs", async () => {
@ -89,11 +95,11 @@ describe("UnbanBanCommand", () => {
const command = "!mjolnir ban test *.example.org --force";
const bits = await parseArguments("!a", createFakeEvent(command), mjolnir, command.split(' '));
expect(bits).toBeTruthy();
expect(bits.reason).toBeFalsy();
expect(bits.ruleType).toBe(RULE_SERVER);
expect(bits.entity).toBe("*.example.org");
expect(bits.list).toBeDefined();
expect(bits.list.listShortcode).toBe("test");
expect(bits!.reason).toBeFalsy();
expect(bits!.ruleType).toBe(RULE_SERVER);
expect(bits!.entity).toBe("*.example.org");
expect(bits!.list).toBeDefined();
expect(bits!.list!.listShortcode).toBe("test");
});
it("should be able to detect servers with the type specified", async () => {
@ -106,11 +112,11 @@ describe("UnbanBanCommand", () => {
const command = "!mjolnir ban test server @*.example.org --force";
const bits = await parseArguments("!a", createFakeEvent(command), mjolnir, command.split(' '));
expect(bits).toBeTruthy();
expect(bits.reason).toBeFalsy();
expect(bits.ruleType).toBe(RULE_SERVER);
expect(bits.entity).toBe("@*.example.org");
expect(bits.list).toBeDefined();
expect(bits.list.listShortcode).toBe("test");
expect(bits!.reason).toBeFalsy();
expect(bits!.ruleType).toBe(RULE_SERVER);
expect(bits!.entity).toBe("@*.example.org");
expect(bits!.list).toBeDefined();
expect(bits!.list!.listShortcode).toBe("test");
});
it("should be able to detect room IDs", async () => {
@ -123,11 +129,11 @@ describe("UnbanBanCommand", () => {
const command = "!mjolnir ban test !example.org";
const bits = await parseArguments("!a", createFakeEvent(command), mjolnir, command.split(' '));
expect(bits).toBeTruthy();
expect(bits.reason).toBeFalsy();
expect(bits.ruleType).toBe(RULE_ROOM);
expect(bits.entity).toBe("!example.org");
expect(bits.list).toBeDefined();
expect(bits.list.listShortcode).toBe("test");
expect(bits!.reason).toBeFalsy();
expect(bits!.ruleType).toBe(RULE_ROOM);
expect(bits!.entity).toBe("!example.org");
expect(bits!.list).toBeDefined();
expect(bits!.list!.listShortcode).toBe("test");
});
it("should be able to detect room IDs with ban reasons", async () => {
@ -140,11 +146,11 @@ describe("UnbanBanCommand", () => {
const command = "!mjolnir ban test !example.org reason here";
const bits = await parseArguments("!a", createFakeEvent(command), mjolnir, command.split(' '));
expect(bits).toBeTruthy();
expect(bits.reason).toBe("reason here");
expect(bits.ruleType).toBe(RULE_ROOM);
expect(bits.entity).toBe("!example.org");
expect(bits.list).toBeDefined();
expect(bits.list.listShortcode).toBe("test");
expect(bits!.reason).toBe("reason here");
expect(bits!.ruleType).toBe(RULE_ROOM);
expect(bits!.entity).toBe("!example.org");
expect(bits!.list).toBeDefined();
expect(bits!.list!.listShortcode).toBe("test");
});
it("should be able to detect room IDs with globs", async () => {
@ -157,11 +163,11 @@ describe("UnbanBanCommand", () => {
const command = "!mjolnir ban test !*.example.org --force";
const bits = await parseArguments("!a", createFakeEvent(command), mjolnir, command.split(' '));
expect(bits).toBeTruthy();
expect(bits.reason).toBeFalsy();
expect(bits.ruleType).toBe(RULE_ROOM);
expect(bits.entity).toBe("!*.example.org");
expect(bits.list).toBeDefined();
expect(bits.list.listShortcode).toBe("test");
expect(bits!.reason).toBeFalsy();
expect(bits!.ruleType).toBe(RULE_ROOM);
expect(bits!.entity).toBe("!*.example.org");
expect(bits!.list).toBeDefined();
expect(bits!.list!.listShortcode).toBe("test");
});
it("should be able to detect room aliases", async () => {
@ -174,11 +180,11 @@ describe("UnbanBanCommand", () => {
const command = "!mjolnir ban test #example.org";
const bits = await parseArguments("!a", createFakeEvent(command), mjolnir, command.split(' '));
expect(bits).toBeTruthy();
expect(bits.reason).toBeFalsy();
expect(bits.ruleType).toBe(RULE_ROOM);
expect(bits.entity).toBe("#example.org");
expect(bits.list).toBeDefined();
expect(bits.list.listShortcode).toBe("test");
expect(bits!.reason).toBeFalsy();
expect(bits!.ruleType).toBe(RULE_ROOM);
expect(bits!.entity).toBe("#example.org");
expect(bits!.list).toBeDefined();
expect(bits!.list!.listShortcode).toBe("test");
});
it("should be able to detect room aliases with ban reasons", async () => {
@ -191,11 +197,11 @@ describe("UnbanBanCommand", () => {
const command = "!mjolnir ban test #example.org reason here";
const bits = await parseArguments("!a", createFakeEvent(command), mjolnir, command.split(' '));
expect(bits).toBeTruthy();
expect(bits.reason).toBe("reason here");
expect(bits.ruleType).toBe(RULE_ROOM);
expect(bits.entity).toBe("#example.org");
expect(bits.list).toBeDefined();
expect(bits.list.listShortcode).toBe("test");
expect(bits!.reason).toBe("reason here");
expect(bits!.ruleType).toBe(RULE_ROOM);
expect(bits!.entity).toBe("#example.org");
expect(bits!.list).toBeDefined();
expect(bits!.list!.listShortcode).toBe("test");
});
it("should be able to detect room aliases with globs", async () => {
@ -208,11 +214,11 @@ describe("UnbanBanCommand", () => {
const command = "!mjolnir ban test #*.example.org --force";
const bits = await parseArguments("!a", createFakeEvent(command), mjolnir, command.split(' '));
expect(bits).toBeTruthy();
expect(bits.reason).toBeFalsy();
expect(bits.ruleType).toBe(RULE_ROOM);
expect(bits.entity).toBe("#*.example.org");
expect(bits.list).toBeDefined();
expect(bits.list.listShortcode).toBe("test");
expect(bits!.reason).toBeFalsy();
expect(bits!.ruleType).toBe(RULE_ROOM);
expect(bits!.entity).toBe("#*.example.org");
expect(bits!.list).toBeDefined();
expect(bits!.list!.listShortcode).toBe("test");
});
it("should be able to detect rooms with the type specified", async () => {
@ -225,11 +231,11 @@ describe("UnbanBanCommand", () => {
const command = "!mjolnir ban test room @*.example.org --force";
const bits = await parseArguments("!a", createFakeEvent(command), mjolnir, command.split(' '));
expect(bits).toBeTruthy();
expect(bits.reason).toBeFalsy();
expect(bits.ruleType).toBe(RULE_ROOM);
expect(bits.entity).toBe("@*.example.org");
expect(bits.list).toBeDefined();
expect(bits.list.listShortcode).toBe("test");
expect(bits!.reason).toBeFalsy();
expect(bits!.ruleType).toBe(RULE_ROOM);
expect(bits!.entity).toBe("@*.example.org");
expect(bits!.list).toBeDefined();
expect(bits!.list!.listShortcode).toBe("test");
});
it("should be able to detect user IDs", async () => {
@ -242,11 +248,11 @@ describe("UnbanBanCommand", () => {
const command = "!mjolnir ban test @example.org";
const bits = await parseArguments("!a", createFakeEvent(command), mjolnir, command.split(' '));
expect(bits).toBeTruthy();
expect(bits.reason).toBeFalsy();
expect(bits.ruleType).toBe(RULE_USER);
expect(bits.entity).toBe("@example.org");
expect(bits.list).toBeDefined();
expect(bits.list.listShortcode).toBe("test");
expect(bits!.reason).toBeFalsy();
expect(bits!.ruleType).toBe(RULE_USER);
expect(bits!.entity).toBe("@example.org");
expect(bits!.list).toBeDefined();
expect(bits!.list!.listShortcode).toBe("test");
});
it("should be able to detect user IDs with ban reasons", async () => {
@ -259,11 +265,11 @@ describe("UnbanBanCommand", () => {
const command = "!mjolnir ban test @example.org reason here";
const bits = await parseArguments("!a", createFakeEvent(command), mjolnir, command.split(' '));
expect(bits).toBeTruthy();
expect(bits.reason).toBe("reason here");
expect(bits.ruleType).toBe(RULE_USER);
expect(bits.entity).toBe("@example.org");
expect(bits.list).toBeDefined();
expect(bits.list.listShortcode).toBe("test");
expect(bits!.reason).toBe("reason here");
expect(bits!.ruleType).toBe(RULE_USER);
expect(bits!.entity).toBe("@example.org");
expect(bits!.list).toBeDefined();
expect(bits!.list!.listShortcode).toBe("test");
});
it("should be able to detect user IDs with globs", async () => {
@ -276,11 +282,11 @@ describe("UnbanBanCommand", () => {
const command = "!mjolnir ban test @*.example.org --force";
const bits = await parseArguments("!a", createFakeEvent(command), mjolnir, command.split(' '));
expect(bits).toBeTruthy();
expect(bits.reason).toBeFalsy();
expect(bits.ruleType).toBe(RULE_USER);
expect(bits.entity).toBe("@*.example.org");
expect(bits.list).toBeDefined();
expect(bits.list.listShortcode).toBe("test");
expect(bits!.reason).toBeFalsy();
expect(bits!.ruleType).toBe(RULE_USER);
expect(bits!.entity).toBe("@*.example.org");
expect(bits!.list).toBeDefined();
expect(bits!.list!.listShortcode).toBe("test");
});
it("should be able to detect user IDs with the type specified", async () => {
@ -293,11 +299,11 @@ describe("UnbanBanCommand", () => {
const command = "!mjolnir ban test user #*.example.org --force";
const bits = await parseArguments("!a", createFakeEvent(command), mjolnir, command.split(' '));
expect(bits).toBeTruthy();
expect(bits.reason).toBeFalsy();
expect(bits.ruleType).toBe(RULE_USER);
expect(bits.entity).toBe("#*.example.org");
expect(bits.list).toBeDefined();
expect(bits.list.listShortcode).toBe("test");
expect(bits!.reason).toBeFalsy();
expect(bits!.ruleType).toBe(RULE_USER);
expect(bits!.entity).toBe("#*.example.org");
expect(bits!.list).toBeDefined();
expect(bits!.list!.listShortcode).toBe("test");
});
it("should error if wildcards used without --force", async () => {
@ -324,11 +330,11 @@ describe("UnbanBanCommand", () => {
const command = "!mjolnir ban test user #*.example.org reason here --force";
const bits = await parseArguments("!a", createFakeEvent(command), mjolnir, command.split(' '));
expect(bits).toBeTruthy();
expect(bits.reason).toBe("reason here");
expect(bits.ruleType).toBe(RULE_USER);
expect(bits.entity).toBe("#*.example.org");
expect(bits.list).toBeDefined();
expect(bits.list.listShortcode).toBe("test");
expect(bits!.reason).toBe("reason here");
expect(bits!.ruleType).toBe(RULE_USER);
expect(bits!.entity).toBe("#*.example.org");
expect(bits!.list).toBeDefined();
expect(bits!.list!.listShortcode).toBe("test");
});
describe("[without default list]", () => {
@ -370,11 +376,11 @@ describe("UnbanBanCommand", () => {
const command = "!mjolnir ban user test @example:example.org";
const bits = await parseArguments("!a", createFakeEvent(command), mjolnir, command.split(' '));
expect(bits).toBeTruthy();
expect(bits.reason).toBeFalsy();
expect(bits.ruleType).toBe(RULE_USER);
expect(bits.entity).toBe("@example:example.org");
expect(bits.list).toBeDefined();
expect(bits.list.listShortcode).toBe("test");
expect(bits!.reason).toBeFalsy();
expect(bits!.ruleType).toBe(RULE_USER);
expect(bits!.entity).toBe("@example:example.org");
expect(bits!.list).toBeDefined();
expect(bits!.list!.listShortcode).toBe("test");
});
it("should not error if a list (without type) is specified", async () => {
@ -387,11 +393,11 @@ describe("UnbanBanCommand", () => {
const command = "!mjolnir ban test @example:example.org";
const bits = await parseArguments("!a", createFakeEvent(command), mjolnir, command.split(' '));
expect(bits).toBeTruthy();
expect(bits.reason).toBeFalsy();
expect(bits.ruleType).toBe(RULE_USER);
expect(bits.entity).toBe("@example:example.org");
expect(bits.list).toBeDefined();
expect(bits.list.listShortcode).toBe("test");
expect(bits!.reason).toBeFalsy();
expect(bits!.ruleType).toBe(RULE_USER);
expect(bits!.entity).toBe("@example:example.org");
expect(bits!.list).toBeDefined();
expect(bits!.list!.listShortcode).toBe("test");
});
it("should not error if a list (with type reversed) is specified", async () => {
@ -404,11 +410,11 @@ describe("UnbanBanCommand", () => {
const command = "!mjolnir ban test user @example:example.org";
const bits = await parseArguments("!a", createFakeEvent(command), mjolnir, command.split(' '));
expect(bits).toBeTruthy();
expect(bits.reason).toBeFalsy();
expect(bits.ruleType).toBe(RULE_USER);
expect(bits.entity).toBe("@example:example.org");
expect(bits.list).toBeDefined();
expect(bits.list.listShortcode).toBe("test");
expect(bits!.reason).toBeFalsy();
expect(bits!.ruleType).toBe(RULE_USER);
expect(bits!.entity).toBe("@example:example.org");
expect(bits!.list).toBeDefined();
expect(bits!.list!.listShortcode).toBe("test");
});
});
@ -423,11 +429,11 @@ describe("UnbanBanCommand", () => {
const command = "!mjolnir ban user @example:example.org";
const bits = await parseArguments("!a", createFakeEvent(command), mjolnir, command.split(' '));
expect(bits).toBeTruthy();
expect(bits.reason).toBeFalsy();
expect(bits.ruleType).toBe(RULE_USER);
expect(bits.entity).toBe("@example:example.org");
expect(bits.list).toBeDefined();
expect(bits.list.listShortcode).toBe("test");
expect(bits!.reason).toBeFalsy();
expect(bits!.ruleType).toBe(RULE_USER);
expect(bits!.entity).toBe("@example:example.org");
expect(bits!.list).toBeDefined();
expect(bits!.list!.listShortcode).toBe("test");
});
it("should use the default list if no list (without type) is specified", async () => {
@ -440,11 +446,11 @@ describe("UnbanBanCommand", () => {
const command = "!mjolnir ban @example:example.org";
const bits = await parseArguments("!a", createFakeEvent(command), mjolnir, command.split(' '));
expect(bits).toBeTruthy();
expect(bits.reason).toBeFalsy();
expect(bits.ruleType).toBe(RULE_USER);
expect(bits.entity).toBe("@example:example.org");
expect(bits.list).toBeDefined();
expect(bits.list.listShortcode).toBe("test");
expect(bits!.reason).toBeFalsy();
expect(bits!.ruleType).toBe(RULE_USER);
expect(bits!.entity).toBe("@example:example.org");
expect(bits!.list).toBeDefined();
expect(bits!.list!.listShortcode).toBe("test");
});
it("should use the specified list if a list (with type) is specified", async () => {
@ -457,11 +463,11 @@ describe("UnbanBanCommand", () => {
const command = "!mjolnir ban user other @example:example.org";
const bits = await parseArguments("!a", createFakeEvent(command), mjolnir, command.split(' '));
expect(bits).toBeTruthy();
expect(bits.reason).toBeFalsy();
expect(bits.ruleType).toBe(RULE_USER);
expect(bits.entity).toBe("@example:example.org");
expect(bits.list).toBeDefined();
expect(bits.list.listShortcode).toBe("other");
expect(bits!.reason).toBeFalsy();
expect(bits!.ruleType).toBe(RULE_USER);
expect(bits!.entity).toBe("@example:example.org");
expect(bits!.list).toBeDefined();
expect(bits!.list!.listShortcode).toBe("other");
});
it("should use the specified list if a list (without type) is specified", async () => {
@ -474,11 +480,11 @@ describe("UnbanBanCommand", () => {
const command = "!mjolnir ban other @example:example.org";
const bits = await parseArguments("!a", createFakeEvent(command), mjolnir, command.split(' '));
expect(bits).toBeTruthy();
expect(bits.reason).toBeFalsy();
expect(bits.ruleType).toBe(RULE_USER);
expect(bits.entity).toBe("@example:example.org");
expect(bits.list).toBeDefined();
expect(bits.list.listShortcode).toBe("other");
expect(bits!.reason).toBeFalsy();
expect(bits!.ruleType).toBe(RULE_USER);
expect(bits!.entity).toBe("@example:example.org");
expect(bits!.list).toBeDefined();
expect(bits!.list!.listShortcode).toBe("other");
});
it("should not error if a list (with type reversed) is specified", async () => {
@ -491,11 +497,11 @@ describe("UnbanBanCommand", () => {
const command = "!mjolnir ban other user @example:example.org";
const bits = await parseArguments("!a", createFakeEvent(command), mjolnir, command.split(' '));
expect(bits).toBeTruthy();
expect(bits.reason).toBeFalsy();
expect(bits.ruleType).toBe(RULE_USER);
expect(bits.entity).toBe("@example:example.org");
expect(bits.list).toBeDefined();
expect(bits.list.listShortcode).toBe("other");
expect(bits!.reason).toBeFalsy();
expect(bits!.ruleType).toBe(RULE_USER);
expect(bits!.entity).toBe("@example:example.org");
expect(bits!.list).toBeDefined();
expect(bits!.list!.listShortcode).toBe("other");
});
});
});

View File

@ -32,8 +32,8 @@ describe("Test: Reporting abuse", async () => {
});
// Create a few users and a room.
let goodUser = await newTestUser({ name: { contains: "reporting-abuse-good-user" }});
let badUser = await newTestUser({ name: { contains: "reporting-abuse-bad-user" }});
let goodUser = await newTestUser(this.config.homeserverUrl, { name: { contains: "reporting-abuse-good-user" }});
let badUser = await newTestUser(this.config.homeserverUrl, { name: { contains: "reporting-abuse-bad-user" }});
let goodUserId = await goodUser.getUserId();
let badUserId = await badUser.getUserId();
@ -227,13 +227,13 @@ describe("Test: Reporting abuse", async () => {
});
// Create a moderator.
let moderatorUser = await newTestUser({ name: { contains: "reporting-abuse-moderator-user" }});
let moderatorUser = await newTestUser(this.config.homeserverUrl, { name: { contains: "reporting-abuse-moderator-user" }});
matrixClient().inviteUser(await moderatorUser.getUserId(), this.mjolnir.managementRoomId);
await moderatorUser.joinRoom(this.mjolnir.managementRoomId);
// Create a few users and a room.
let goodUser = await newTestUser({ name: { contains: "reacting-abuse-good-user" }});
let badUser = await newTestUser({ name: { contains: "reacting-abuse-bad-user" }});
let goodUser = await newTestUser(this.config.homeserverUrl, { name: { contains: "reacting-abuse-good-user" }});
let badUser = await newTestUser(this.config.homeserverUrl, { name: { contains: "reacting-abuse-bad-user" }});
let goodUserId = await goodUser.getUserId();
let badUserId = await badUser.getUserId();

View File

@ -0,0 +1,48 @@
import { MatrixClient } from "matrix-bot-sdk";
import { Mjolnir } from "../../src/Mjolnir"
import { newTestUser } from "./clientHelper";
describe("Test: Accept Invites From Space", function() {
let client: MatrixClient|undefined;
this.beforeEach(async function () {
client = await newTestUser(this.config.homeserverUrl, { name: { contains: "spacee" }});
await client.start();
})
this.afterEach(async function () {
await client.stop();
})
it("Mjolnir should accept an invite from a user in a nominated Space", async function() {
this.timeout(20000);
const mjolnir: Mjolnir = this.mjolnir!;
const mjolnirUserId = await mjolnir.client.getUserId();
const space = await client.createSpace({
name: "mjolnir space invite test",
invites: [mjolnirUserId],
isPublic: false
});
await this.mjolnir.client.joinRoom(space.roomId);
// we're mutating a static object, which may affect other tests :(
mjolnir.config.autojoinOnlyIfManager = false;
mjolnir.config.acceptInvitesFromSpace = space.roomId;
const promise = new Promise(async resolve => {
const newRoomId = await client.createRoom({ invite: [mjolnirUserId] });
client.on("room.event", (roomId, event) => {
if (
roomId === newRoomId
&& event.type === "m.room.member"
&& event.sender === mjolnirUserId
&& event.content?.membership === "join"
) {
resolve(null);
}
});
});
await promise;
});
});

View File

@ -1,12 +1,12 @@
import { strict as assert } from "assert";
import config from "../../src/config";
import { newTestUser } from "./clientHelper";
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 { getFirstReaction } from "./commands/commandUtils";
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.
@ -18,7 +18,7 @@ import { getMessagesByUserIn } from "../../src/utils";
* @param template The template to use for the policy rule event.
* @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}`, {
entity,
reason,
@ -26,36 +26,36 @@ async function createPolicyRule(client: MatrixClient, policyRoomId: string, poli
});
}
describe("Test: Updating the BanList", function () {
it("Calculates what has changed correctly.", async function () {
describe("Test: Updating the PolicyList", function() {
it("Calculates what has changed correctly.", async function() {
this.timeout(10000);
const mjolnir = config.RUNTIME.client!
const moderator = await newTestUser({ name: { contains: "moderator" }});
const banListId = await mjolnir.createRoom({ invite: [await moderator.getUserId()]});
const banList = new BanList(banListId, banListId, mjolnir);
mjolnir.setUserPowerLevel(await moderator.getUserId(), banListId, 100);
const mjolnir: Mjolnir = this.mjolnir!
const moderator = await newTestUser(this.config.homeserverUrl, { name: { contains: "moderator" } });
const banListId = await mjolnir.client.createRoom({ invite: [await moderator.getUserId()] });
const banList = new PolicyList(banListId, banListId, mjolnir.client);
mjolnir.client.setUserPowerLevel(await moderator.getUserId(), banListId, 100);
assert.equal(banList.allRules.length, 0);
// 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();
assert.equal(changes.length, 1, 'There should only be one change');
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.allRules.length, 1);
// 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();
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();
assert.equal(changes.length, 1);
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].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();
assert.equal(changes.length, 1);
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');
// 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();
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();
assert.equal(changes.length, 1);
assert.equal(changes[0].changeType, ChangeType.Removed);
@ -79,10 +79,10 @@ describe("Test: Updating the BanList", function () {
// Test soft redaction of a rule
const softRedactedEntity = '@softredacted:localhost:9999'
await createPolicyRule(mjolnir, banListId, RULE_USER, softRedactedEntity, '');
await createPolicyRule(mjolnir.client, banListId, RULE_USER, softRedactedEntity, '');
await banList.updateList();
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();
assert.equal(changes.length, 1);
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');
// 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();
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');
// 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();
assert.equal(changes.length, 1);
assert.equal(changes[0].changeType, ChangeType.Added);
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();
assert.equal(changes.length, 1);
assert.equal(changes[0].changeType, ChangeType.Modified);
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(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();
assert.equal(changes.length, 1);
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(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);
const mjolnir = config.RUNTIME.client!
const moderator = await newTestUser({ name: { contains: "moderator" }});
const banListId = await mjolnir.createRoom({ invite: [await moderator.getUserId()]});
const banList = new BanList(banListId, banListId, mjolnir);
mjolnir.setUserPowerLevel(await moderator.getUserId(), banListId, 100);
const mjolnir: Mjolnir = this.mjolnir!
const moderator = await newTestUser(this.config.homeserverUrl, { name: { contains: "moderator" }} );
const banListId = await mjolnir.client.createRoom({ invite: [await moderator.getUserId()] });
const banList = new PolicyList(banListId, banListId, mjolnir.client);
mjolnir.client.setUserPowerLevel(await moderator.getUserId(), banListId, 100);
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();
assert.equal(changes.length, 1);
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...')
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();
assert.equal(changes.length, 1);
assert.equal(changes[0].changeType, ChangeType.Removed);
@ -140,21 +140,20 @@ 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(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 () {
this.timeout(3000);
const mjolnir = config.RUNTIME.client!
const moderator = await newTestUser({ name: { contains: "moderator" }});
const banListId = await mjolnir.createRoom({ invite: [await moderator.getUserId()]});
const banList = new BanList(banListId, banListId, mjolnir);
mjolnir.setUserPowerLevel(await moderator.getUserId(), banListId, 100);
it("A rule of the most recent type won't be deleted when an old rule is deleted for the same entity.", async function() {
const mjolnir: Mjolnir = this.mjolnir!
const moderator = await newTestUser(this.config.homeserverUrl, { name: { contains: "moderator" } });
const banListId = await mjolnir.client.createRoom({ invite: [await moderator.getUserId()] });
const banList = new PolicyList(banListId, banListId, mjolnir.client);
mjolnir.client.setUserPowerLevel(await moderator.getUserId(), banListId, 100);
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();
assert.equal(changes.length, 1);
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...')
let updatedEventId = await createPolicyRule(mjolnir, banListId, RULE_USER, entity, '');
let updatedEventId = await createPolicyRule(mjolnir.client, banListId, RULE_USER, entity, '');
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
// is the rule type. The actual content is identical.
@ -165,13 +164,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.');
// 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();
assert.equal(changes.length, 0);
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.
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();
assert.equal(changes.length, 1);
assert.equal(changes[0].changeType, ChangeType.Removed);
@ -179,12 +178,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(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 () {
const mjolnir = config.RUNTIME.client!
const banListId = await mjolnir.createRoom();
const banList = new BanList(banListId, banListId, mjolnir);
it('Test: PolicyList Supports all entity types.', async function () {
const mjolnir: Mjolnir = this.mjolnir!
const banListId = await mjolnir.client.createRoom();
const banList = new PolicyList(banListId, banListId, mjolnir.client);
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();
assert.equal(changes.length, ALL_RULE_TYPES.length);
@ -192,31 +191,31 @@ describe("Test: Updating the BanList", function () {
})
});
describe('Test: We do not respond to recommendations other than m.ban in the banlist', 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 banListId = await mjolnir.createRoom();
const banList = new BanList(banListId, banListId, mjolnir);
await createPolicyRule(mjolnir, banListId, RULE_SERVER, 'exmaple.org', '', {recommendation: 'something that is not m.ban'});
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() {
const mjolnir: Mjolnir = this.mjolnir!
const banListId = await mjolnir.client.createRoom();
const banList = new PolicyList(banListId, banListId, mjolnir.client);
await createPolicyRule(mjolnir.client, banListId, RULE_SERVER, 'exmaple.org', '', { recommendation: 'something that is not m.ban' });
let changes: ListRuleChange[] = await banList.updateList();
assert.equal(changes.length, 1, 'There should only be one change');
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.
assert.equal(banList.serverRules.length, 0);
assert.equal(banList.allRules.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, `We should have an empty allRules, got ${JSON.stringify(banList.allRules)}`);
})
})
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 () {
const mjolnir = config.RUNTIME.client!
const serverName = new UserID(await mjolnir.getUserId()).domain;
const banListId = await mjolnir.createRoom();
const banList = new BanList(banListId, banListId, mjolnir);
await createPolicyRule(mjolnir, banListId, RULE_SERVER, serverName, '');
await createPolicyRule(mjolnir, banListId, RULE_SERVER, 'evil.com', '');
await createPolicyRule(mjolnir, banListId, RULE_SERVER, '*', '');
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() {
const mjolnir: Mjolnir = this.mjolnir
const serverName = new UserID(await mjolnir.client.getUserId()).domain;
const banListId = await mjolnir.client.createRoom();
const banList = new PolicyList(banListId, banListId, mjolnir.client);
await createPolicyRule(mjolnir.client, banListId, RULE_SERVER, serverName, '');
await createPolicyRule(mjolnir.client, banListId, RULE_SERVER, 'evil.com', '');
await createPolicyRule(mjolnir.client, banListId, RULE_SERVER, '*', '');
// We should still intern the matching rules rule.
let changes: ListRuleChange[] = await banList.updateList();
assert.equal(banList.serverRules.length, 3);
@ -229,62 +228,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 () {
it('Will batch ACL updates if we spam rules into a BanList', async function () {
const mjolnir = config.RUNTIME.client!
const serverName: string = new UserID(await mjolnir.getUserId()).domain
const moderator = await newTestUser({ name: { contains: "moderator" }});
moderator.joinRoom(this.mjolnir.managementRoomId);
const mjolnirId = await mjolnir.getUserId();
describe('Test: ACL updates will batch when rules are added in succession.', function() {
it('Will batch ACL updates if we spam rules into a PolicyList', async function() {
const mjolnir: Mjolnir = this.mjolnir!
const serverName: string = new UserID(await mjolnir.client.getUserId()).domain
const moderator = await newTestUser(this.config.homeserverUrl, { name: { contains: "moderator" } });
moderator.joinRoom(this.mjolnir.client.managementRoomId);
const mjolnirId = await mjolnir.client.getUserId();
// Setup some protected rooms so we can check their ACL state later.
const protectedRooms: string[] = [];
for (let i = 0; i < 10; i++) {
const room = await moderator.createRoom({ invite: [mjolnirId]});
await mjolnir.joinRoom(room);
for (let i = 0; i < 5; i++) {
const room = await moderator.createRoom({ invite: [mjolnirId] });
await mjolnir.client.joinRoom(room);
await moderator.setUserPowerLevel(mjolnirId, room, 100);
await this.mjolnir!.addProtectedRoom(room);
await mjolnir.addProtectedRoom(room);
protectedRooms.push(room);
}
// 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 => {
// 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.');
}));
// 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] });
mjolnir.joinRoom(banListId);
this.mjolnir!.watchList(Permalinks.forRoom(banListId));
mjolnir.client.joinRoom(banListId);
mjolnir.watchList(Permalinks.forRoom(banListId));
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`;
acl.denyServer(badServer);
await createPolicyRule(moderator, banListId, RULE_SERVER, badServer, `Rule #${i}`);
// Give them a bit of a spread over time.
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.
// 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.
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)) {
assert.fail(`Room ${room} doesn't have the correct ACL: ${JSON.stringify(roomAcl, null, 2)}`)
}
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);
});
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()`
// 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.
@ -293,36 +295,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(); });
it('Will remove rules that have legacy types', async function () {
this.timeout(20000)
const mjolnir = config.RUNTIME.client!
const serverName: string = new UserID(await mjolnir.getUserId()).domain
const moderator = await newTestUser({ name: { contains: "moderator" }});
it('Will remove rules that have legacy types', async function() {
const mjolnir: Mjolnir = this.mjolnir!
const serverName: string = new UserID(await mjolnir.client.getUserId()).domain
const moderator = await newTestUser(this.config.homeserverUrl, { name: { contains: "moderator" } });
this.moderator = moderator;
moderator.joinRoom(this.mjolnir.managementRoomId);
const mjolnirId = await mjolnir.getUserId();
await moderator.joinRoom(mjolnir.managementRoomId);
const mjolnirId = await mjolnir.client.getUserId();
// We'll make 1 protected room to test ACLs in.
const protectedRoom = await moderator.createRoom({ invite: [mjolnirId]});
await mjolnir.joinRoom(protectedRoom);
const protectedRoom = await moderator.createRoom({ invite: [mjolnirId] });
await mjolnir.client.joinRoom(protectedRoom);
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.
await this.mjolnir!.syncLists();
const roomAcl = await mjolnir.getRoomStateEvent(protectedRoom, "m.room.server_acl", "");
await mjolnir.syncLists();
// If this is not present, then it means the room isn't being protected, which is really bad.
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.');
// Create some legacy rules on a BanList.
// Create some legacy rules on a PolicyList.
const banListId = await moderator.createRoom({ invite: [mjolnirId] });
await moderator.setUserPowerLevel(await mjolnir.getUserId(), banListId, 100);
await moderator.sendStateEvent(banListId, 'org.matrix.mjolnir.shortcode', '', { shortcode: "unban-test"});
await mjolnir.joinRoom(banListId);
this.mjolnir!.watchList(Permalinks.forRoom(banListId));
await moderator.setUserPowerLevel(await mjolnir.client.getUserId(), banListId, 100);
await moderator.sendStateEvent(banListId, 'org.matrix.mjolnir.shortcode', '', { shortcode: "unban-test" });
await mjolnir.client.joinRoom(banListId);
await mjolnir.watchList(Permalinks.forRoom(banListId));
// 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
// and another one that only has one (so that we would hit 404 while looking up state)
const olderBadServer = "old.evil.com"
@ -338,7 +340,7 @@ describe('Test: unbaning entities via the BanList.', function () {
// 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 protectedAcl = await mjolnir.getRoomStateEvent(protectedRoom, "m.room.server_acl", "");
const protectedAcl = await mjolnir.client.getRoomStateEvent(protectedRoom, "m.room.server_acl", "");
if (!acl.matches(protectedAcl)) {
assert.fail(`Room ${protectedRoom} doesn't have the correct ACL: ${JSON.stringify(roomAcl, null, 2)}`);
}
@ -347,8 +349,8 @@ describe('Test: unbaning entities via the BanList.', function () {
try {
await moderator.start();
for (const server of [olderBadServer, newerBadServer]) {
await getFirstReaction(moderator, this.mjolnir.managementRoomId, '✅', async () => {
return await moderator.sendMessage(this.mjolnir.managementRoomId, {msgtype: 'm.text', body: `!mjolnir unban unban-test server ${server}`});
await getFirstReaction(moderator, mjolnir.managementRoomId, '✅', async () => {
return await moderator.sendMessage(mjolnir.managementRoomId, { msgtype: 'm.text', body: `!mjolnir unban unban-test server ${server}` });
});
}
} finally {
@ -356,47 +358,47 @@ describe('Test: unbaning entities via the BanList.', function () {
}
// Wait for mjolnir to sync protected rooms to update ACL.
await this.mjolnir!.syncLists();
await mjolnir.syncLists();
// Confirm that the server is unbanned.
await banList.updateList();
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');
})
})
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 () {
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() {
this.timeout(180000)
const mjolnir = config.RUNTIME.client!
const serverName: string = new UserID(await mjolnir.getUserId()).domain
const moderator = await newTestUser({ name: { contains: "moderator" }});
moderator.joinRoom(this.mjolnir.managementRoomId);
const mjolnirId = await mjolnir.getUserId();
const mjolnir: Mjolnir = this.mjolnir!
const serverName: string = new UserID(await mjolnir.client.getUserId()).domain
const moderator = await newTestUser(this.config.homeserverUrl, { name: { contains: "moderator" } });
moderator.joinRoom(mjolnir.managementRoomId);
const mjolnirId = await mjolnir.client.getUserId();
// Setup some protected rooms so we can check their ACL state later.
const protectedRooms: string[] = [];
for (let i = 0; i < 10; i++) {
const room = await moderator.createRoom({ invite: [mjolnirId]});
await mjolnir.joinRoom(room);
const room = await moderator.createRoom({ invite: [mjolnirId] });
await mjolnir.client.joinRoom(room);
await moderator.setUserPowerLevel(mjolnirId, room, 100);
await this.mjolnir!.addProtectedRoom(room);
await mjolnir.addProtectedRoom(room);
protectedRooms.push(room);
}
// 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 => {
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.');
}));
// 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] });
mjolnir.joinRoom(banListId);
this.mjolnir!.watchList(Permalinks.forRoom(banListId));
mjolnir.client.joinRoom(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.
for (let i = protectedRooms.length - 1; i > 0; i--) {
@ -405,13 +407,13 @@ describe('Test: should apply bans to the most recently active rooms first', func
}
// create some activity in the same order.
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));
}
// check the rooms are in the expected order
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`;
@ -420,18 +422,18 @@ describe('Test: should apply bans to the most recently active rooms first', func
await createPolicyRule(moderator, banListId, RULE_SERVER, badServer, `Rule ${badServer}`);
// Wait until all the ACL events have been applied.
await this.mjolnir!.syncLists();
await mjolnir.syncLists();
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.
let last_event_ts = 0;
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.
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;
if (!acl.matches(roomAcl)) {
assert.fail(`Room ${roomId} doesn't have the correct ACL: ${JSON.stringify(roomAcl, null, 2)}`)

View File

@ -1,6 +1,5 @@
import { HmacSHA1 } from "crypto-js";
import { getRequestFn, LogService, MatrixClient, MemoryStorageProvider, PantalaimonClient } from "matrix-bot-sdk";
import config from "../../src/config";
const REGISTRATION_ATTEMPTS = 10;
const REGISTRATION_RETRY_BASE_DELAY_MS = 100;
@ -16,8 +15,8 @@ const REGISTRATION_RETRY_BASE_DELAY_MS = 100;
* @param admin True to make the user an admin, false otherwise.
* @returns The response from synapse.
*/
export async function registerUser(username: string, displayname: string, password: string, admin: boolean): Promise<void> {
let registerUrl = `${config.homeserverUrl}/_synapse/admin/v1/register`
export async function registerUser(homeserver: string, username: string, displayname: string, password: string, admin: boolean): Promise<void> {
let registerUrl = `${homeserver}/_synapse/admin/v1/register`
const data: {nonce: string} = await new Promise((resolve, reject) => {
getRequestFn()({uri: registerUrl, method: "GET", timeout: 60000}, (error: any, response: any, resBody: any) => {
error ? reject(error) : resolve(JSON.parse(resBody))
@ -81,7 +80,7 @@ export type RegistrationOptions = {
*
* @returns A string that is both the username and password of a new user.
*/
async function registerNewTestUser(options: RegistrationOptions) {
async function registerNewTestUser(homeserver: string, options: RegistrationOptions) {
do {
let username;
if ("exact" in options.name) {
@ -90,7 +89,7 @@ async function registerNewTestUser(options: RegistrationOptions) {
username = `mjolnir-test-user-${options.name.contains}${Math.floor(Math.random() * 100000)}`
}
try {
await registerUser(username, username, username, Boolean(options.isAdmin));
await registerUser(homeserver, username, username, username, Boolean(options.isAdmin));
return username;
} catch (e) {
if (e?.body?.errcode === 'M_USER_IN_USE') {
@ -113,13 +112,13 @@ async function registerNewTestUser(options: RegistrationOptions) {
*
* @returns A new `MatrixClient` session for a unique test user.
*/
export async function newTestUser(options: RegistrationOptions): Promise<MatrixClient> {
const username = await registerNewTestUser(options);
const pantalaimon = new PantalaimonClient(config.homeserverUrl, new MemoryStorageProvider());
export async function newTestUser(homeserver: string, options: RegistrationOptions): Promise<MatrixClient> {
const username = await registerNewTestUser(homeserver, options);
const pantalaimon = new PantalaimonClient(homeserver, new MemoryStorageProvider());
const client = await pantalaimon.createClientWithCredentials(username, username);
if (!options.isThrottled) {
let userId = await client.getUserId();
await overrideRatelimitForUser(userId);
await overrideRatelimitForUser(homeserver, userId);
}
return client;
}
@ -130,12 +129,12 @@ let _globalAdminUser: MatrixClient;
* Get a client that can perform synapse admin API actions.
* @returns A client logged in with an admin user.
*/
async function getGlobalAdminUser(): Promise<MatrixClient> {
async function getGlobalAdminUser(homeserver: string): Promise<MatrixClient> {
// Initialize global admin user if needed.
if (!_globalAdminUser) {
const USERNAME = "mjolnir-test-internal-admin-user";
try {
await registerUser(USERNAME, USERNAME, USERNAME, true);
await registerUser(homeserver, USERNAME, USERNAME, USERNAME, true);
} catch (e) {
if (e.isAxiosError && e?.response?.data?.errcode === 'M_USER_IN_USE') {
// Then we've already registered the user in a previous run and that is ok.
@ -143,7 +142,7 @@ async function getGlobalAdminUser(): Promise<MatrixClient> {
throw e;
}
}
_globalAdminUser = await new PantalaimonClient(config.homeserverUrl, new MemoryStorageProvider()).createClientWithCredentials(USERNAME, USERNAME);
_globalAdminUser = await new PantalaimonClient(homeserver, new MemoryStorageProvider()).createClientWithCredentials(USERNAME, USERNAME);
}
return _globalAdminUser;
}
@ -152,8 +151,8 @@ async function getGlobalAdminUser(): Promise<MatrixClient> {
* Disable ratelimiting for this user in Synapse.
* @param userId The user to disable ratelimiting for, has to include both the server part and local part.
*/
export async function overrideRatelimitForUser(userId: string) {
await (await getGlobalAdminUser()).doRequest("POST", `/_synapse/admin/v1/users/${userId}/override_ratelimit`, null, {
export async function overrideRatelimitForUser(homeserver: string, userId: string) {
await (await getGlobalAdminUser(homeserver)).doRequest("POST", `/_synapse/admin/v1/users/${userId}/override_ratelimit`, null, {
"messages_per_second": 0,
"burst_count": 0
});
@ -163,8 +162,8 @@ export async function overrideRatelimitForUser(userId: string) {
* Put back the default ratelimiting for this user in Synapse.
* @param userId The user to use default ratelimiting for, has to include both the server part and local part.
*/
export async function resetRatelimitForUser(userId: string) {
await (await getGlobalAdminUser()).doRequest("DELETE", `/_synapse/admin/v1/users/${userId}/override_ratelimit`, null);
export async function resetRatelimitForUser(homeserver: string, userId: string) {
await (await getGlobalAdminUser(homeserver)).doRequest("DELETE", `/_synapse/admin/v1/users/${userId}/override_ratelimit`, null);
}

View File

@ -1,6 +1,5 @@
import { strict as assert } from "assert";
import config from "../../../src/config";
import { newTestUser } from "../clientHelper";
import { PowerLevelAction } from "matrix-bot-sdk/lib/models/PowerLevelAction";
import { LogService } from "matrix-bot-sdk";
@ -16,20 +15,20 @@ describe("Test: The make admin command", function () {
it('Mjölnir make the bot self room administrator', async function () {
this.timeout(90000);
if (!config.admin?.enableMakeRoomAdminCommand) {
if (!this.config.admin?.enableMakeRoomAdminCommand) {
done();
}
const mjolnir = config.RUNTIME.client!;
const mjolnir = this.config.RUNTIME.client!;
const mjolnirUserId = await mjolnir.getUserId();
const moderator = await newTestUser({ name: { contains: "moderator" } });
const userA = await newTestUser({ name: { contains: "a" } });
const moderator = await newTestUser(this.config.homeserverUrl, { name: { contains: "moderator" } });
const userA = await newTestUser(this.config.homeserverUrl, { name: { contains: "a" } });
const userAId = await userA.getUserId();
this.moderator = moderator;
this.userA = userA;
let powerLevels: any;
await moderator.joinRoom(config.managementRoom);
LogService.debug("makeadminTest", `Joining managementRoom: ${config.managementRoom}`);
await moderator.joinRoom(this.config.managementRoom);
LogService.debug("makeadminTest", `Joining managementRoom: ${this.config.managementRoom}`);
let targetRoom = await moderator.createRoom({ invite: [mjolnirUserId], preset: "public_chat" });
LogService.debug("makeadminTest", `moderator creating targetRoom: ${targetRoom}; and inviting ${mjolnirUserId}`);
await moderator.sendMessage(this.mjolnir.managementRoomId, { msgtype: 'm.text.', body: `!mjolnir rooms add ${targetRoom}` });
@ -57,14 +56,14 @@ describe("Test: The make admin command", function () {
it('Mjölnir make the tester room administrator', async function () {
this.timeout(90000);
if (!config.admin?.enableMakeRoomAdminCommand) {
if (!this.config.admin?.enableMakeRoomAdminCommand) {
done();
}
const mjolnir = config.RUNTIME.client!;
const moderator = await newTestUser({ name: { contains: "moderator" } });
const userA = await newTestUser({ name: { contains: "a" } });
const userB = await newTestUser({ name: { contains: "b" } });
const userC = await newTestUser({ name: { contains: "c" } });
const mjolnir = this.config.RUNTIME.client!;
const moderator = await newTestUser(this.config.homeserverUrl, { name: { contains: "moderator" } });
const userA = await newTestUser(this.config.homeserverUrl, { name: { contains: "a" } });
const userB = await newTestUser(this.config.homeserverUrl, { name: { contains: "b" } });
const userC = await newTestUser(this.config.homeserverUrl, { name: { contains: "c" } });
const userBId = await userB.getUserId();
const userCId = await userC.getUserId();
this.moderator = moderator;

View File

@ -1,6 +1,5 @@
import { strict as assert } from "assert";
import config from "../../../src/config";
import { newTestUser } from "../clientHelper";
import { getMessagesByUserIn } from "../../../src/utils";
import { LogService } from "matrix-bot-sdk";
@ -13,19 +12,19 @@ import { getFirstReaction } from "./commandUtils";
it('Mjölnir redacts all of the events sent by a spammer when instructed to by giving their id and a room id.', async function() {
this.timeout(60000);
// Create a few users and a room.
let badUser = await newTestUser({ name: { contains: "spammer-needs-redacting" } });
let badUser = await newTestUser(this.config.homeserverUrl, { name: { contains: "spammer-needs-redacting" } });
let badUserId = await badUser.getUserId();
const mjolnir = config.RUNTIME.client!
const mjolnir = this.config.RUNTIME.client!
let mjolnirUserId = await mjolnir.getUserId();
let moderator = await newTestUser({ name: { contains: "moderator" } });
let moderator = await newTestUser(this.config.homeserverUrl, { name: { contains: "moderator" } });
this.moderator = moderator;
await moderator.joinRoom(config.managementRoom);
await moderator.joinRoom(this.config.managementRoom);
let targetRoom = await moderator.createRoom({ invite: [await badUser.getUserId(), mjolnirUserId]});
await moderator.setUserPowerLevel(mjolnirUserId, targetRoom, 100);
await badUser.joinRoom(targetRoom);
moderator.sendMessage(this.mjolnir.managementRoomId, {msgtype: 'm.text.', body: `!mjolnir rooms add ${targetRoom}`});
LogService.debug("redactionTest", `targetRoom: ${targetRoom}, managementRoom: ${config.managementRoom}`);
LogService.debug("redactionTest", `targetRoom: ${targetRoom}, managementRoom: ${this.config.managementRoom}`);
// Sandwich irrelevant messages in bad messages.
await badUser.sendMessage(targetRoom, {msgtype: 'm.text', body: "Very Bad Stuff"});
await Promise.all([...Array(50).keys()].map((i) => moderator.sendMessage(targetRoom, {msgtype: 'm.text.', body: `Irrelevant Message #${i}`})));
@ -58,13 +57,13 @@ import { getFirstReaction } from "./commandUtils";
it('Mjölnir redacts all of the events sent by a spammer when instructed to by giving their id in multiple rooms.', async function() {
this.timeout(60000);
// Create a few users and a room.
let badUser = await newTestUser({ name: { contains: "spammer-needs-redacting" } });
let badUser = await newTestUser(this.config.homeserverUrl, { name: { contains: "spammer-needs-redacting" } });
let badUserId = await badUser.getUserId();
const mjolnir = config.RUNTIME.client!
const mjolnir = this.config.RUNTIME.client!
let mjolnirUserId = await mjolnir.getUserId();
let moderator = await newTestUser({ name: { contains: "moderator" } });
let moderator = await newTestUser(this.config.homeserverUrl, { name: { contains: "moderator" } });
this.moderator = moderator;
await moderator.joinRoom(config.managementRoom);
await moderator.joinRoom(this.config.managementRoom);
let targetRooms: string[] = [];
for (let i = 0; i < 5; i++) {
let targetRoom = await moderator.createRoom({ invite: [await badUser.getUserId(), mjolnirUserId]});
@ -107,12 +106,12 @@ import { getFirstReaction } from "./commandUtils";
it("Redacts a single event when instructed to.", async function () {
this.timeout(60000);
// Create a few users and a room.
let badUser = await newTestUser({ name: { contains: "spammer-needs-redacting" } });
const mjolnir = config.RUNTIME.client!
let badUser = await newTestUser(this.config.homeserverUrl, { name: { contains: "spammer-needs-redacting" } });
const mjolnir = this.config.RUNTIME.client!
let mjolnirUserId = await mjolnir.getUserId();
let moderator = await newTestUser({ name: { contains: "moderator" } });
let moderator = await newTestUser(this.config.homeserverUrl, { name: { contains: "moderator" } });
this.moderator = moderator;
await moderator.joinRoom(config.managementRoom);
await moderator.joinRoom(this.config.managementRoom);
let targetRoom = await moderator.createRoom({ invite: [await badUser.getUserId(), mjolnirUserId]});
await moderator.setUserPowerLevel(mjolnirUserId, targetRoom, 100);
await badUser.joinRoom(targetRoom);

View File

@ -5,7 +5,7 @@ import { newTestUser } from "../clientHelper";
describe("Test: shutdown command", function() {
let client;
this.beforeEach(async function () {
client = await newTestUser({ name: { contains: "shutdown-command" }});
client = await newTestUser(this.config.homeserverUrl, { name: { contains: "shutdown-command" }});
await client.start();
})
this.afterEach(async function () {

View File

@ -28,7 +28,7 @@ describe("Test: DetectFederationLag protection", function() {
await this.mjolnir.enableProtection("DetectFederationLag");
// Setup a moderator.
this.moderator = await newTestUser({ name: { contains: "moderator" } });
this.moderator = await newTestUser(this.config.homeserverUrl, { name: { contains: "moderator" } });
await this.moderator.joinRoom(this.mjolnir.managementRoomId);
const SETTINGS = {

View File

@ -1,4 +1,4 @@
import config from "../../src/config";
import { read as configRead } from "../../src/config";
import { makeMjolnir, teardownManagementRoom } from "./mjolnirSetupUtils";
// When Mjolnir starts (src/index.ts) it clobbers the config by resolving the management room
@ -12,9 +12,10 @@ export const mochaHooks = {
console.error("---- entering test", JSON.stringify(this.currentTest.title)); // Makes MatrixClient error logs a bit easier to parse.
console.log("mochaHooks.beforeEach");
// Sometimes it takes a little longer to register users.
this.timeout(10000)
this.timeout(10000);
const config = this.config = configRead();
this.managementRoomAlias = config.managementRoom;
this.mjolnir = await makeMjolnir();
this.mjolnir = await makeMjolnir(config);
config.RUNTIME.client = this.mjolnir.client;
await Promise.all([
this.mjolnir.client.setAccountData('org.matrix.mjolnir.protected_rooms', { rooms: [] }),
@ -33,7 +34,7 @@ export const mochaHooks = {
this.mjolnir.client.setAccountData('org.matrix.mjolnir.watched_lists', { references: [] }),
]);
// remove alias from management room and leave it.
await teardownManagementRoom(this.mjolnir.client, this.mjolnir.managementRoomId, config.managementRoom);
await teardownManagementRoom(this.mjolnir.client, this.mjolnir.managementRoomId, this.managementRoomAlias);
console.error("---- completed test", JSON.stringify(this.currentTest.title), "\n\n"); // Makes MatrixClient error logs a bit easier to parse.
}
]

View File

@ -4,7 +4,7 @@ import { newTestUser, noticeListener } from "./clientHelper"
describe("Test: !help command", function() {
let client;
this.beforeEach(async function () {
client = await newTestUser({ name: { contains: "-" }});;
client = await newTestUser(this.config.homeserverUrl, { name: { contains: "-" }});;
await client.start();
})
this.afterEach(async function () {
@ -13,7 +13,7 @@ describe("Test: !help command", function() {
it('Mjolnir responded to !mjolnir help', async function() {
this.timeout(30000);
// send a messgage
await client.joinRoom(config.managementRoom);
await client.joinRoom(this.config.managementRoom);
// listener for getting the event reply
let reply = new Promise((resolve, reject) => {
client.on('room.message', noticeListener(this.mjolnir.managementRoomId, (event) => {

View File

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

View File

@ -22,9 +22,9 @@ import {
RichConsoleLogger
} from "matrix-bot-sdk";
import { Mjolnir} from '../../src/Mjolnir';
import config from "../../src/config";
import { overrideRatelimitForUser, registerUser } from "./clientHelper";
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.
@ -48,9 +48,9 @@ export async function ensureAliasedRoomExists(client: MatrixClient, alias: strin
}
}
async function configureMjolnir() {
async function configureMjolnir(config: IConfig) {
try {
await registerUser(config.pantalaimon.username, config.pantalaimon.username, config.pantalaimon.password, true)
await registerUser(config.homeserverUrl, config.pantalaimon.username, config.pantalaimon.username, config.pantalaimon.password, true)
} catch (e) {
if (e?.body?.errcode === 'M_USER_IN_USE') {
console.log(`${config.pantalaimon.username} already registered, skipping`);
@ -72,17 +72,17 @@ let globalMjolnir: Mjolnir | null;
/**
* Return a test instance of Mjolnir.
*/
export async function makeMjolnir(): Promise<Mjolnir> {
await configureMjolnir();
export async function makeMjolnir(config: IConfig): Promise<Mjolnir> {
await configureMjolnir(config);
LogService.setLogger(new RichConsoleLogger());
LogService.setLevel(LogLevel.fromString(config.logLevel, LogLevel.DEBUG));
LogService.info("test/mjolnirSetupUtils", "Starting bot...");
const pantalaimon = new PantalaimonClient(config.homeserverUrl, new MemoryStorageProvider());
const client = await pantalaimon.createClientWithCredentials(config.pantalaimon.username, config.pantalaimon.password);
await overrideRatelimitForUser(await client.getUserId());
await overrideRatelimitForUser(config.homeserverUrl, await client.getUserId());
patchMatrixClient();
await ensureAliasedRoomExists(client, config.managementRoom);
let mj = await Mjolnir.setupMjolnirFromConfig(client);
let mj = await Mjolnir.setupMjolnirFromConfig(client, config);
globalClient = client;
globalMjolnir = mj;
return mj;

View File

@ -1,6 +1,7 @@
import { strict as assert } from "assert";
import { newTestUser } from "./clientHelper";
import { Mjolnir } from "../../src/Mjolnir";
import config from "../../src/config";
import { getRequestFn, LogService, MatrixClient } from "matrix-bot-sdk";
import { createBanList, getFirstReaction } from "./commands/commandUtils";
@ -8,9 +9,9 @@ import { createBanList, getFirstReaction } from "./commands/commandUtils";
/**
* Get a copy of the rules from the ruleserver.
*/
async function currentRules(): Promise<{ start: object, stop: object, since: string }> {
async function currentRules(mjolnir: Mjolnir): Promise<{ start: object, stop: object, since: string }> {
return await new Promise((resolve, reject) => getRequestFn()({
uri: `http://${config.web.address}:${config.web.port}/api/1/ruleserver/updates/`,
uri: `http://${mjolnir.config.web.address}:${mjolnir.config.web.port}/api/1/ruleserver/updates/`,
method: "GET"
}, (error, response, body) => {
if (error) {
@ -25,8 +26,8 @@ async function currentRules(): Promise<{ start: object, stop: object, since: str
* Wait for the rules to change as a result of the thunk. The returned promise will resolve when the rules being served have changed.
* @param thunk Should cause the rules the RuleServer is serving to change some way.
*/
async function waitForRuleChange(thunk): Promise<void> {
const initialRules = await currentRules();
async function waitForRuleChange(mjolnir: Mjolnir, thunk): Promise<void> {
const initialRules = await currentRules(mjolnir);
let rules = initialRules;
// We use JSON.stringify like this so that it is pretty printed in the log and human readable.
LogService.debug('policyConsumptionTest', `Rules before we wait for them to change: ${JSON.stringify(rules, null, 2)}`);
@ -35,7 +36,7 @@ async function waitForRuleChange(thunk): Promise<void> {
await new Promise<void>(resolve => {
setTimeout(resolve, 500);
})
rules = await currentRules();
rules = await currentRules(mjolnir);
};
// The problem is, we have no idea how long a consumer will take to process the changed rules.
// We know the pull peroid is 1 second though.
@ -47,9 +48,9 @@ async function waitForRuleChange(thunk): Promise<void> {
describe("Test: that policy lists are consumed by the associated synapse module", function () {
this.afterEach(async function () {
if(config.web.ruleServer.enabled) {
if(this.config.web.ruleServer.enabled) {
this.timeout(5000)
LogService.debug('policyConsumptionTest', `Rules at end of test ${JSON.stringify(await currentRules(), null, 2)}`);
LogService.debug('policyConsumptionTest', `Rules at end of test ${JSON.stringify(await currentRules(this.mjolnir), null, 2)}`);
const mjolnir = config.RUNTIME.client!;
// Clear any state associated with the account.
await mjolnir.setAccountData('org.matrix.mjolnir.watched_lists', {
@ -58,23 +59,23 @@ describe("Test: that policy lists are consumed by the associated synapse module"
}
})
this.beforeAll(async function() {
if (!config.web.ruleServer.enabled) {
if (!this.config.web.ruleServer.enabled) {
LogService.warn("policyConsumptionTest", "Skipping policy consumption test because the ruleServer is not enabled")
this.skip();
}
})
this.beforeEach(async function () {
this.timeout(1000);
const mjolnir = config.RUNTIME.client!;
const mjolnir = this.config.RUNTIME.client!;
})
it('blocks users in antispam when they are banned from sending messages and invites serverwide.', async function() {
this.timeout(20000);
// Create a few users and a room.
let badUser = await newTestUser({ name: { contains: "spammer" }});
let badUser = await newTestUser(this.config.homeserverUrl, { name: { contains: "spammer" }});
let badUserId = await badUser.getUserId();
const mjolnir = config.RUNTIME.client!
let mjolnirUserId = await mjolnir.getUserId();
let moderator = await newTestUser({ name: { contains: "moderator" }});
let moderator = await newTestUser(this.config.homeserverUrl, { name: { contains: "moderator" }});
this.moderator = moderator;
await moderator.joinRoom(this.mjolnir.managementRoomId);
let unprotectedRoom = await badUser.createRoom({ invite: [await moderator.getUserId()]});
@ -84,7 +85,7 @@ describe("Test: that policy lists are consumed by the associated synapse module"
const banList = await createBanList(this.mjolnir.managementRoomId, mjolnir, moderator);
await badUser.sendMessage(unprotectedRoom, {msgtype: 'm.text', body: 'Something bad and mean'});
await waitForRuleChange(async () => {
await waitForRuleChange(this.config.web.address, this.mjolnir.config.web.port, async () => {
await getFirstReaction(mjolnir, this.mjolnir.managementRoomId, '✅', async () => {
return await moderator.sendMessage(this.mjolnir.managementRoomId, { msgtype: 'm.text', body: `!mjolnir ban ${banList} ${badUserId}` });
});
@ -95,7 +96,7 @@ describe("Test: that policy lists are consumed by the associated synapse module"
assert.ok(await moderator.sendMessage(unprotectedRoom, { msgtype: 'm.text', body: 'test'}), 'They should be able to send messages still too.');
// Test we can remove the rules.
await waitForRuleChange(async () => {
await waitForRuleChange(this.config.web.address, this.mjolnir.config.web.port, async () => {
await getFirstReaction(mjolnir, this.mjolnir.managementRoomId, '✅', async () => {
return await moderator.sendMessage(this.mjolnir.managementRoomId, { msgtype: 'm.text', body: `!mjolnir unban ${banList} ${badUserId}` });
});
@ -105,15 +106,15 @@ describe("Test: that policy lists are consumed by the associated synapse module"
})
it('Test: Cannot send message to a room that is listed in a policy list and cannot invite a user to the room either', async function () {
this.timeout(20000);
let badUser = await newTestUser({ name: { contains: "spammer" }});
let badUser = await newTestUser(this.config.homeserverUrl, { name: { contains: "spammer" }});
const mjolnir = config.RUNTIME.client!
let moderator = await newTestUser({ name: { contains: "moderator" }});
let moderator = await newTestUser(this.config.homeserverUrl, { name: { contains: "moderator" }});
await moderator.joinRoom(this.mjolnir.managementRoomId);
const banList = await createBanList(this.mjolnir.managementRoomId, mjolnir, moderator);
let badRoom = await badUser.createRoom();
let unrelatedRoom = await badUser.createRoom();
await badUser.sendMessage(badRoom, {msgtype: 'm.text', body: "Very Bad Stuff in this room"});
await waitForRuleChange(async () => {
await waitForRuleChange(this.config.web.address, this.mjolnir.config.web.port, async () => {
await getFirstReaction(mjolnir, this.mjolnir.managementRoomId, '✅', async () => {
return await moderator.sendMessage(this.mjolnir.managementRoomId, { msgtype: 'm.text', body: `!mjolnir ban ${banList} ${badRoom}` });
});
@ -123,7 +124,7 @@ describe("Test: that policy lists are consumed by the associated synapse module"
assert.ok(await badUser.sendMessage(unrelatedRoom, { msgtype: 'm.text.', body: 'hey'}), 'should be able to send messages to unrelated room');
assert.ok(await badUser.inviteUser(await moderator.getUserId(), unrelatedRoom), 'They should still be able to invite to other rooms though');
// Test we can remove these rules.
await waitForRuleChange(async () => {
await waitForRuleChange(this.config.web.address, this.mjolnir.config.web.port, async () => {
await getFirstReaction(mjolnir, this.mjolnir.managementRoomId, '✅', async () => {
return await moderator.sendMessage(this.mjolnir.managementRoomId, { msgtype: 'm.text', body: `!mjolnir unban ${banList} ${badRoom}` });
});
@ -135,19 +136,19 @@ describe("Test: that policy lists are consumed by the associated synapse module"
it('Test: When a list becomes unwatched, the associated policies are stopped.', async function () {
this.timeout(20000);
const mjolnir = config.RUNTIME.client!
let moderator = await newTestUser({ name: { contains: "moderator" }});
let moderator = await newTestUser(this.config.homeserverUrl, { name: { contains: "moderator" }});
await moderator.joinRoom(this.mjolnir.managementRoomId);
const banList = await createBanList(this.mjolnir.managementRoomId, mjolnir, moderator);
let targetRoom = await moderator.createRoom();
await moderator.sendMessage(targetRoom, {msgtype: 'm.text', body: "Fluffy Foxes."});
await waitForRuleChange(async () => {
await waitForRuleChange(this.config.web.address, this.mjolnir.config.web.port, async () => {
await getFirstReaction(mjolnir, this.mjolnir.managementRoomId, '✅', async () => {
return await moderator.sendMessage(this.mjolnir.managementRoomId, { msgtype: 'm.text', body: `!mjolnir ban ${banList} ${targetRoom}` });
});
});
await assert.rejects(moderator.sendMessage(targetRoom, { msgtype: 'm.text', body: 'test'}), 'should not be able to send messages to a room which is listed.');
await waitForRuleChange(async () => {
await waitForRuleChange(this.config.web.address, this.mjolnir.config.web.port, async () => {
await getFirstReaction(mjolnir, this.mjolnir.managementRoomId, '✅', async () => {
return await moderator.sendMessage(this.mjolnir.managementRoomId, { msgtype: 'm.text', body: `!mjolnir unwatch #${banList}:localhost:9999` });
});

View File

@ -1,6 +1,5 @@
import { strict as assert } from "assert";
import config from "../../src/config";
import { Mjolnir } from "../../src/Mjolnir";
import { IProtection } from "../../src/protections/IProtection";
import { PROTECTIONS } from "../../src/protections/protections";
@ -12,7 +11,7 @@ import { matrixClient, mjolnir } from "./mjolnirSetupUtils";
describe("Test: Protection settings", function() {
let client;
this.beforeEach(async function () {
client = await newTestUser({ name: { contains: "protection-settings" }});
client = await newTestUser(this.config.homeserverUrl, { name: { contains: "protection-settings" }});
await client.start();
})
this.afterEach(async function () {
@ -59,7 +58,7 @@ describe("Test: Protection settings", function() {
});
it("Mjolnir responds to !set correctly", async function() {
this.timeout(20000);
await client.joinRoom(config.managementRoom);
await client.joinRoom(this.config.managementRoom);
await this.mjolnir.registerProtection(new class implements IProtection {
name = "JY2TPN";
@ -84,7 +83,7 @@ describe("Test: Protection settings", function() {
});
it("Mjolnir adds a value to a list setting", async function() {
this.timeout(20000);
await client.joinRoom(config.managementRoom);
await client.joinRoom(this.config.managementRoom);
await this.mjolnir.registerProtection(new class implements IProtection {
name = "r33XyT";
@ -108,7 +107,7 @@ describe("Test: Protection settings", function() {
});
it("Mjolnir removes a value from a list setting", async function() {
this.timeout(20000);
await client.joinRoom(config.managementRoom);
await client.joinRoom(this.config.managementRoom);
await this.mjolnir.registerProtection(new class implements IProtection {
name = "oXzT0E";
@ -133,7 +132,7 @@ describe("Test: Protection settings", function() {
});
it("Mjolnir will change a protection setting in-place", async function() {
this.timeout(20000);
await client.joinRoom(config.managementRoom);
await client.joinRoom(this.config.managementRoom);
await this.mjolnir.registerProtection(new class implements IProtection {
name = "d0sNrt";

View File

@ -5,7 +5,7 @@ import { newTestUser } from "./clientHelper";
describe("Test: Report polling", function() {
let client;
this.beforeEach(async function () {
client = await newTestUser({ name: { contains: "protection-settings" }});
client = await newTestUser(this.config.homeserverUrl, { name: { contains: "protection-settings" }});
})
it("Mjolnir correctly retrieves a report from synapse", async function() {
this.timeout(40000);

View File

@ -257,7 +257,7 @@ describe("Test: Testing RoomMemberManager", function() {
const start = new Date(Date.now() - 10_000);
// Setup a moderator.
this.moderator = await newTestUser({ name: { contains: "moderator" } });
this.moderator = await newTestUser(this.config.homeserverUrl, { name: { contains: "moderator" } });
await this.mjolnir.client.inviteUser(await this.moderator.getUserId(), this.mjolnir.managementRoomId)
await this.moderator.joinRoom(this.mjolnir.managementRoomId);
@ -265,7 +265,7 @@ describe("Test: Testing RoomMemberManager", function() {
this.users = [];
const SAMPLE_SIZE = 10;
for (let i = 0; i < SAMPLE_SIZE; ++i) {
this.users.push(await newTestUser({ name: { contains: `user_${i}_room_member_test` } }));
this.users.push(await newTestUser(this.config.homeserverUrl, { name: { contains: `user_${i}_room_member_test` } }));
}
const userIds = [];
for (let client of this.users) {
@ -380,7 +380,7 @@ describe("Test: Testing RoomMemberManager", function() {
const start = new Date(Date.now() - 10_000);
// Setup a moderator.
this.moderator = await newTestUser({ name: { contains: "moderator" } });
this.moderator = await newTestUser(this.config.homeserverUrl, { name: { contains: "moderator" } });
await this.moderator.joinRoom(this.mjolnir.managementRoomId);
// Create a few users.
@ -388,8 +388,8 @@ describe("Test: Testing RoomMemberManager", function() {
this.badUsers = [];
const SAMPLE_SIZE = 10;
for (let i = 0; i < SAMPLE_SIZE; ++i) {
this.goodUsers.push(await newTestUser({ name: { contains: `good_user_${i}_room_member_test` } }));
this.badUsers.push(await newTestUser({ name: { contains: `bad_user_${i}_room_member_test` } }));
this.goodUsers.push(await newTestUser(this.config.homeserverUrl, { name: { contains: `good_user_${i}_room_member_test` } }));
this.badUsers.push(await newTestUser(this.config.homeserverUrl, { name: { contains: `bad_user_${i}_room_member_test` } }));
}
const goodUserIds: string[] = [];
const badUserIds: string[] = [];

View File

@ -1,6 +1,5 @@
import { strict as assert } from "assert";
import config from "../../src/config";
import { Mjolnir } from "../../src/Mjolnir";
import { IProtection } from "../../src/protections/IProtection";
import { newTestUser, noticeListener } from "./clientHelper";
@ -11,8 +10,8 @@ describe("Test: standard consequences", function() {
let badUser;
let goodUser;
this.beforeEach(async function () {
badUser = await newTestUser({ name: { contains: "standard-consequences" }});
goodUser = await newTestUser({ name: { contains: "standard-consequences" }});
badUser = await newTestUser(this.config.homeserverUrl, { name: { contains: "standard-consequences" }});
goodUser = await newTestUser(this.config.homeserverUrl, { name: { contains: "standard-consequences" }});
await badUser.start();
await goodUser.start();
})

View File

@ -1,11 +1,10 @@
import { strict as assert } from "assert";
import { newTestUser, overrideRatelimitForUser, resetRatelimitForUser } from "./clientHelper";
import { newTestUser } from "./clientHelper";
import { getMessagesByUserIn } from "../../src/utils";
import { getFirstReaction } from "./commands/commandUtils";
describe("Test: throttled users can function with Mjolnir.", function () {
it('throttled users survive being throttled by synapse', async function() {
let throttledUser = await newTestUser({ name: { contains: "throttled" }, isThrottled: true });
let throttledUser = await newTestUser(this.config.homeserverUrl, { name: { contains: "throttled" }, isThrottled: true });
let throttledUserId = await throttledUser.getUserId();
let targetRoom = await throttledUser.createRoom();
// send enough messages to hit the rate limit.
@ -18,58 +17,17 @@ describe("Test: throttled users can function with Mjolnir.", function () {
})
})
describe("Test: Mjolnir can still sync and respond to commands while throttled", function () {
beforeEach(async function() {
await resetRatelimitForUser(await this.mjolnir.client.getUserId())
})
afterEach(async function() {
// If a test has a timeout while awaitng on a promise then we never get given control back.
this.moderator?.stop();
await overrideRatelimitForUser(await this.mjolnir.client.getUserId());
})
it('Can still perform and respond to a redaction command', async function () {
// Create a few users and a room.
let badUser = await newTestUser({ name: { contains: "spammer-needs-redacting" } });
let badUserId = await badUser.getUserId();
const mjolnir = this.mjolnir.client;
let mjolnirUserId = await mjolnir.getUserId();
let moderator = await newTestUser({ name: { contains: "moderator" } });
this.moderator = moderator;
await moderator.joinRoom(this.mjolnir.managementRoomId);
let targetRoom = await moderator.createRoom({ invite: [await badUser.getUserId(), mjolnirUserId]});
await moderator.setUserPowerLevel(mjolnirUserId, targetRoom, 100);
await badUser.joinRoom(targetRoom);
// Give Mjolnir some work to do and some messages to sync through.
await Promise.all([...Array(25).keys()].map((i) => moderator.sendMessage(this.mjolnir.managementRoomId, {msgtype: 'm.text.', body: `Irrelevant Message #${i}`})));
await Promise.all([...Array(25).keys()].map(_ => moderator.sendMessage(this.mjolnir.managementRoomId, {msgtype: 'm.text', body: '!mjolnir status'})));
await moderator.sendMessage(this.mjolnir.managementRoomId, {msgtype: 'm.text', body: `!mjolnir rooms add ${targetRoom}`});
await Promise.all([...Array(25).keys()].map((i) => badUser.sendMessage(targetRoom, {msgtype: 'm.text.', body: `Bad Message #${i}`})));
try {
await moderator.start();
await getFirstReaction(moderator, this.mjolnir.managementRoomId, '✅', async () => {
return await moderator.sendMessage(this.mjolnir.managementRoomId, { msgtype: 'm.text', body: `!mjolnir redact ${badUserId} ${targetRoom}` });
});
} finally {
moderator.stop();
}
let count = 0;
await getMessagesByUserIn(moderator, badUserId, targetRoom, 1000, function(events) {
count += events.length
events.map(e => {
if (e.type === 'm.room.member') {
assert.equal(Object.keys(e.content).length, 1, "Only membership should be left on the membership event when it has been redacted.")
} else if (Object.keys(e.content).length !== 0) {
throw new Error(`This event should have been redacted: ${JSON.stringify(e, null, 2)}`)
}
})
});
assert.equal(count, 26, "There should be exactly 26 events from the spammer in this room.");
})
})
/**
* We used to have a test here that tested whether Mjolnir was going to carry out a redact order the default limits in a reasonable time scale.
* Now I think that's never going to happen without writing a new algorithm for respecting rate limiting.
* Which is not something there is time for.
*
* https://github.com/matrix-org/synapse/pull/13018
*
* Synapse rate limits were broken and very permitting so that's why the current hack worked so well.
* Now it is not broken, so our rate limit handling is.
*
* https://github.com/matrix-org/mjolnir/commit/b850e4554c6cbc9456e23ab1a92ede547d044241
*
* Honestly I don't think we can expect anyone to be able to use Mjolnir under default rate limits.
*/

View File

@ -10,9 +10,9 @@ describe("Test: timeline pagination", function () {
it('does not paginate across the entire room history while backfilling.', async function() {
this.timeout(60000);
// Create a few users and a room.
let badUser = await newTestUser({ name: { contains: "spammer" }});
let badUser = await newTestUser(this.config.homeserverUrl, { name: { contains: "spammer" }});
let badUserId = await badUser.getUserId();
let moderator = await newTestUser({ name: { contains: "moderator" }});
let moderator = await newTestUser(this.config.homeserverUrl, { name: { contains: "moderator" }});
let targetRoom = await moderator.createRoom({ invite: [await badUser.getUserId()]});
await badUser.joinRoom(targetRoom);
@ -39,9 +39,9 @@ describe("Test: timeline pagination", function () {
})
it('does not call the callback with an empty array when there are no relevant events', async function() {
this.timeout(60000);
let badUser = await newTestUser({ name: { contains: "spammer" }});
let badUser = await newTestUser(this.config.homeserverUrl, { name: { contains: "spammer" }});
let badUserId = await badUser.getUserId();
let moderator = await newTestUser({ name: { contains: "moderator" }});
let moderator = await newTestUser(this.config.homeserverUrl, { name: { contains: "moderator" }});
let targetRoom = await moderator.createRoom();
// send some irrelevant messages
await Promise.all([...Array(200).keys()].map((i) => moderator.sendMessage(targetRoom, {msgtype: 'm.text.', body: `Irrelevant Message #${i}`})));
@ -54,9 +54,9 @@ describe("Test: timeline pagination", function () {
})
it("The limit provided is respected", async function() {
this.timeout(60000);
let badUser = await newTestUser({ name: { contains: "spammer" }});
let badUser = await newTestUser(this.config.homeserverUrl, { name: { contains: "spammer" }});
let badUserId = await badUser.getUserId();
let moderator = await newTestUser({ name: { contains: "moderator" }});
let moderator = await newTestUser(this.config.homeserverUrl, { name: { contains: "moderator" }});
let targetRoom = await moderator.createRoom({ invite: [await badUser.getUserId()]});
await badUser.joinRoom(targetRoom);
// send some bad person messages
@ -83,7 +83,7 @@ describe("Test: timeline pagination", function () {
});
it("Gives the events to the callback ordered by youngest first (even more important when the limit is reached halfway through a chunk).", async function() {
this.timeout(60000);
let moderator = await newTestUser({ name: { contains: "moderator" }});
let moderator = await newTestUser(this.config.homeserverUrl, { name: { contains: "moderator" }});
let moderatorId = await moderator.getUserId();
let targetRoom = await moderator.createRoom();
for (let i = 0; i < 20; i++) {

View File

@ -8,11 +8,13 @@ describe("Test: utils", function() {
it("replaceRoomIdsWithPills correctly turns a room ID in to a pill", async function() {
this.timeout(20000);
const managementRoomAlias = this.config.managementRoom;
await this.mjolnir.client.sendStateEvent(
this.mjolnir.managementRoomId,
"m.room.canonical_alias",
"",
{ alias: config.managementRoom }
{ alias: managementRoomAlias }
);
const out = await replaceRoomIdsWithPills(
@ -24,7 +26,7 @@ describe("Test: utils", function() {
const ourHomeserver = new UserID(await this.mjolnir.client.getUserId()).domain;
assert.equal(
out.formatted_body,
`it's fun here in <a href="https://matrix.to/#/${config.managementRoom}?via=${ourHomeserver}">${config.managementRoom}</a>`
`it's fun here in <a href="https://matrix.to/#/${managementRoomAlias}?via=${ourHomeserver}">${managementRoomAlias}</a>`
);
});
});

458
yarn.lock

File diff suppressed because it is too large Load Diff