runtime persistent settings system (#158)

This commit is contained in:
Jess Porter 2022-01-25 14:47:50 +00:00 committed by GitHub
parent c7a96a3afe
commit 423a34bebe
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
12 changed files with 560 additions and 7 deletions

View File

@ -36,6 +36,7 @@ import { logMessage } from "./LogProxy";
import ErrorCache, { ERROR_KIND_FATAL, ERROR_KIND_PERMISSION } from "./ErrorCache"; import ErrorCache, { ERROR_KIND_FATAL, ERROR_KIND_PERMISSION } from "./ErrorCache";
import { IProtection } from "./protections/IProtection"; import { IProtection } from "./protections/IProtection";
import { PROTECTIONS } from "./protections/protections"; import { PROTECTIONS } from "./protections/protections";
import { ProtectionSettingValidationError } from "./protections/ProtectionSettings";
import { UnlistedUserRedactionQueue } from "./queues/UnlistedUserRedactionQueue"; import { UnlistedUserRedactionQueue } from "./queues/UnlistedUserRedactionQueue";
import { Healthz } from "./health/healthz"; import { Healthz } from "./health/healthz";
import { EventRedactionQueue, RedactUserInRoom } from "./queues/EventRedactionQueue"; import { EventRedactionQueue, RedactUserInRoom } from "./queues/EventRedactionQueue";
@ -401,6 +402,78 @@ export class Mjolnir {
} }
} }
/*
* Read org.matrix.mjolnir.setting state event, find any saved settings for
* the requested protectionName, then iterate and validate against their parser
* counterparts in IProtection.settings and return those which validate
*
* @param protectionName The name of the protection whose settings we're reading
* @returns Every saved setting for this protectionName that has a valid value
*/
public async getProtectionSettings(protectionName: string): Promise<{ [setting: string]: any }> {
let savedSettings: { [setting: string]: any } = {}
try {
savedSettings = await this.client.getRoomStateEvent(
this.managementRoomId, 'org.matrix.mjolnir.setting', protectionName
);
} catch {
// setting does not exist, return empty object
return savedSettings;
}
const settingDefinitions = PROTECTIONS[protectionName].factory().settings;
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)
) {
validatedSettings[key] = value;
} else {
await logMessage(
LogLevel.WARN,
"getProtectionSetting",
`Tried to read ${protectionName}.${key} and got invalid value ${value}`
);
}
}
return validatedSettings;
}
/*
* Takes an object of settings we want to change and what their values should be,
* check that their values are valid, combine them with current saved settings,
* then save the amalgamation to a state event
*
* @param protectionName Which protection these settings belong to
* @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 validatedSettings: { [setting: string]: any } = await this.getProtectionSettings(protectionName);
for (let [key, value] of Object.entries(changedSettings)) {
if (!(key in settingDefinitions)) {
throw new ProtectionSettingValidationError(`Failed to find protection setting by name: ${key}`);
}
if (typeof(settingDefinitions[key].value) !== typeof(value)) {
throw new ProtectionSettingValidationError(`Invalid type for protection setting: ${key} (${typeof(value)})`);
}
if (!settingDefinitions[key].validate(value)) {
throw new ProtectionSettingValidationError(`Invalid value for protection setting: ${key} (${value})`);
}
validatedSettings[key] = value;
}
await this.client.sendStateEvent(
this.managementRoomId, 'org.matrix.mjolnir.setting', protectionName, validatedSettings
);
}
public async enableProtection(protectionName: string, persist = true): Promise<any> { public async enableProtection(protectionName: string, persist = true): Promise<any> {
const definition = PROTECTIONS[protectionName]; const definition = PROTECTIONS[protectionName];
if (!definition) throw new Error("Failed to find protection by name: " + protectionName); if (!definition) throw new Error("Failed to find protection by name: " + protectionName);
@ -408,6 +481,12 @@ export class Mjolnir {
const protection = definition.factory(); const protection = definition.factory();
this.protections.push(protection); this.protections.push(protection);
const savedSettings = await this.getProtectionSettings(protectionName);
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) { if (persist) {
const existing = this.protections.map(p => p.name); const existing = this.protections.map(p => p.name);
await this.client.setAccountData(ENABLED_PROTECTIONS_EVENT_TYPE, { enabled: existing }); await this.client.setAccountData(ENABLED_PROTECTIONS_EVENT_TYPE, { enabled: existing });

View File

@ -28,7 +28,8 @@ import { execRedactCommand } from "./RedactCommand";
import { execImportCommand } from "./ImportCommand"; import { execImportCommand } from "./ImportCommand";
import { execSetDefaultListCommand } from "./SetDefaultBanListCommand"; import { execSetDefaultListCommand } from "./SetDefaultBanListCommand";
import { execDeactivateCommand } from "./DeactivateCommand"; import { execDeactivateCommand } from "./DeactivateCommand";
import { execDisableProtection, execEnableProtection, execListProtections } from "./ProtectionsCommands"; import { execDisableProtection, execEnableProtection, execListProtections, execConfigGetProtection,
execConfigSetProtection, execConfigAddProtection, execConfigRemoveProtection } from "./ProtectionsCommands";
import { execListProtectedRooms } from "./ListProtectedRoomsCommand"; import { execListProtectedRooms } from "./ListProtectedRoomsCommand";
import { execAddProtectedRoom, execRemoveProtectedRoom } from "./AddRemoveProtectedRoomsCommand"; import { execAddProtectedRoom, execRemoveProtectedRoom } from "./AddRemoveProtectedRoomsCommand";
import { execAddRoomToDirectoryCommand, execRemoveRoomFromDirectoryCommand } from "./AddRemoveRoomFromDirectoryCommand"; import { execAddRoomToDirectoryCommand, execRemoveRoomFromDirectoryCommand } from "./AddRemoveRoomFromDirectoryCommand";
@ -76,6 +77,14 @@ export async function handleCommand(roomId: string, event: any, mjolnir: Mjolnir
return await execEnableProtection(roomId, event, mjolnir, parts); return await execEnableProtection(roomId, event, mjolnir, parts);
} else if (parts[1] === 'disable' && parts.length > 1) { } else if (parts[1] === 'disable' && parts.length > 1) {
return await execDisableProtection(roomId, event, mjolnir, parts); return await execDisableProtection(roomId, event, mjolnir, parts);
} else if (parts[1] === 'config' && parts[2] === 'set' && parts.length > 3) {
return await execConfigSetProtection(roomId, event, mjolnir, parts.slice(3))
} else if (parts[1] === 'config' && parts[2] === 'add' && parts.length > 3) {
return await execConfigAddProtection(roomId, event, mjolnir, parts.slice(3))
} else if (parts[1] === 'config' && parts[2] === 'remove' && parts.length > 3) {
return await execConfigRemoveProtection(roomId, event, mjolnir, parts.slice(3))
} else if (parts[1] === 'config' && parts[2] === 'get') {
return await execConfigGetProtection(roomId, event, mjolnir, parts.slice(3))
} else if (parts[1] === 'rooms' && parts.length > 3 && parts[2] === 'add') { } else if (parts[1] === 'rooms' && parts.length > 3 && parts[2] === 'add') {
return await execAddProtectedRoom(roomId, event, mjolnir, parts); return await execAddProtectedRoom(roomId, event, mjolnir, parts);
} else if (parts[1] === 'rooms' && parts.length > 3 && parts[2] === 'remove') { } else if (parts[1] === 'rooms' && parts.length > 3 && parts[2] === 'remove') {
@ -122,6 +131,10 @@ export async function handleCommand(roomId: string, event: any, mjolnir: Mjolnir
"!mjolnir protections - List all available protections\n" + "!mjolnir protections - List all available protections\n" +
"!mjolnir enable <protection> - Enables a particular protection\n" + "!mjolnir enable <protection> - Enables a particular protection\n" +
"!mjolnir disable <protection> - Disables a particular protection\n" + "!mjolnir disable <protection> - Disables a particular protection\n" +
"!mjolnir config set <protection>.<setting> [value] - Change a projection setting\n" +
"!mjolnir config add <protection>.<setting> [value] - Add a value to a list protection setting\n" +
"!mjolnir config remove <protection>.<setting> [value] - Remove a value from a list protection setting\n" +
"!mjolnir config get [protection] - List protection settings\n" +
"!mjolnir rooms - Lists all the protected rooms\n" + "!mjolnir rooms - Lists all the protected rooms\n" +
"!mjolnir rooms add <room alias/ID> - Adds a protected room (may cause high server load)\n" + "!mjolnir rooms add <room alias/ID> - Adds a protected room (may cause high server load)\n" +
"!mjolnir rooms remove <room alias/ID> - Removes a protected room\n" + "!mjolnir rooms remove <room alias/ID> - Removes a protected room\n" +

View File

@ -14,9 +14,11 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import * as htmlEscape from "escape-html";
import { Mjolnir } from "../Mjolnir"; import { Mjolnir } from "../Mjolnir";
import { extractRequestError, LogService, RichReply } from "matrix-bot-sdk"; import { extractRequestError, LogService, RichReply } from "matrix-bot-sdk";
import { PROTECTIONS } from "../protections/protections"; import { PROTECTIONS } from "../protections/protections";
import { isListSetting } from "../protections/ProtectionSettings";
// !mjolnir enable <protection> // !mjolnir enable <protection>
export async function execEnableProtection(roomId: string, event: any, mjolnir: Mjolnir, parts: string[]) { export async function execEnableProtection(roomId: string, event: any, mjolnir: Mjolnir, parts: string[]) {
@ -33,6 +35,177 @@ export async function execEnableProtection(roomId: string, event: any, mjolnir:
} }
} }
enum ConfigAction {
Set,
Add,
Remove
}
/*
* Process a given ConfigAction against a given protection setting
*
* @param mjolnir Current Mjolnir instance
* @param parts Arguments given to the command being processed
* @param action Which ConfigAction to do to the provided protection setting
* @returns Command success or failure message
*/
async function _execConfigChangeProtection(mjolnir: Mjolnir, parts: string[], action: ConfigAction): Promise<string> {
const [protectionName, ...settingParts] = parts[0].split(".");
const protection = PROTECTIONS[protectionName];
if (protection === undefined) {
return `Unknown protection ${protectionName}`;
}
const defaultSettings = protection.factory().settings
const settingName = settingParts[0];
const stringValue = parts[1];
if (!(settingName in defaultSettings)) {
return `Unknown setting ${settingName}`;
}
const parser = defaultSettings[settingName];
// we don't need to validate `value`, because mjolnir.setProtectionSettings does
// it for us (and raises an exception if there's a problem)
let value = parser.fromString(stringValue);
if (action === ConfigAction.Add) {
if (!isListSetting(parser)) {
return `Setting ${settingName} isn't a list`;
} else {
value = parser.addValue(value);
}
} else if (action === ConfigAction.Remove) {
if (!isListSetting(parser)) {
return `Setting ${settingName} isn't a list`;
} else {
value = parser.removeValue(value);
}
}
// 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);
}
return `Changed ${protectionName}.${settingName} to ${value} (was ${oldSettings[settingName]})`;
}
/*
* Change a protection setting
*
* !mjolnir set <protection name>.<setting name> <value>
*/
export async function execConfigSetProtection(roomId: string, event: any, mjolnir: Mjolnir, parts: string[]) {
const message = await _execConfigChangeProtection(mjolnir, parts, ConfigAction.Set);
const reply = RichReply.createFor(roomId, event, message, message);
reply["msgtype"] = "m.notice";
await mjolnir.client.sendMessage(roomId, reply);
}
/*
* Add a value to a protection list setting
*
* !mjolnir add <protection name>.<setting name> <value>
*/
export async function execConfigAddProtection(roomId: string, event: any, mjolnir: Mjolnir, parts: string[]) {
const message = await _execConfigChangeProtection(mjolnir, parts, ConfigAction.Add);
const reply = RichReply.createFor(roomId, event, message, message);
reply["msgtype"] = "m.notice";
await mjolnir.client.sendMessage(roomId, reply);
}
/*
* Remove a value from a protection list setting
*
* !mjolnir remove <protection name>.<setting name> <value>
*/
export async function execConfigRemoveProtection(roomId: string, event: any, mjolnir: Mjolnir, parts: string[]) {
const message = await _execConfigChangeProtection(mjolnir, parts, ConfigAction.Remove);
const reply = RichReply.createFor(roomId, event, message, message);
reply["msgtype"] = "m.notice";
await mjolnir.client.sendMessage(roomId, reply);
}
/*
* Get all protection settings or get all settings for a given protection
*
* !mjolnir get [protection name]
*/
export async function execConfigGetProtection(roomId: string, event: any, mjolnir: Mjolnir, parts: string[]) {
let pickProtections = Object.keys(PROTECTIONS);
if (parts.length < 3) {
// no specific protectionName provided, show all of them.
// sort output by protection name
pickProtections.sort();
} else {
if (!pickProtections.includes(parts[0])) {
const errMsg = `Unknown protection: ${parts[0]}`;
const errReply = RichReply.createFor(roomId, event, errMsg, errMsg);
errReply["msgtype"] = "m.notice";
await mjolnir.client.sendMessage(roomId, errReply);
return;
}
pickProtections = [parts[0]];
}
let text = "Protection settings\n";
let html = "<b>Protection settings<b><br /><ul>";
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);
if (Object.keys(availableSettings).length === 0) continue;
const settingNames = Object.keys(PROTECTIONS[protectionName].factory().settings);
// 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]
text += `* ${protectionName}.${settingName}: ${value}`;
// `protectionName` and `settingName` are user-provided but
// validated against the names of existing protections and their
// settings, so XSS is avoided for these already
html += `<li><code>${protectionName}.${settingName}</code>: <code>${htmlEscape(value)}</code></li>`
}
}
html += "</ul>";
if (!anySettings)
html = text = "No settings found";
const reply = RichReply.createFor(roomId, event, text, html);
reply["msgtype"] = "m.notice";
await mjolnir.client.sendMessage(roomId, reply);
}
// !mjolnir disable <protection> // !mjolnir disable <protection>
export async function execDisableProtection(roomId: string, event: any, mjolnir: Mjolnir, parts: string[]) { export async function execDisableProtection(roomId: string, event: any, mjolnir: Mjolnir, parts: string[]) {
await mjolnir.disableProtection(parts[2]); await mjolnir.disableProtection(parts[2]);

View File

@ -15,12 +15,14 @@ limitations under the License.
*/ */
import { IProtection } from "./IProtection"; import { IProtection } from "./IProtection";
import { NumberProtectionSetting } from "./ProtectionSettings";
import { Mjolnir } from "../Mjolnir"; import { Mjolnir } from "../Mjolnir";
import { LogLevel, LogService } from "matrix-bot-sdk"; import { LogLevel, LogService } from "matrix-bot-sdk";
import { logMessage } from "../LogProxy"; import { logMessage } from "../LogProxy";
import config from "../config"; import config from "../config";
export const MAX_PER_MINUTE = 10; // if this is exceeded, we'll ban the user for spam and redact their messages // if this is exceeded, we'll ban the user for spam and redact their messages
export const DEFAULT_MAX_PER_MINUTE = 10;
const TIMESTAMP_THRESHOLD = 30000; // 30s out of phase const TIMESTAMP_THRESHOLD = 30000; // 30s out of phase
export class BasicFlooding implements IProtection { export class BasicFlooding implements IProtection {
@ -28,7 +30,11 @@ export class BasicFlooding implements IProtection {
private lastEvents: { [roomId: string]: { [userId: string]: { originServerTs: number, eventId: string }[] } } = {}; private lastEvents: { [roomId: string]: { [userId: string]: { originServerTs: number, eventId: string }[] } } = {};
private recentlyBanned: string[] = []; private recentlyBanned: string[] = [];
maxPerMinute = new NumberProtectionSetting(DEFAULT_MAX_PER_MINUTE);
settings = {};
constructor() { constructor() {
this.settings['maxPerMinute'] = this.maxPerMinute;
} }
public get name(): string { public get name(): string {
@ -56,7 +62,7 @@ export class BasicFlooding implements IProtection {
messageCount++; messageCount++;
} }
if (messageCount >= MAX_PER_MINUTE) { if (messageCount >= this.maxPerMinute.value) {
await logMessage(LogLevel.WARN, "BasicFlooding", `Banning ${event['sender']} in ${roomId} for flooding (${messageCount} messages in the last minute)`, roomId); await logMessage(LogLevel.WARN, "BasicFlooding", `Banning ${event['sender']} in ${roomId} for flooding (${messageCount} messages in the last minute)`, roomId);
if (!config.noop) { if (!config.noop) {
await mjolnir.client.banUser(event['sender'], roomId, "spam"); await mjolnir.client.banUser(event['sender'], roomId, "spam");
@ -82,8 +88,8 @@ export class BasicFlooding implements IProtection {
} }
// Trim the oldest messages off the user's history if it's getting large // Trim the oldest messages off the user's history if it's getting large
if (forUser.length > MAX_PER_MINUTE * 2) { if (forUser.length > this.maxPerMinute.value * 2) {
forUser.splice(0, forUser.length - (MAX_PER_MINUTE * 2) - 1); forUser.splice(0, forUser.length - (this.maxPerMinute.value * 2) - 1);
} }
} }
} }

View File

@ -26,6 +26,8 @@ export class FirstMessageIsImage implements IProtection {
private justJoined: { [roomId: string]: string[] } = {}; private justJoined: { [roomId: string]: string[] } = {};
private recentlyBanned: string[] = []; private recentlyBanned: string[] = [];
settings = {};
constructor() { constructor() {
} }

View File

@ -15,6 +15,7 @@ limitations under the License.
*/ */
import { Mjolnir } from "../Mjolnir"; import { Mjolnir } from "../Mjolnir";
import { AbstractProtectionSetting } from "./ProtectionSettings";
/** /**
* Represents a protection mechanism of sorts. Protections are intended to be * Represents a protection mechanism of sorts. Protections are intended to be
@ -24,5 +25,6 @@ import { Mjolnir } from "../Mjolnir";
*/ */
export interface IProtection { export interface IProtection {
readonly name: string; readonly name: string;
settings: { [setting: string]: AbstractProtectionSetting<any, any> };
handleEvent(mjolnir: Mjolnir, roomId: string, event: any): Promise<any>; handleEvent(mjolnir: Mjolnir, roomId: string, event: any): Promise<any>;
} }

View File

@ -22,6 +22,8 @@ import config from "../config";
export class MessageIsMedia implements IProtection { export class MessageIsMedia implements IProtection {
settings = {};
constructor() { constructor() {
} }

View File

@ -22,6 +22,8 @@ import config from "../config";
export class MessageIsVoice implements IProtection { export class MessageIsVoice implements IProtection {
settings = {};
constructor() { constructor() {
} }

View File

@ -0,0 +1,120 @@
/*
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.
*/
export class ProtectionSettingValidationError extends Error {};
export class AbstractProtectionSetting<TChange, TValue> {
// the current value of this setting
value: TValue
/*
* Deserialise a value for this setting type from a string
*
* @param data Serialised value
* @returns Deserialised value or undefined if deserialisation failed
*/
fromString(data: string): TChange | undefined {
throw new Error("not Implemented");
}
/*
* Check whether a given value is valid for this setting
*
* @param data Setting value
* @returns Validity of provided value
*/
validate(data: TChange): boolean {
throw new Error("not Implemented");
}
/*
* Store a value in this setting, only to be used after `validate()`
* @param data Validated setting value
*/
setValue(data: TValue) {
this.value = data;
}
}
export class AbstractProtectionListSetting<TChange, TValue> extends AbstractProtectionSetting<TChange, TValue> {
/*
* Add `data` to the current setting value, and return that new object
*
* @param data Value to add to the current setting value
* @returns The potential new value of this setting object
*/
addValue(data: TChange): TValue {
throw new Error("not Implemented");
}
/*
* Remove `data` from the current setting value, and return that new object
*
* @param data Value to remove from the current setting value
* @returns The potential new value of this setting object
*/
removeValue(data: TChange): TValue {
throw new Error("not Implemented");
}
}
export function isListSetting(object: any): object is AbstractProtectionListSetting<any, any> {
return object instanceof AbstractProtectionListSetting;
}
export class StringProtectionSetting extends AbstractProtectionSetting<string, string> {
value = "";
fromString = (data) => data;
validate = (data) => true;
}
export class StringListProtectionSetting extends AbstractProtectionListSetting<string, string[]> {
value: string[] = [];
fromString = (data) => data;
validate = (data) => true;
addValue(data: string): string[] {
return [...this.value, data];
}
removeValue(data: string): string[] {
const index = this.value.indexOf(data);
return this.value.splice(index, index + 1);
}
}
export class NumberProtectionSetting extends AbstractProtectionSetting<number, number> {
min: number|undefined;
max: number|undefined;
constructor(
defaultValue: number,
min: number|undefined = undefined,
max: number|undefined = undefined
) {
super();
this.setValue(defaultValue);
this.min = min;
this.max = max;
}
fromString(data) {
let number = Number(data);
return isNaN(number) ? undefined : number;
}
validate(data) {
return (!isNaN(data)
&& (this.min === undefined || this.min <= data)
&& (this.max === undefined || data <= this.max))
}
}

View File

@ -23,6 +23,8 @@ import { isTrueJoinEvent } from "../utils";
export class WordList implements IProtection { export class WordList implements IProtection {
settings = {};
private justJoined: { [roomId: string]: { [username: string]: Date} } = {}; private justJoined: { [roomId: string]: { [username: string]: Date} } = {};
private badWords: RegExp; private badWords: RegExp;

View File

@ -16,7 +16,7 @@ limitations under the License.
import { FirstMessageIsImage } from "./FirstMessageIsImage"; import { FirstMessageIsImage } from "./FirstMessageIsImage";
import { IProtection } from "./IProtection"; import { IProtection } from "./IProtection";
import { BasicFlooding, MAX_PER_MINUTE } from "./BasicFlooding"; import { BasicFlooding, DEFAULT_MAX_PER_MINUTE } from "./BasicFlooding";
import { WordList } from "./WordList"; import { WordList } from "./WordList";
import { MessageIsVoice } from "./MessageIsVoice"; import { MessageIsVoice } from "./MessageIsVoice";
import { MessageIsMedia } from "./MessageIsMedia"; import { MessageIsMedia } from "./MessageIsMedia";
@ -28,7 +28,7 @@ export const PROTECTIONS: PossibleProtections = {
factory: () => new FirstMessageIsImage(), factory: () => new FirstMessageIsImage(),
}, },
[new BasicFlooding().name]: { [new BasicFlooding().name]: {
description: "If a user posts more than " + MAX_PER_MINUTE + " messages in 60s they'll be " + 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.", "banned for spam. This does not publish the ban to any of your ban lists.",
factory: () => new BasicFlooding(), factory: () => new BasicFlooding(),
}, },

View File

@ -0,0 +1,152 @@
import { strict as assert } from "assert";
import config from "../../src/config";
import { PROTECTIONS } from "../../src/protections/protections";
import { ProtectionSettingValidationError } from "../../src/protections/ProtectionSettings";
import { NumberProtectionSetting, StringProtectionSetting, StringListProtectionSetting } from "../../src/protections/ProtectionSettings";
import { newTestUser, noticeListener } from "./clientHelper";
import { matrixClient, mjolnir } from "./mjolnirSetupUtils";
describe("Test: Protection settings", function() {
let client;
this.beforeEach(async function () {
client = await newTestUser(true);
await client.start();
})
this.afterEach(async function () {
await client.stop();
})
it("Mjolnir refuses to save invalid protection setting values", async function() {
this.timeout(20000);
await assert.rejects(
async () => await this.mjolnir.setProtectionSettings("BasicFloodingProtection", {"maxPerMinute": "soup"}),
ProtectionSettingValidationError
);
});
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.setProtectionSettings("05OVMS", { test: 123 });
assert.equal(
(await this.mjolnir.getProtectionSettings("05OVMS"))["test"],
123
);
});
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.setProtectionSettings("HPUjKN", { test1: 1 });
await this.mjolnir.setProtectionSettings("HPUjKN", { test2: 2 });
const settings = await this.mjolnir.getProtectionSettings("HPUjKN");
assert.equal(settings["test1"], 1);
assert.equal(settings["test2"], 2);
});
it("Mjolnir responds to !set correctly", async 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() };
}
};
let reply = new Promise((resolve, reject) => {
client.on('room.message', noticeListener(this.mjolnir.managementRoomId, (event) => {
if (event.content.body.includes("Changed JY2TPN.test ")) {
resolve(event);
}
}))
});
await client.sendMessage(this.mjolnir.managementRoomId, {msgtype: "m.text", body: "!mjolnir config set JY2TPN.test asd"})
await reply
const settings = await this.mjolnir.getProtectionSettings("JY2TPN");
assert.equal(settings["test"], "asd");
});
it("Mjolnir adds a value to a list setting", async 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() };
}
};
let reply = new Promise((resolve, reject) => {
client.on('room.message', noticeListener(this.mjolnir.managementRoomId, (event) => {
if (event.content.body.includes("Changed r33XyT.test ")) {
resolve(event);
}
}))
});
await client.sendMessage(this.mjolnir.managementRoomId, {msgtype: "m.text", body: "!mjolnir config add r33XyT.test asd"})
await reply
assert.deepEqual(await this.mjolnir.getProtectionSettings("r33XyT"), { "test": ["asd"] });
});
it("Mjolnir removes a value from a list setting", async 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() };
}
};
let reply = new Promise((resolve, reject) => {
let i = 0;
client.on('room.message', noticeListener(this.mjolnir.managementRoomId, (event) => {
if (event.content.body.includes("Changed oXzT0E.test ")) {
if (++i == 2) {
resolve(event);
}
}
}))
});
await client.sendMessage(this.mjolnir.managementRoomId, {msgtype: "m.text", body: "!mjolnir config add oXzT0E.test asd"})
await client.sendMessage(this.mjolnir.managementRoomId, {msgtype: "m.text", body: "!mjolnir config remove oXzT0E.test asd"})
await reply
assert.deepEqual(await this.mjolnir.getProtectionSettings("oXzT0E"), { "test": [] });
});
});