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 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 });
|
||||||
|
@ -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" +
|
||||||
|
@ -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]);
|
||||||
|
@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -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() {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -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>;
|
||||||
}
|
}
|
||||||
|
@ -22,6 +22,8 @@ import config from "../config";
|
|||||||
|
|
||||||
export class MessageIsMedia implements IProtection {
|
export class MessageIsMedia implements IProtection {
|
||||||
|
|
||||||
|
settings = {};
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -22,6 +22,8 @@ import config from "../config";
|
|||||||
|
|
||||||
export class MessageIsVoice implements IProtection {
|
export class MessageIsVoice implements IProtection {
|
||||||
|
|
||||||
|
settings = {};
|
||||||
|
|
||||||
constructor() {
|
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 {
|
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;
|
||||||
|
|
||||||
|
@ -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(),
|
||||||
},
|
},
|
||||||
|
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