mjolnir/src/protections/JoinWaveShortCircuit.ts
Gnuxie 77ad40e27a
Refactor protected rooms. (#371)
* Attempt to factor out protected rooms from Mjolnir.

This is useful to the appservice because it means we don't
have to wrap a Mjolnir that is designed to sync.

It's also useful if we later on want to have specific
settings per space.

It's also just a nice seperation between Mjolnir's needs while
syncing via client-server and the behaviour of syncing policy rooms.

 ### Things that have changed

- `ErrorCache` no longer a static class (phew), gets used by `ProtectedRooms`.
- `ManagementRoomOutput` class gets created to handle logging back to the management room.
- Responsibilities for syncing member bans and server ACL are handled by `ProtectedRooms`.
- Responsibilities for watched lists should be moved to `ProtectedRooms` if they haven't been.
- `EventRedactionQueue` is moved to `ProtectedRooms` since this needs to happen after
  member bans.
- ApplyServerAcls moved to `ProtectedRooms`
- ApplyMemberBans move to `ProtectedRooms`
- `logMessage` and `replaceRoomIdsWithPills` moved to `ManagementRoomOutput`.
- `resyncJoinedRooms` has been made a little more clear, though I am concerned about how often it does run because it does seem expensive.


* ProtectedRooms is not supposed to track joined rooms.

The reason is because it is supposed to represent a specific
set of rooms to protect, not do horrible logic
for working out what rooms mjolnir is supposed to protect.
2022-09-29 14:49:09 +01:00

137 lines
5.1 KiB
TypeScript

/*
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 {Protection} from "./IProtection";
import {Mjolnir} from "../Mjolnir";
import {NumberProtectionSetting} from "./ProtectionSettings";
import {LogLevel} from "matrix-bot-sdk";
const DEFAULT_MAX_PER_TIMESCALE = 50;
const DEFAULT_TIMESCALE_MINUTES = 60;
const ONE_MINUTE = 60_000; // 1min in ms
export class JoinWaveShortCircuit extends Protection {
requiredStatePermissions = ["m.room.join_rules"]
private joinBuckets: {
[roomId: string]: {
lastBucketStart: Date,
numberOfJoins: number,
}
} = {};
settings = {
maxPer: new NumberProtectionSetting(DEFAULT_MAX_PER_TIMESCALE),
timescaleMinutes: new NumberProtectionSetting(DEFAULT_TIMESCALE_MINUTES)
};
constructor() {
super();
}
public get name(): string {
return "JoinWaveShortCircuit";
}
public get description(): string {
return "If X amount of users join in Y time, set the room to invite-only."
}
public async handleEvent(mjolnir: Mjolnir, roomId: string, event: any) {
if (event['type'] !== 'm.room.member') {
// Not a join/leave event.
return;
}
if (!mjolnir.protectedRoomsTracker.isProtectedRoom(roomId)) {
// Not a room we are watching.
return;
}
const userId = event['state_key'];
if (!userId) {
// Ill-formed event.
return;
}
const newMembership = event['content']['membership'];
const prevMembership = event['unsigned']?.['prev_content']?.['membership'] || null;
// We look at the previous membership to filter out profile changes
if (newMembership === 'join' && prevMembership !== "join") {
// A new join, fallthrough
} else {
return;
}
// If either the roomId bucket didn't exist, or the bucket has expired, create a new one
if (!this.joinBuckets[roomId] || this.hasExpired(this.joinBuckets[roomId].lastBucketStart)) {
this.joinBuckets[roomId] = {
lastBucketStart: new Date(),
numberOfJoins: 0
}
}
if (++this.joinBuckets[roomId].numberOfJoins >= this.settings.maxPer.value) {
await mjolnir.managementRoomOutput.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 (!mjolnir.config.noop) {
await mjolnir.client.sendStateEvent(roomId, "m.room.join_rules", "", {"join_rule": "invite"})
} else {
await mjolnir.managementRoomOutput.logMessage(LogLevel.WARN, "JoinWaveShortCircuit", `Tried to set ${roomId} to invite-only, but Mjolnir is running in no-op mode`, roomId);
}
}
}
private hasExpired(at: Date): boolean {
return ((new Date()).getTime() - at.getTime()) > this.timescaleMilliseconds()
}
private timescaleMilliseconds(): number {
return (this.settings.timescaleMinutes.value * ONE_MINUTE)
}
public async statusCommand(mjolnir: Mjolnir, subcommand: string[]): Promise<{ html: string, text: string }> {
const withExpired = subcommand.includes("withExpired");
const withStart = subcommand.includes("withStart");
let html = `<b>Short Circuit join buckets (max ${this.settings.maxPer.value} per ${this.settings.timescaleMinutes.value} minutes}):</b><br/><ul>`;
let text = `Short Circuit join buckets (max ${this.settings.maxPer.value} per ${this.settings.timescaleMinutes.value} minutes):\n`;
for (const roomId of Object.keys(this.joinBuckets)) {
const bucket = this.joinBuckets[roomId];
const isExpired = this.hasExpired(bucket.lastBucketStart);
if (isExpired && !withExpired) {
continue;
}
const startText = withStart ? ` (since ${bucket.lastBucketStart})` : "";
const expiredText = isExpired ? ` (bucket expired since ${new Date(bucket.lastBucketStart.getTime() + this.timescaleMilliseconds())})` : "";
html += `<li><a href="https://matrix.to/#/${roomId}">${roomId}</a>: ${bucket.numberOfJoins} joins${startText}${expiredText}.</li>`;
text += `* ${roomId}: ${bucket.numberOfJoins} joins${startText}${expiredText}.\n`;
}
html += "</ul>";
return {
html,
text,
}
}
}