refactor (and simplify) how protections are stored in-memory

This commit is contained in:
jesopo 2022-02-02 15:31:11 +00:00
parent 8b23f93b71
commit ad3917c8b2
10 changed files with 201 additions and 166 deletions

View File

@ -59,7 +59,7 @@ export class Mjolnir {
private displayName: string;
private localpart: string;
private currentState: string = STATE_NOT_STARTED;
private protections: IProtection[] = [];
public protections = new Map<string /* protection name */, IProtection>();
/**
* This is for users who are not listed on a watchlist,
* but have been flagged by the automatic spam detection as suispicous
@ -237,7 +237,7 @@ export class Mjolnir {
}
public get enabledProtections(): IProtection[] {
return this.protections;
return [...this.protections.values()].filter(p => p.enabled);
}
/**
@ -293,7 +293,7 @@ export class Mjolnir {
if (config.syncOnStartup) {
await logMessage(LogLevel.INFO, "Mjolnir@startup", "Syncing lists...");
await this.syncLists(config.verboseLogging);
await this.enableProtections();
await this.registerProtections();
}
this.currentState = STATE_RUNNING;
@ -375,32 +375,52 @@ export class Mjolnir {
}
}
private async getEnabledProtections() {
let enabled: string[] = [];
try {
const protections: { enabled: string[] } | null = await this.client.getAccountData(ENABLED_PROTECTIONS_EVENT_TYPE);
if (protections && protections['enabled']) {
for (const protection of protections['enabled']) {
enabled.push(protection);
}
}
} catch (e) {
LogService.warn("Mjolnir", extractRequestError(e));
}
return enabled;
}
private async enableProtections() {
for (const protection of await this.getEnabledProtections()) {
/*
* Take all the builtin protections, register them to set their enabled (or not) state and
* update their settings with any saved non-default values
*/
private async registerProtections() {
for (const protection of PROTECTIONS) {
try {
this.enableProtection(protection, false);
await this.registerProtection(protection);
} catch (e) {
LogService.warn("Mjolnir", extractRequestError(e));
}
}
}
/*
* Make a list of the names of enabled protections and save them in a state event
*/
private async saveEnabledProtections() {
const protections = this.enabledProtections.map(p => p.name);
await this.client.setAccountData(ENABLED_PROTECTIONS_EVENT_TYPE, { enabled: protections });
}
/*
* Enable a protection by name and persist its enable state in to a state event
*
* @param name The name of the protection whose settings we're enabling
*/
public async enableProtection(name: string) {
const protection = this.protections.get(name);
if (protection !== undefined) {
protection.enabled = true;
await this.saveEnabledProtections();
}
}
/*
* Disable a protection by name and remove it from the persistent list of enabled protections
*
* @param name The name of the protection whose settings we're disabling
*/
public async disableProtection(name: string) {
const protection = this.protections.get(name);
if (protection !== undefined) {
protection.enabled = false;
await this.saveEnabledProtections();
}
}
/*
* Read org.matrix.mjolnir.setting state event, find any saved settings for
* the requested protectionName, then iterate and validate against their parser
@ -417,10 +437,10 @@ export class Mjolnir {
);
} catch {
// setting does not exist, return empty object
return savedSettings;
return {};
}
const settingDefinitions = PROTECTIONS[protectionName].factory().settings;
const settingDefinitions = this.protections.get(protectionName)?.settings ?? {};
const validatedSettings: { [setting: string]: any } = {}
for (let [key, value] of Object.entries(savedSettings)) {
if (
@ -452,17 +472,21 @@ export class Mjolnir {
* @param changedSettings The settings to change and their values
*/
public async setProtectionSettings(protectionName: string, changedSettings: { [setting: string]: any }): Promise<any> {
const settingDefinitions = PROTECTIONS[protectionName].factory().settings;
const protection = this.protections.get(protectionName);
if (protection === undefined) {
return;
}
const validatedSettings: { [setting: string]: any } = await this.getProtectionSettings(protectionName);
for (let [key, value] of Object.entries(changedSettings)) {
if (!(key in settingDefinitions)) {
if (!(key in protection.settings)) {
throw new ProtectionSettingValidationError(`Failed to find protection setting by name: ${key}`);
}
if (typeof(settingDefinitions[key].value) !== typeof(value)) {
if (typeof(protection.settings[key].value) !== typeof(value)) {
throw new ProtectionSettingValidationError(`Invalid type for protection setting: ${key} (${typeof(value)})`);
}
if (!settingDefinitions[key].validate(value)) {
if (!protection.settings[key].validate(value)) {
throw new ProtectionSettingValidationError(`Invalid value for protection setting: ${key} (${value})`);
}
validatedSettings[key] = value;
@ -473,31 +497,40 @@ export class Mjolnir {
);
}
public async enableProtection(protectionName: string, persist = true): Promise<any> {
const definition = PROTECTIONS[protectionName];
if (!definition) throw new Error("Failed to find protection by name: " + protectionName);
/*
* Given a protection object; add it to our list of protections, set whether it is enabled
* and update its settings with any saved non-default values.
*
* @param protection The protection object we want to register
*/
public async registerProtection(protection: IProtection): Promise<any> {
this.protections.set(protection.name, protection)
const protection = definition.factory();
this.protections.push(protection);
let enabledProtections: { enabled: string[] } | null = null;
try {
enabledProtections = await this.client.getAccountData(ENABLED_PROTECTIONS_EVENT_TYPE);
} catch {
// this setting either doesn't exist, or we failed to read it (bad network?)
// TODO: retry on certain failures?
}
protection.enabled = enabledProtections?.enabled.includes(protection.name) ?? false;
const savedSettings = await this.getProtectionSettings(protectionName);
const savedSettings = await this.getProtectionSettings(protection.name);
for (let [key, value] of Object.entries(savedSettings)) {
// this.getProtectionSettings() validates this data for us, so we don't need to
protection.settings[key].setValue(value);
}
if (persist) {
const existing = this.protections.map(p => p.name);
await this.client.setAccountData(ENABLED_PROTECTIONS_EVENT_TYPE, { enabled: existing });
}
}
public async disableProtection(protectionName: string): Promise<any> {
const idx = this.protections.findIndex(p => p.name === protectionName);
if (idx >= 0) this.protections.splice(idx, 1);
const existing = this.protections.map(p => p.name);
await this.client.setAccountData(ENABLED_PROTECTIONS_EVENT_TYPE, { enabled: existing });
/*
* Given a protection object; remove it from our list of protections.
*
* @param protection The protection object we want to unregister
*/
public unregisterProtection(protectionName: string) {
if (!(protectionName in this.protections)) {
throw new Error("Failed to find protection by name: " + protectionName);
}
this.protections.delete(protectionName);
}
public async watchList(roomRef: string): Promise<BanList | null> {
@ -779,8 +812,8 @@ export class Mjolnir {
if (Object.keys(this.protectedRooms).includes(roomId)) {
if (event['sender'] === await this.client.getUserId()) return; // Ignore ourselves
// Iterate all the protections
for (const protection of this.protections) {
// Iterate all the enabled protections
for (const protection of this.enabledProtections) {
try {
await protection.handleEvent(this, roomId, event);
} catch (e) {

View File

@ -17,7 +17,6 @@ limitations under the License.
import { htmlEscape } from "../utils";
import { Mjolnir } from "../Mjolnir";
import { extractRequestError, LogService, RichReply } from "matrix-bot-sdk";
import { PROTECTIONS } from "../protections/protections";
import { isListSetting } from "../protections/ProtectionSettings";
// !mjolnir enable <protection>
@ -51,12 +50,12 @@ enum ConfigAction {
*/
async function _execConfigChangeProtection(mjolnir: Mjolnir, parts: string[], action: ConfigAction): Promise<string> {
const [protectionName, ...settingParts] = parts[0].split(".");
const protection = PROTECTIONS[protectionName];
const protection = mjolnir.protections.get(protectionName);
if (protection === undefined) {
return `Unknown protection ${protectionName}`;
}
const defaultSettings = protection.factory().settings
const defaultSettings = protection.settings
const settingName = settingParts[0];
const stringValue = parts[1];
@ -83,22 +82,16 @@ async function _execConfigChangeProtection(mjolnir: Mjolnir, parts: string[], ac
}
}
// we need this to show what the value used to be
const oldSettings = await mjolnir.getProtectionSettings(protectionName);
try {
await mjolnir.setProtectionSettings(protectionName, { [settingName]: value });
} catch (e) {
return `Failed to set setting: ${e.message}`;
}
const enabledProtections = Object.fromEntries(mjolnir.enabledProtections.map(p => [p.name, p]));
if (protectionName in enabledProtections) {
// protection is currently loaded, so change the live setting value
enabledProtections[protectionName].settings[settingName].setValue(value);
}
const oldValue = protection.settings[settingName].value;
protection.settings[settingName].setValue(value);
return `Changed ${protectionName}.${settingName} to ${value} (was ${oldSettings[settingName]})`;
return `Changed ${protectionName}.${settingName} to ${value} (was ${oldValue})`;
}
/*
@ -146,7 +139,7 @@ export async function execConfigRemoveProtection(roomId: string, event: any, mjo
* !mjolnir get [protection name]
*/
export async function execConfigGetProtection(roomId: string, event: any, mjolnir: Mjolnir, parts: string[]) {
let pickProtections = Object.keys(PROTECTIONS);
let pickProtections = Object.keys(mjolnir.protections);
if (parts.length < 3) {
// no specific protectionName provided, show all of them.
@ -170,24 +163,19 @@ export async function execConfigGetProtection(roomId: string, event: any, mjolni
let anySettings = false;
for (const protectionName of pickProtections) {
// get all available settings, their default values, and their parsers
const availableSettings = PROTECTIONS[protectionName].factory().settings;
// get all saved non-default values
const savedSettings = await mjolnir.getProtectionSettings(protectionName);
const protectionSettings = mjolnir.protections.get(protectionName)?.settings ?? {};
if (Object.keys(availableSettings).length === 0) continue;
if (Object.keys(protectionSettings).length === 0) {
continue;
}
const settingNames = Object.keys(PROTECTIONS[protectionName].factory().settings);
const settingNames = Object.keys(protectionSettings);
// this means, within each protection name, setting names are sorted
settingNames.sort();
for (const settingName of settingNames) {
anySettings = true;
let value = availableSettings[settingName].value
if (settingName in savedSettings)
// we have a non-default value for this setting, use it
value = savedSettings[settingName]
let value = protectionSettings[settingName].value
text += `* ${protectionName}.${settingName}: ${value}`;
// `protectionName` and `settingName` are user-provided but
// validated against the names of existing protections and their
@ -214,16 +202,15 @@ export async function execDisableProtection(roomId: string, event: any, mjolnir:
// !mjolnir protections
export async function execListProtections(roomId: string, event: any, mjolnir: Mjolnir, parts: string[]) {
const possibleProtections = Object.keys(PROTECTIONS);
const enabledProtections = mjolnir.enabledProtections.map(p => p.name);
let html = "Available protections:<ul>";
let text = "Available protections:\n";
for (const protection of possibleProtections) {
const emoji = enabledProtections.includes(protection) ? '🟢 (enabled)' : '🔴 (disabled)';
html += `<li>${emoji} <code>${protection}</code> - ${PROTECTIONS[protection].description}</li>`;
text += `* ${emoji} ${protection} - ${PROTECTIONS[protection].description}\n`;
for (const [protectionName, protection] of mjolnir.protections) {
const emoji = enabledProtections.includes(protectionName) ? '🟢 (enabled)' : '🔴 (disabled)';
html += `<li>${emoji} <code>${protectionName}</code> - ${protection.description}</li>`;
text += `* ${emoji} ${protectionName} - ${protection.description}\n`;
}
html += "</ul>";

View File

@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import { IProtection } from "./IProtection";
import { Protection } from "./IProtection";
import { NumberProtectionSetting } from "./ProtectionSettings";
import { Mjolnir } from "../Mjolnir";
import { LogLevel, LogService } from "matrix-bot-sdk";
@ -25,7 +25,7 @@ import config from "../config";
export const DEFAULT_MAX_PER_MINUTE = 10;
const TIMESTAMP_THRESHOLD = 30000; // 30s out of phase
export class BasicFlooding implements IProtection {
export class BasicFlooding extends Protection {
private lastEvents: { [roomId: string]: { [userId: string]: { originServerTs: number, eventId: string }[] } } = {};
private recentlyBanned: string[] = [];
@ -34,11 +34,13 @@ export class BasicFlooding implements IProtection {
maxPerMinute: new NumberProtectionSetting(DEFAULT_MAX_PER_MINUTE)
};
constructor() { }
public get name(): string {
return 'BasicFloodingProtection';
}
public get description(): string {
return "If a user posts more than " + DEFAULT_MAX_PER_MINUTE + " messages in 60s they'll be " +
"banned for spam. This does not publish the ban to any of your ban lists.";
}
public async handleEvent(mjolnir: Mjolnir, roomId: string, event: any): Promise<any> {
if (!this.lastEvents[roomId]) this.lastEvents[roomId] = {};

View File

@ -14,14 +14,14 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import { IProtection } from "./IProtection";
import { Protection } from "./IProtection";
import { Mjolnir } from "../Mjolnir";
import { LogLevel, LogService } from "matrix-bot-sdk";
import { logMessage } from "../LogProxy";
import config from "../config";
import { isTrueJoinEvent } from "../utils";
export class FirstMessageIsImage implements IProtection {
export class FirstMessageIsImage extends Protection {
private justJoined: { [roomId: string]: string[] } = {};
private recentlyBanned: string[] = [];
@ -29,11 +29,16 @@ export class FirstMessageIsImage implements IProtection {
settings = {};
constructor() {
super();
}
public get name(): string {
return 'FirstMessageIsImageProtection';
}
public get description(): string {
return "If the first thing a user does after joining is to post an image or video, " +
"they'll be banned for spam. This does not publish the ban to any of your ban lists.";
}
public async handleEvent(mjolnir: Mjolnir, roomId: string, event: any): Promise<any> {
if (!this.justJoined[roomId]) this.justJoined[roomId] = [];

View File

@ -25,6 +25,15 @@ import { AbstractProtectionSetting } from "./ProtectionSettings";
*/
export interface IProtection {
readonly name: string;
readonly description: string;
enabled: boolean;
settings: { [setting: string]: AbstractProtectionSetting<any, any> };
handleEvent(mjolnir: Mjolnir, roomId: string, event: any): Promise<any>;
}
export abstract class Protection implements IProtection {
abstract readonly name: string
abstract readonly description: string;
enabled = false;
abstract settings: { [setting: string]: AbstractProtectionSetting<any, any> };
abstract handleEvent(mjolnir: Mjolnir, roomId: string, event: any): Promise<any>;
}

View File

@ -14,22 +14,26 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import { IProtection } from "./IProtection";
import { Protection } from "./IProtection";
import { Mjolnir } from "../Mjolnir";
import { LogLevel, Permalinks, UserID } from "matrix-bot-sdk";
import { logMessage } from "../LogProxy";
import config from "../config";
export class MessageIsMedia implements IProtection {
export class MessageIsMedia extends Protection {
settings = {};
constructor() {
super();
}
public get name(): string {
return 'MessageIsMediaProtection';
}
public get description(): string {
return "If a user posts an image or video, that message will be redacted. No bans are issued.";
}
public async handleEvent(mjolnir: Mjolnir, roomId: string, event: any): Promise<any> {
if (event['type'] === 'm.room.message') {

View File

@ -14,22 +14,26 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import { IProtection } from "./IProtection";
import { Protection } from "./IProtection";
import { Mjolnir } from "../Mjolnir";
import { LogLevel, Permalinks, UserID } from "matrix-bot-sdk";
import { logMessage } from "../LogProxy";
import config from "../config";
export class MessageIsVoice implements IProtection {
export class MessageIsVoice extends Protection {
settings = {};
constructor() {
super();
}
public get name(): string {
return 'MessageIsVoiceProtection';
}
public get description(): string {
return "If a user posts a voice message, that message will be redacted. No bans are issued.";
}
public async handleEvent(mjolnir: Mjolnir, roomId: string, event: any): Promise<any> {
if (event['type'] === 'm.room.message' && event['content']) {

View File

@ -14,14 +14,14 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import { IProtection } from "./IProtection";
import { Protection } from "./IProtection";
import { Mjolnir } from "../Mjolnir";
import { LogLevel, LogService } from "matrix-bot-sdk";
import { logMessage } from "../LogProxy";
import config from "../config";
import { isTrueJoinEvent } from "../utils";
export class WordList implements IProtection {
export class WordList extends Protection {
settings = {};
@ -29,6 +29,7 @@ export class WordList implements IProtection {
private badWords: RegExp;
constructor() {
super();
// Create a mega-regex from all the tiny baby regexs
this.badWords = new RegExp(
"(" + config.protections.wordlist.words.join(")|(") + ")",
@ -39,6 +40,10 @@ export class WordList implements IProtection {
public get name(): string {
return 'WordList';
}
public get description(): string {
return "If a user posts a monitored word a set amount of time after joining, they " +
"will be banned from that room. This will not publish the ban to a ban list.";
}
public async handleEvent(mjolnir: Mjolnir, roomId: string, event: any): Promise<any> {

View File

@ -16,40 +16,15 @@ limitations under the License.
import { FirstMessageIsImage } from "./FirstMessageIsImage";
import { IProtection } from "./IProtection";
import { BasicFlooding, DEFAULT_MAX_PER_MINUTE } from "./BasicFlooding";
import { BasicFlooding } from "./BasicFlooding";
import { WordList } from "./WordList";
import { MessageIsVoice } from "./MessageIsVoice";
import { MessageIsMedia } from "./MessageIsMedia";
export const PROTECTIONS: PossibleProtections = {
[new FirstMessageIsImage().name]: {
description: "If the first thing a user does after joining is to post an image or video, " +
"they'll be banned for spam. This does not publish the ban to any of your ban lists.",
factory: () => new FirstMessageIsImage(),
},
[new BasicFlooding().name]: {
description: "If a user posts more than " + DEFAULT_MAX_PER_MINUTE + " messages in 60s they'll be " +
"banned for spam. This does not publish the ban to any of your ban lists.",
factory: () => new BasicFlooding(),
},
[new WordList().name]: {
description: "If a user posts a monitored word a set amount of time after joining, they " +
"will be banned from that room. This will not publish the ban to a ban list.",
factory: () => new WordList(),
},
[new MessageIsVoice().name]: {
description: "If a user posts a voice message, that message will be redacted. No bans are issued.",
factory: () => new MessageIsVoice(),
},
[new MessageIsMedia().name]: {
description: "If a user posts an image or video, that message will be redacted. No bans are issued.",
factory: () => new MessageIsMedia(),
}
};
export interface PossibleProtections {
[name: string]: {
description: string;
factory: () => IProtection;
};
}
export const PROTECTIONS: IProtection[] = [
new FirstMessageIsImage(),
new BasicFlooding(),
new WordList(),
new MessageIsVoice(),
new MessageIsMedia()
];

View File

@ -28,14 +28,11 @@ describe("Test: Protection settings", function() {
it("Mjolnir successfully saves valid protection setting values", async function() {
this.timeout(20000);
PROTECTIONS["05OVMS"] = {
description: "A test protection",
factory: () => new class implements IProtection {
name = "05OVMS";
async handleEvent(mjolnir: Mjolnir, roomId: string, event: any) {};
settings = { test: new NumberProtectionSetting(3) };
}
};
await this.mjolnir.registerProtection(new class implements IProtection {
name = "05OVMS";
description = "A test protection";
settings = { test: new NumberProtectionSetting(3) };
});
await this.mjolnir.setProtectionSettings("05OVMS", { test: 123 });
assert.equal(
@ -46,17 +43,13 @@ describe("Test: Protection settings", function() {
it("Mjolnir should accumulate changed settings", async function() {
this.timeout(20000);
PROTECTIONS["HPUjKN"] = {
description: "A test protection",
factory: () => new class implements IProtection {
name = "HPUjKN";
async handleEvent(mjolnir: Mjolnir, roomId: string, event: any) {};
settings = {
test1: new NumberProtectionSetting(3),
test2: new NumberProtectionSetting(4)
};
}
};
await this.mjolnir.registerProtection(new class implements IProtection {
name = "HPUjKN";
settings = {
test1: new NumberProtectionSetting(3),
test2: new NumberProtectionSetting(4)
};
});
await this.mjolnir.setProtectionSettings("HPUjKN", { test1: 1 });
await this.mjolnir.setProtectionSettings("HPUjKN", { test2: 2 });
@ -68,14 +61,11 @@ describe("Test: Protection settings", function() {
this.timeout(20000);
await client.joinRoom(config.managementRoom);
PROTECTIONS["JY2TPN"] = {
description: "A test protection",
factory: () => new class implements IProtection {
name = "JY2TPN";
async handleEvent(mjolnir: Mjolnir, roomId: string, event: any) {};
settings = { test: new StringProtectionSetting() };
}
};
await this.mjolnir.registerProtection(new class implements IProtection {
name = "JY2TPN";
description = "A test protection";
settings = { test: new StringProtectionSetting() };
});
let reply = new Promise((resolve, reject) => {
@ -96,14 +86,11 @@ describe("Test: Protection settings", function() {
this.timeout(20000);
await client.joinRoom(config.managementRoom);
PROTECTIONS["r33XyT"] = {
description: "A test protection",
factory: () => new class implements IProtection {
name = "r33XyT";
async handleEvent(mjolnir: Mjolnir, roomId: string, event: any) {};
settings = { test: new StringListProtectionSetting() };
}
};
await this.mjolnir.registerProtection(new class implements IProtection {
name = "r33XyT";
description = "A test protection";
settings = { test: new StringListProtectionSetting() };
});
let reply = new Promise((resolve, reject) => {
@ -123,15 +110,11 @@ describe("Test: Protection settings", function() {
this.timeout(20000);
await client.joinRoom(config.managementRoom);
PROTECTIONS["oXzT0E"] = {
description: "A test protection",
factory: () => new class implements IProtection {
name = "oXzT0E";
async handleEvent(mjolnir: Mjolnir, roomId: string, event: any) {};
settings = { test: new StringListProtectionSetting() };
}
};
await this.mjolnir.registerProtection(new class implements IProtection {
name = "oXzT0E";
description = "A test protection";
settings = { test: new StringListProtectionSetting() };
});
let reply = () => new Promise((resolve, reject) => {
client.on('room.message', noticeListener(this.mjolnir.managementRoomId, (event) => {
@ -148,5 +131,33 @@ describe("Test: Protection settings", function() {
assert.deepEqual(await this.mjolnir.getProtectionSettings("oXzT0E"), { "test": [] });
});
it("Mjolnir will change a protection setting in-place", async function() {
this.timeout(20000);
await client.joinRoom(config.managementRoom);
await this.mjolnir.registerProtection(new class implements IProtection {
name = "d0sNrt";
description = "A test protection";
settings = { test: new StringProtectionSetting() };
});
let replyPromise = new Promise((resolve, reject) => {
let i = 0;
client.on('room.message', noticeListener(this.mjolnir.managementRoomId, (event) => {
if (event.content.body.includes("Changed d0sNrt.test ")) {
if (++i == 2) {
resolve(event);
}
}
}))
});
await client.sendMessage(this.mjolnir.managementRoomId, {msgtype: "m.text", body: "!mjolnir config set d0sNrt.test asd1"})
await client.sendMessage(this.mjolnir.managementRoomId, {msgtype: "m.text", body: "!mjolnir config set d0sNrt.test asd2"})
assert.equal(
(await replyPromise).content.body.split("\n", 3)[2],
"Changed d0sNrt.test to asd2 (was asd1)"
)
});
});