mirror of
https://github.com/matrix-org/mjolnir.git
synced 2024-10-01 01:36:06 -04:00
runtime persistent settings system (#158)
This commit is contained in:
parent
c7a96a3afe
commit
423a34bebe
@ -36,6 +36,7 @@ import { logMessage } from "./LogProxy";
|
||||
import ErrorCache, { ERROR_KIND_FATAL, ERROR_KIND_PERMISSION } from "./ErrorCache";
|
||||
import { IProtection } from "./protections/IProtection";
|
||||
import { PROTECTIONS } from "./protections/protections";
|
||||
import { ProtectionSettingValidationError } from "./protections/ProtectionSettings";
|
||||
import { UnlistedUserRedactionQueue } from "./queues/UnlistedUserRedactionQueue";
|
||||
import { Healthz } from "./health/healthz";
|
||||
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> {
|
||||
const definition = PROTECTIONS[protectionName];
|
||||
if (!definition) throw new Error("Failed to find protection by name: " + protectionName);
|
||||
@ -408,6 +481,12 @@ export class Mjolnir {
|
||||
const protection = definition.factory();
|
||||
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) {
|
||||
const existing = this.protections.map(p => p.name);
|
||||
await this.client.setAccountData(ENABLED_PROTECTIONS_EVENT_TYPE, { enabled: existing });
|
||||
|
@ -28,7 +28,8 @@ import { execRedactCommand } from "./RedactCommand";
|
||||
import { execImportCommand } from "./ImportCommand";
|
||||
import { execSetDefaultListCommand } from "./SetDefaultBanListCommand";
|
||||
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 { execAddProtectedRoom, execRemoveProtectedRoom } from "./AddRemoveProtectedRoomsCommand";
|
||||
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);
|
||||
} else if (parts[1] === 'disable' && parts.length > 1) {
|
||||
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') {
|
||||
return await execAddProtectedRoom(roomId, event, mjolnir, parts);
|
||||
} 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 enable <protection> - Enables 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 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" +
|
||||
|
@ -14,9 +14,11 @@ See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import * as htmlEscape from "escape-html";
|
||||
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>
|
||||
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>
|
||||
export async function execDisableProtection(roomId: string, event: any, mjolnir: Mjolnir, parts: string[]) {
|
||||
await mjolnir.disableProtection(parts[2]);
|
||||
|
@ -15,12 +15,14 @@ limitations under the License.
|
||||
*/
|
||||
|
||||
import { IProtection } from "./IProtection";
|
||||
import { NumberProtectionSetting } from "./ProtectionSettings";
|
||||
import { Mjolnir } from "../Mjolnir";
|
||||
import { LogLevel, LogService } from "matrix-bot-sdk";
|
||||
import { logMessage } from "../LogProxy";
|
||||
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
|
||||
|
||||
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 recentlyBanned: string[] = [];
|
||||
|
||||
maxPerMinute = new NumberProtectionSetting(DEFAULT_MAX_PER_MINUTE);
|
||||
settings = {};
|
||||
|
||||
constructor() {
|
||||
this.settings['maxPerMinute'] = this.maxPerMinute;
|
||||
}
|
||||
|
||||
public get name(): string {
|
||||
@ -56,7 +62,7 @@ export class BasicFlooding implements IProtection {
|
||||
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);
|
||||
if (!config.noop) {
|
||||
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
|
||||
if (forUser.length > MAX_PER_MINUTE * 2) {
|
||||
forUser.splice(0, forUser.length - (MAX_PER_MINUTE * 2) - 1);
|
||||
if (forUser.length > this.maxPerMinute.value * 2) {
|
||||
forUser.splice(0, forUser.length - (this.maxPerMinute.value * 2) - 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -26,6 +26,8 @@ export class FirstMessageIsImage implements IProtection {
|
||||
private justJoined: { [roomId: string]: string[] } = {};
|
||||
private recentlyBanned: string[] = [];
|
||||
|
||||
settings = {};
|
||||
|
||||
constructor() {
|
||||
}
|
||||
|
||||
|
@ -15,6 +15,7 @@ limitations under the License.
|
||||
*/
|
||||
|
||||
import { Mjolnir } from "../Mjolnir";
|
||||
import { AbstractProtectionSetting } from "./ProtectionSettings";
|
||||
|
||||
/**
|
||||
* Represents a protection mechanism of sorts. Protections are intended to be
|
||||
@ -24,5 +25,6 @@ import { Mjolnir } from "../Mjolnir";
|
||||
*/
|
||||
export interface IProtection {
|
||||
readonly name: string;
|
||||
settings: { [setting: string]: AbstractProtectionSetting<any, any> };
|
||||
handleEvent(mjolnir: Mjolnir, roomId: string, event: any): Promise<any>;
|
||||
}
|
||||
|
@ -22,6 +22,8 @@ import config from "../config";
|
||||
|
||||
export class MessageIsMedia implements IProtection {
|
||||
|
||||
settings = {};
|
||||
|
||||
constructor() {
|
||||
}
|
||||
|
||||
|
@ -22,6 +22,8 @@ import config from "../config";
|
||||
|
||||
export class MessageIsVoice implements IProtection {
|
||||
|
||||
settings = {};
|
||||
|
||||
constructor() {
|
||||
}
|
||||
|
||||
|
120
src/protections/ProtectionSettings.ts
Normal file
120
src/protections/ProtectionSettings.ts
Normal 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))
|
||||
}
|
||||
|
||||
}
|
@ -23,6 +23,8 @@ import { isTrueJoinEvent } from "../utils";
|
||||
|
||||
export class WordList implements IProtection {
|
||||
|
||||
settings = {};
|
||||
|
||||
private justJoined: { [roomId: string]: { [username: string]: Date} } = {};
|
||||
private badWords: RegExp;
|
||||
|
||||
|
@ -16,7 +16,7 @@ limitations under the License.
|
||||
|
||||
import { FirstMessageIsImage } from "./FirstMessageIsImage";
|
||||
import { IProtection } from "./IProtection";
|
||||
import { BasicFlooding, MAX_PER_MINUTE } from "./BasicFlooding";
|
||||
import { BasicFlooding, DEFAULT_MAX_PER_MINUTE } from "./BasicFlooding";
|
||||
import { WordList } from "./WordList";
|
||||
import { MessageIsVoice } from "./MessageIsVoice";
|
||||
import { MessageIsMedia } from "./MessageIsMedia";
|
||||
@ -28,7 +28,7 @@ export const PROTECTIONS: PossibleProtections = {
|
||||
factory: () => new FirstMessageIsImage(),
|
||||
},
|
||||
[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.",
|
||||
factory: () => new BasicFlooding(),
|
||||
},
|
||||
|
152
test/integration/protectionSettingsTest.ts
Normal file
152
test/integration/protectionSettingsTest.ts
Normal 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": [] });
|
||||
});
|
||||
});
|
||||
|
Loading…
Reference in New Issue
Block a user