mirror of
https://github.com/matrix-org/mjolnir.git
synced 2024-10-01 01:36:06 -04:00
Apply members and server bans to the most recently active rooms first. (#274)
* Apply members and server bans to the most recently active rooms first. https://github.com/matrix-org/mjolnir/issues/273
This commit is contained in:
parent
cc9f393ed7
commit
c8caf744c5
@ -47,6 +47,7 @@ import { WebAPIs } from "./webapis/WebAPIs";
|
||||
import { replaceRoomIdsWithPills } from "./utils";
|
||||
import RuleServer from "./models/RuleServer";
|
||||
import { RoomMemberManager } from "./RoomMembers";
|
||||
import { ProtectedRoomActivityTracker } from "./queues/ProtectedRoomActivityTracker";
|
||||
|
||||
const levelToFn = {
|
||||
[LogLevel.DEBUG.toString()]: LogService.debug,
|
||||
@ -83,10 +84,17 @@ export class Mjolnir {
|
||||
*/
|
||||
private eventRedactionQueue = new EventRedactionQueue();
|
||||
private automaticRedactionReasons: MatrixGlob[] = [];
|
||||
/**
|
||||
* Every room that we are joined to except the management room. Used to implement `config.protectAllJoinedRooms`.
|
||||
*/
|
||||
private protectedJoinedRoomIds: string[] = [];
|
||||
/**
|
||||
* These are rooms that were explicitly said to be protected either in the config, or by what is present in the account data for `org.matrix.mjolnir.protected_rooms`.
|
||||
*/
|
||||
private explicitlyProtectedRoomIds: string[] = [];
|
||||
private knownUnprotectedRooms: string[] = [];
|
||||
private unprotectedWatchedListRooms: string[] = [];
|
||||
private webapis: WebAPIs;
|
||||
private protectedRoomActivityTracker: ProtectedRoomActivityTracker;
|
||||
/**
|
||||
* Adds a listener to the client that will automatically accept invitations.
|
||||
* @param {MatrixClient} client
|
||||
@ -167,6 +175,10 @@ export class Mjolnir {
|
||||
constructor(
|
||||
public readonly client: MatrixClient,
|
||||
public readonly managementRoomId: string,
|
||||
/*
|
||||
* 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[],
|
||||
// Combines the rules from ban lists so they can be served to a homeserver module or another consumer.
|
||||
@ -235,6 +247,9 @@ export class Mjolnir {
|
||||
}
|
||||
});
|
||||
|
||||
// Setup room activity watcher
|
||||
this.protectedRoomActivityTracker = new ProtectedRoomActivityTracker(client);
|
||||
|
||||
// Setup Web APIs
|
||||
console.log("Creating Web APIs");
|
||||
const reportManager = new ReportManager(this);
|
||||
@ -293,6 +308,7 @@ export class Mjolnir {
|
||||
for (const roomId of data['rooms']) {
|
||||
this.protectedRooms[roomId] = Permalinks.forRoom(roomId);
|
||||
this.explicitlyProtectedRoomIds.push(roomId);
|
||||
this.protectedRoomActivityTracker.addProtectedRoom(roomId);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
@ -372,9 +388,10 @@ export class Mjolnir {
|
||||
public async addProtectedRoom(roomId: string) {
|
||||
this.protectedRooms[roomId] = Permalinks.forRoom(roomId);
|
||||
this.roomJoins.addRoom(roomId);
|
||||
this.protectedRoomActivityTracker.addProtectedRoom(roomId);
|
||||
|
||||
const unprotectedIdx = this.knownUnprotectedRooms.indexOf(roomId);
|
||||
if (unprotectedIdx >= 0) this.knownUnprotectedRooms.splice(unprotectedIdx, 1);
|
||||
const unprotectedIdx = this.unprotectedWatchedListRooms.indexOf(roomId);
|
||||
if (unprotectedIdx >= 0) this.unprotectedWatchedListRooms.splice(unprotectedIdx, 1);
|
||||
this.explicitlyProtectedRoomIds.push(roomId);
|
||||
|
||||
let additionalProtectedRooms: { rooms?: string[] } | null = null;
|
||||
@ -392,6 +409,7 @@ export class Mjolnir {
|
||||
public async removeProtectedRoom(roomId: string) {
|
||||
delete this.protectedRooms[roomId];
|
||||
this.roomJoins.removeRoom(roomId);
|
||||
this.protectedRoomActivityTracker.removeProtectedRoom(roomId);
|
||||
|
||||
const idx = this.explicitlyProtectedRoomIds.indexOf(roomId);
|
||||
if (idx >= 0) this.explicitlyProtectedRoomIds.splice(idx, 1);
|
||||
@ -412,15 +430,19 @@ export class Mjolnir {
|
||||
const joinedRoomIds = (await this.client.getJoinedRooms()).filter(r => r !== this.managementRoomId);
|
||||
const oldRoomIdsSet = new Set(this.protectedJoinedRoomIds);
|
||||
const joinedRoomIdsSet = new Set(joinedRoomIds);
|
||||
// Remove every room id that we have joined from `this.protectedRooms`.
|
||||
for (const roomId of this.protectedJoinedRoomIds) {
|
||||
delete this.protectedRooms[roomId];
|
||||
this.protectedRoomActivityTracker.removeProtectedRoom(roomId);
|
||||
if (!joinedRoomIdsSet.has(roomId)) {
|
||||
this.roomJoins.removeRoom(roomId);
|
||||
}
|
||||
}
|
||||
this.protectedJoinedRoomIds = joinedRoomIds;
|
||||
// Add all joined rooms back to the permalink object
|
||||
for (const roomId of joinedRoomIds) {
|
||||
this.protectedRooms[roomId] = Permalinks.forRoom(roomId);
|
||||
this.protectedRoomActivityTracker.addProtectedRoom(roomId);
|
||||
if (!oldRoomIdsSet.has(roomId)) {
|
||||
this.roomJoins.addRoom(roomId);
|
||||
}
|
||||
@ -662,7 +684,7 @@ export class Mjolnir {
|
||||
const createEvent = new CreateEvent(await this.client.getRoomStateEvent(roomId, "m.room.create", ""));
|
||||
if (createEvent.creator === await this.client.getUserId()) return; // we created it
|
||||
|
||||
if (!this.knownUnprotectedRooms.includes(roomId)) this.knownUnprotectedRooms.push(roomId);
|
||||
if (!this.unprotectedWatchedListRooms.includes(roomId)) this.unprotectedWatchedListRooms.push(roomId);
|
||||
this.applyUnprotectedRooms();
|
||||
|
||||
try {
|
||||
@ -677,8 +699,9 @@ export class Mjolnir {
|
||||
}
|
||||
|
||||
private applyUnprotectedRooms() {
|
||||
for (const roomId of this.knownUnprotectedRooms) {
|
||||
for (const roomId of this.unprotectedWatchedListRooms) {
|
||||
delete this.protectedRooms[roomId];
|
||||
this.protectedRoomActivityTracker.removeProtectedRoom(roomId);
|
||||
}
|
||||
}
|
||||
|
||||
@ -798,6 +821,13 @@ export class Mjolnir {
|
||||
return errors;
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns The protected rooms ordered by the most recently active first.
|
||||
*/
|
||||
public protectedRoomsByActivity(): string[] {
|
||||
return this.protectedRoomActivityTracker.protectedRoomsByActivity();
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync all the rooms with all the watched lists, banning and applying any changed ACLS.
|
||||
* @param verbose Whether to report any errors to the management room.
|
||||
@ -809,9 +839,10 @@ export class Mjolnir {
|
||||
}
|
||||
|
||||
let hadErrors = false;
|
||||
|
||||
const aclErrors = await applyServerAcls(this.banLists, Object.keys(this.protectedRooms), this);
|
||||
const banErrors = await applyUserBans(this.banLists, Object.keys(this.protectedRooms), this);
|
||||
const [aclErrors, banErrors] = await Promise.all([
|
||||
applyServerAcls(this.banLists, this.protectedRoomsByActivity(), this),
|
||||
applyUserBans(this.banLists, this.protectedRoomsByActivity(), this)
|
||||
]);
|
||||
const redactionErrors = await this.processRedactionQueue();
|
||||
hadErrors = hadErrors || await this.printActionResult(aclErrors, "Errors updating server ACLs:");
|
||||
hadErrors = hadErrors || await this.printActionResult(banErrors, "Errors updating member bans:");
|
||||
@ -839,8 +870,10 @@ export class Mjolnir {
|
||||
const changes = await banList.updateList();
|
||||
|
||||
let hadErrors = false;
|
||||
const aclErrors = await applyServerAcls(this.banLists, Object.keys(this.protectedRooms), this);
|
||||
const banErrors = await applyUserBans(this.banLists, Object.keys(this.protectedRooms), this);
|
||||
const [aclErrors, banErrors] = await Promise.all([
|
||||
applyServerAcls(this.banLists, this.protectedRoomsByActivity(), this),
|
||||
applyUserBans(this.banLists, this.protectedRoomsByActivity(), this)
|
||||
]);
|
||||
const redactionErrors = await this.processRedactionQueue();
|
||||
hadErrors = hadErrors || await this.printActionResult(aclErrors, "Errors updating server ACLs:");
|
||||
hadErrors = hadErrors || await this.printActionResult(banErrors, "Errors updating member bans:");
|
||||
|
79
src/queues/ProtectedRoomActivityTracker.ts
Normal file
79
src/queues/ProtectedRoomActivityTracker.ts
Normal file
@ -0,0 +1,79 @@
|
||||
/*
|
||||
Copyright 2022 The Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
import { MatrixClient } from "matrix-bot-sdk";
|
||||
|
||||
/**
|
||||
* Used to keep track of protected rooms so they are always ordered for activity.
|
||||
*
|
||||
* We use the same method as Element web for this, the major disadvantage being that we sort on each access to the room list (sort by most recently active first).
|
||||
* We have tried to mitigate this by caching the sorted list until the activity in rooms changes again.
|
||||
* See https://github.com/matrix-org/matrix-react-sdk/blob/8a0398b632dff1a5f6cfd4bf95d78854aeadc60e/src/stores/room-list/algorithms/tag-sorting/RecentAlgorithm.ts
|
||||
*
|
||||
*/
|
||||
export class ProtectedRoomActivityTracker {
|
||||
private protectedRoomActivities = new Map<string/*room id*/, number/*last event timestamp*/>();
|
||||
/**
|
||||
* A slot to cache the rooms for `protectedRoomsByActivity` ordered so the most recently active room is first.
|
||||
*/
|
||||
private activeRoomsCache: null|string[] = null
|
||||
constructor(client: MatrixClient) {
|
||||
client.on('room.event', this.handleEvent.bind(this));
|
||||
}
|
||||
|
||||
/**
|
||||
* Inform the tracker that a new room is being protected by Mjolnir.
|
||||
* @param roomId The room Mjolnir is now protecting.
|
||||
*/
|
||||
public addProtectedRoom(roomId: string): void {
|
||||
this.protectedRoomActivities.set(roomId, /* epoch */ 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Inform the trakcer that a room is no longer being protected by Mjolnir.
|
||||
* @param roomId The roomId that is no longer being protected by Mjolnir.
|
||||
*/
|
||||
public removeProtectedRoom(roomId: string): void {
|
||||
this.protectedRoomActivities.delete(roomId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Inform the tracker of a new event in a room, so that the internal ranking of rooms can be updated
|
||||
* @param roomId The room the new event is in.
|
||||
* @param event The new event.
|
||||
*/
|
||||
public handleEvent(roomId: string, event: any): void {
|
||||
const last_origin_server_ts = this.protectedRoomActivities.get(roomId);
|
||||
if (last_origin_server_ts !== undefined && Number.isInteger(event.origin_server_ts)) {
|
||||
if (event.origin_server_ts > last_origin_server_ts) {
|
||||
this.activeRoomsCache = null;
|
||||
this.protectedRoomActivities.set(roomId, event.origin_server_ts);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns A list of protected rooms ids ordered by activity.
|
||||
*/
|
||||
public protectedRoomsByActivity(): string[] {
|
||||
if (!this.activeRoomsCache) {
|
||||
this.activeRoomsCache = [...this.protectedRoomActivities]
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.map(pair => pair[0]);
|
||||
}
|
||||
return this.activeRoomsCache;
|
||||
}
|
||||
}
|
||||
|
@ -1,11 +1,11 @@
|
||||
import { strict as assert } from "assert";
|
||||
|
||||
import config from "../../src/config";
|
||||
import { newTestUser, noticeListener } from "./clientHelper";
|
||||
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 { ServerAcl, ServerAclContent } from "../../src/models/ServerAcl";
|
||||
import { createBanList, getFirstReaction } from "./commands/commandUtils";
|
||||
import { ServerAcl } from "../../src/models/ServerAcl";
|
||||
import { getFirstReaction } from "./commands/commandUtils";
|
||||
import { getMessagesByUserIn } from "../../src/utils";
|
||||
|
||||
/**
|
||||
@ -363,3 +363,80 @@ describe('Test: unbaning entities via the BanList.', function () {
|
||||
assert.equal(aclAfter.deny.length, 0, 'Should be no servers denied anymore');
|
||||
})
|
||||
})
|
||||
|
||||
describe.only('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(6000000000)
|
||||
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();
|
||||
|
||||
// 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);
|
||||
await moderator.setUserPowerLevel(mjolnirId, room, 100);
|
||||
await this.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 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));
|
||||
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));
|
||||
|
||||
await this.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--) {
|
||||
const j = Math.floor(Math.random() * (i + 1));
|
||||
[protectedRooms[i], protectedRooms[j]] = [protectedRooms[j], protectedRooms[i]];
|
||||
}
|
||||
// create some activity in the same order.
|
||||
for (const roomId of protectedRooms.slice().reverse()) {
|
||||
await mjolnir.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]);
|
||||
}
|
||||
|
||||
const badServer = `evil.com`;
|
||||
// just ban one server
|
||||
const acl = new ServerAcl(serverName).denyIpAddresses().allowServer("*").denyServer(badServer);
|
||||
await createPolicyRule(moderator, banListId, RULE_SERVER, badServer, `Rule ${badServer}`);
|
||||
|
||||
// Wait until all the ACL events have been applied.
|
||||
await this.mjolnir!.syncLists();
|
||||
|
||||
for (let i = 0; i < protectedRooms.length; i++) {
|
||||
assert.equal(this.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;
|
||||
// Can't be the best way to get the whole event, but ok.
|
||||
await getMessagesByUserIn(mjolnir, 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)}`)
|
||||
}
|
||||
assert.equal(roomAclEvent.origin_server_ts > last_event_ts, true, `This room was more recently active so should have the more recent timestamp`);
|
||||
last_event_ts = roomAclEvent.origin_server_ts;
|
||||
}
|
||||
})
|
||||
})
|
||||
|
@ -21,6 +21,7 @@
|
||||
"include": [
|
||||
"./src/**/*",
|
||||
"./test/integration/manualLaunchScript.ts",
|
||||
"./test/integration/roomMembersTest.ts"
|
||||
"./test/integration/roomMembersTest.ts",
|
||||
"./test/integration/banListTest.ts"
|
||||
]
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user