Merge commit 'efe842cc9f6fc67185702169933ecf7408bcb2e8' into MTRNord/draupnir4all

This commit is contained in:
MTRNord
2023-09-13 11:09:13 +02:00
33 changed files with 1030 additions and 737 deletions
+18
View File
@@ -0,0 +1,18 @@
version: 2
updates:
- package-ecosystem: "npm"
directory: "/"
schedule:
interval: "daily"
groups:
development-dependencies:
dependency-type: "development"
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "daily"
groups:
github-actions:
patterns:
- "*"
+3 -3
View File
@@ -18,7 +18,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Check out
uses: actions/checkout@v2
uses: actions/checkout@v4
- name: Unshallow for git describe so we can create version.txt
run: git fetch --prune --unshallow --tags --all --force
- name: Prepare version file
@@ -29,10 +29,10 @@ jobs:
uses: docker/setup-qemu-action@v2.2.0
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2.6.0
uses: docker/setup-buildx-action@v2.10.0
- name: Log in to Docker Hub
uses: docker/login-action@v1
uses: docker/login-action@v2
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
+3 -3
View File
@@ -15,7 +15,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Check out
uses: actions/checkout@v2
uses: actions/checkout@v4
- name: Get release tag
run: echo "RELEASE_VERSION=${GITHUB_REF#refs/*/}" >> $GITHUB_ENV
- name: Unshallow for git describe so we can create version.txt
@@ -28,10 +28,10 @@ jobs:
uses: docker/setup-qemu-action@v2.2.0
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2.6.0
uses: docker/setup-buildx-action@v2.10.0
- name: Log in to Docker Hub
uses: docker/login-action@v1
uses: docker/login-action@v2
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
+6 -6
View File
@@ -15,7 +15,7 @@ jobs:
name: Build & Lint
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/checkout@v4
- name: Specifically use node 18 like in the readme.
uses: actions/setup-node@v3
@@ -28,7 +28,7 @@ jobs:
name: Unit tests
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/checkout@v4
- name: Specifically use node 18 like in the readme.
uses: actions/setup-node@v3
with:
@@ -40,12 +40,12 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 60
steps:
- uses: actions/checkout@v3
- uses: actions/checkout@v4
- uses: actions/setup-node@v3
with:
node-version: '18'
- name: Fetch and build mx-tester (cached across runs)
uses: baptiste0928/cargo-install@v1
uses: baptiste0928/cargo-install@v2
with:
crate: mx-tester
version: "0.3.3"
@@ -62,12 +62,12 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- uses: actions/checkout@v3
- uses: actions/checkout@v4
- uses: actions/setup-node@v3
with:
node-version: '18'
- name: Fetch and build mx-tester (cached across runs)
uses: baptiste0928/cargo-install@v1
uses: baptiste0928/cargo-install@v2
with:
crate: mx-tester
version: "0.3.3"
+63 -6
View File
@@ -4,13 +4,15 @@ Draupnir is licensed under the Cooperative Software License.
The reason for the license is simply because this project
was something that I was previously employed to work on.
I have not decided whether I will accept contributions under the same license
or another license or a CLA yet.
I will welcome advice, but I will not welcome attempts to parsuade me to re-adopt
and relicense under Apache-2.0 unless being offered compensation.
I have not decided whether I will accept contributions under the
same license or another license or a CLA yet.
I will welcome advice, but I will not welcome attempts to parsuade
me to re-adopt and relicense under Apache-2.0 unless being offered
compensation.
The easiest way forwards would be for me to accept individual contributions under
Apache-2.0.
As of now, I am accepting contributions under the Apache-2.0 license
in the same way as Mjolnir. This allows me the option to relicense
Draupnir under Apache-2.0 without needing to chase up all contributors.
## How to contribute
@@ -185,3 +187,58 @@ matrix together all the fragmented communication technologies out there we are
reliant on contributions and collaboration from the community to do so. So
please get involved - and we hope you have as much fun hacking on Matrix as we
do!
## Further notes on license and its relation to business in general
Ultimately most open source software contributions start by gifting
labour without any obligation or transaction.
There is no ethical way to directly sell this labour.
Many so called post open source[^post-open-source] ideas fixate on
finding a way to conduct business in an ethical way,
and this is problematic.
Once you start working within capitalism with capitalism, and exchange
your power and influence over a work to monitize the work itself,
the work will gain inertia and a power of its own that you cannot control.
You will work for the work, for external interests, and these won't
be the interests of your powerless users who you were among to begin with.
It would be extreme, but I am tempted to suggest that by performing a
buisness this way, you are part of an effort
which not only reinforces capitalism but works to make it more
efficient. Effectively working to make capitalism more powerful.
Congratulations.
Another point that is often brought up in these discussions is how
software licensing relies on an appeal to state power, the power of
the law.
Therefore I propose a new licensing model, one which appeals
to the power of public pressure rather than the law.
Such a license would be liberal, allowing incorperation into
proprietary works provided it retained a notice.
However, any work which is used in any way to conduct business must
report all software being used by the buisness with this license,
all turnover made by the buisness, all profit made by the buisness
and an estimation of both profit and turnover made by the buisness in
relation to the collection of software reported.
It is not clear to me how often these figures should be reported
and when, or even where they should be reported to (ideally they could
be found centrally). It is also unclear how to create the legalise
required.
With the information these licenses would provide, public pressure
could then be used to demand reperations for the profits made by
pillaging and destructive businesses.
It is not clear yet how any reperations would be distributed,
probably through some system of
[venture communes](https://wiki.p2pfoundation.net/Venture_Commune).
The idea is to ensure that the developers and users of projects
would not be distracted from providing each other mutual
support and to give them a hope of escaping.
[^post-open-source] https://applied-langua.ge/posts/the-poverty-of-post-open-source.html.
+46 -16
View File
@@ -5,34 +5,59 @@ for more information.
> I offer you the ring, which was burned, laid upon the pyre of Baldr by Odin.
This is a hard fork of [Mjolnir](https://github.com/matrix-org/mjolnir),
This is a continuation and fork of [Mjolnir](https://github.com/matrix-org/mjolnir),
with an entirely new framework for interacting with Matrix written to
overcome some of the burdens there were holding development of mjolnir.
## Status
The command handler is currently being refactored and the syntax will become
incompatible with legacy Mjolnir commands. The UX will be overhauled
as such and we will consider the launch of Draupnir a 2.0.0 release.
The command handler is currently being refactored and some command syntax will become
incompatible with legacy Mjolnir commands.
The UX is being overhauled and Draupnir is slowly moving towards a 2.0.0 release.
As Draupnir heads towards `v2.0.0` releases will appear [here](https://github.com/Gnuxie/Draupnir/releases).
Until `v2.0.0` there will be frequent changes to commands.
As Draupnir heads towards `v2.0.0`, releases will appear [here](https://github.com/Gnuxie/Draupnir/releases).
Until `v2.0.0` there will be frequent changes to commands but all of these
will be noted in the changes for that release.
Migrating from Mjolnir is straightforward, and Draupnir remains backwards compatible.
So it is possible to try Draupnir and still have the option to switch back to Mjolnir.
### Migration
Migrating from Mjolnir is straightforward and requires no manual steps,
migration for your setup is likely as simple as changing your server config to
pull the latest Draupnir docker image instead of a mjolnir one.
Draupnir remains backwards compatible so that it is possible to try Draupnir
and still have the option to switch back to Mjolnir.
Any problems with migration should be reported to our [support room](https://matrix.to/#/#draupnir:matrix.org).
## Features
As an all-in-one moderation tool, it can protect your server from malicious invites, spam
messages, and whatever else you don't want. In addition to server-level protection, Draupnir
is great for communities wanting to protect their rooms without having to use their personal
accounts for moderation.
As an all-in-one moderation tool, Draupnir can protect your community by
applying policies from both your own and community curated policy lists.
The bot by default includes support for bans, redactions, anti-spam, server ACLs, room
directory changes, room alias transfers, account deactivation, room shutdown, and more.
directory changes and room alias transfers.
A Synapse module is also available to apply the same rulesets the bot uses across an entire
homeserver.
Support is also provided for some Synapse admin functions such as account
deactivation and room shutdown.
A Synapse module is also available to apply the same rulesets the bot is watching
across an entire homeserver.
### Differences from Mjolnir
The main difference from Mjolnir is that it is no longer necessary to use
commands for some functions. Banning a user in a protected room from your
Matrix client will cause Draupnir to show a prompt in the management room,
which will offer to add the ban to a policy list.
If you do still wish to use the ban command, please note that users
and other entities that are being banned are now the first argument
to the ban command. It is now also possible to provide only the entity to
Draupnir and have Draupnir prompt you for the policy list and the ban reason.
In general, any command that has been migrated to the new interface will
feature better error messages for common problems and allow admins
to trace the cause of unexpected errors much more easily.
## Setting up
@@ -88,7 +113,12 @@ server can only receive requests from your reverse proxy (e.g. `localhost`).
## Development
TODO. It's a TypeScript project with a linter.
Draupnir is a TypeScript project that depends on the labour of a handful of
developers, testers and users. The code base is in relatively good shape,
and if you would like to contribute or gain an understanding of the workings
of Draupnir, please read our [context document](./docs/context.md).
Once you have done that, go ahead and read our [contributing document](./CONTRIBUTING.md)
### Development and testing with mx-tester
+7 -1
View File
@@ -51,9 +51,11 @@ recordIgnoredInvites: false
# (see verboseLogging to adjust this a bit.)
managementRoom: "#moderators:example.org"
# Deprecated and will be removed in a future version.
# Running with verboseLogging is unsupported.
# Whether Draupnir should log a lot more messages in the room,
# mainly involves "all-OK" messages, and debugging messages for when draupnir checks bans in a room.
verboseLogging: true
verboseLogging: false
# The log level of terminal (or container) output,
# can be one of DEBUG, INFO, WARN and ERROR, in increasing order of importance and severity.
@@ -73,6 +75,10 @@ verifyPermissionsOnStartup: true
# turn on to trial some untrusted configuration or lists.
noop: false
# Whether or not Draupnir should apply `m.room.server_acl` events.
# DO NOT change this to `true` unless you are very confident that you know what you are doing.
disableServerACL: false
# Whether Draupnir should check member lists quicker (by using a different endpoint),
# keep in mind that enabling this will miss invited (but not joined) users.
#
+6 -2
View File
@@ -47,8 +47,12 @@ recordIgnoredInvites: false
# Note: Mjolnir is fairly verbose - expect a lot of messages from it.
managementRoom: "#moderators:localhost:9999"
# Set to false to make the management room a bit quieter.
verboseLogging: true
# Deprecated and will be removed in a future version.
# Running with verboseLogging is unsupported.
# Whether Draupnir should log a lot more messages in the room,
# mainly involves "all-OK" messages, and debugging messages for when draupnir checks bans in a room.
verboseLogging: false
# The log level for the logs themselves. One of DEBUG, INFO, WARN, and ERROR.
# This should be at INFO or DEBUG in order to get support for Mjolnir problems.
+31 -3
View File
@@ -1,9 +1,9 @@
## Context for developing Draupnir
#### And also context that is essential if you are developing anything
that uses Policy Lists.
alternatively context that is essential for developing
anything that uses Policy Lists.
### Sync loop
### The synchronisation loop
In order to understand how Draupnir works you have to first understand
the sync loop of Matrix Clients. All Matrix clients have a sync loop.
@@ -122,6 +122,34 @@ begin synchronising policies with with the protected rooms.
Draupnir starts synchronising rooms by visiting the most recently
active room first.
### A history of moderation projects
Mjolnir was originally created by
[Travis Ralston](https://github.com/turt2live) as a good enough
solution temprarily made permanent.
The abstract architecture of Mjolnir remains today and we are
thankful for good foundations, and significantly
[policies](https://spec.matrix.org/latest/client-server-api/#moderation-policy-lists)
that were
[proposed](https://github.com/matrix-org/matrix-spec-proposals/pull/2313)
by [Matthew Hodgson](https://github.com/ara4n).
There were several other similar solutions known to us that were
developed and deployed at the same time as Mjolnir in the earlier days
and either directly or indirectly had influence on things to come.
Notably [Fly Swatter](https://github.com/serra-allgood/matrix-fly-swatter)
and [Luna](https://gitlab.com/Gnuxie/luna).
After a period of maintenance, Mjolnir was then developed by other
contributors from Element who restructured the project, tackled
usability concerns and would go on to produce a multi-tenancy
appservice mode of deployment called "Mjolnir for all".
With the eventual aim of integrating the functions of Mjolnir
transparently with both homeservers and clients.
This effort is now continued by the Matrix community in the form
of Draupnir and [MTRNord](https://github.com/MTRNord)'s
[Draupnir4all deployment](https://docs.draupnir.midnightthoughts.space/).
[^full-state]: matrix-bot-sdk could be modified to sync with
`full_state` set to true. This has been
+15 -15
View File
@@ -1,6 +1,6 @@
{
"name": "draupnir",
"version": "1.84.0",
"version": "1.85.1",
"description": "A moderation tool for Matrix",
"main": "lib/index.js",
"repository": "https://github.com/MTRNord/Draupnir.git",
@@ -15,34 +15,34 @@
"lint": "tslint --project ./tsconfig.json -t stylish",
"start:dev": "yarn build && node --async-stack-traces lib/index.js",
"test": "ts-mocha --project ./tsconfig.json test/commands/**/*.ts",
"test:integration": "NODE_ENV=harness ts-mocha --async-stack-traces --require test/integration/fixtures.ts --timeout 300000 --project ./tsconfig.json \"test/integration/**/*Test.ts\"",
"test:integration": "NODE_ENV=harness ts-mocha --async-stack-traces --forbid-only --require test/integration/fixtures.ts --timeout 300000 --project ./tsconfig.json \"test/integration/**/*Test.ts\"",
"test:integration:single": "NODE_ENV=harness npx ts-mocha --require test/integration/fixtures.ts --timeout 300000 --project ./tsconfig.json",
"test:appservice:integration": "NODE_ENV=harness ts-mocha --async-stack-traces --timeout 300000 --project ./tsconfig.json \"test/appservice/integration/**/*Test.ts\"",
"test:appservice:integration": "NODE_ENV=harness ts-mocha --async-stack-traces --forbid-only --timeout 300000 --project ./tsconfig.json \"test/appservice/integration/**/*Test.ts\"",
"test:appservice:integration:single": "NODE_ENV=harness npx ts-mocha --timeout 300000 --project ./tsconfig.json",
"test:manual": "NODE_ENV=harness ts-node test/integration/manualLaunchScript.ts",
"version": "sed -i '/# version automated/s/[0-9][0-9]*\\.[0-9][0-9]*\\.[0-9][^\"]*/'$npm_package_version'/' synapse_antispam/setup.py && git add synapse_antispam/setup.py && cat synapse_antispam/setup.py"
},
"devDependencies": {
"@types/config": "^3.3.0",
"@types/crypto-js": "^4.0.2",
"@types/config": "^3.3.1",
"@types/crypto-js": "^4.1.2",
"@types/express": "^4.17.13",
"@types/html-to-text": "^8.0.1",
"@types/humanize-duration": "^3.27.1",
"@types/js-yaml": "^4.0.5",
"@types/jsdom": "^16.2.11",
"@types/mocha": "^9.0.0",
"@types/nedb": "^1.8.12",
"@types/node": "^18.16.7",
"@types/mocha": "^10.0.1",
"@types/nedb": "^1.8.13",
"@types/node": "^20.6.0",
"@types/pg": "^8.6.5",
"@types/request": "^2.48.8",
"@types/shell-quote": "1.7.1",
"crypto-js": "^4.1.1",
"eslint": "^7.32",
"expect": "^27.0.6",
"mocha": "^9.0.1",
"ts-mocha": "^9.0.2",
"eslint": "^8.49",
"expect": "^29.6.4",
"mocha": "^10.2.0",
"ts-mocha": "^10.0.0",
"tslint": "^6.1.3",
"typescript": "^5.1.6",
"typescript": "^5.2.2",
"typescript-formatter": "^7.2"
},
"dependencies": {
@@ -57,7 +57,7 @@
"@sentry/tracing": "^7.17.2",
"await-lock": "^2.2.2",
"body-parser": "^1.20.1",
"config": "^3.3.8",
"config": "^3.3.9",
"express": "^4.17",
"html-to-text": "^8.0.0",
"humanize-duration": "^3.27.1",
@@ -69,7 +69,7 @@
"pg": "^8.8.0",
"shell-quote": "^1.7.3",
"ulidx": "^0.3.0",
"yaml": "^2.2.2"
"yaml": "^2.3.2"
},
"overrides": {
"matrix-appservice-bridge": {
-93
View File
@@ -1,93 +0,0 @@
/**
* Copyright (C) 2022 Gnuxie <Gnuxie@protonmail.com>
* All rights reserved.
*
* This file is modified and is NOT licensed under the Apache License.
* This modified file incorperates work from mjolnir
* https://github.com/matrix-org/mjolnir
* which included the following license notice:
Copyright 2019 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.
*
* However, this file is modified and the modifications in this file
* are NOT distributed, contributed, committed, or licensed under the Apache License.
*/
import { trace } from "./utils";
export const ERROR_KIND_PERMISSION = "permission";
export const ERROR_KIND_FATAL = "fatal";
const TRIGGER_INTERVALS: { [key: string]: number } = {
[ERROR_KIND_PERMISSION]: 3 * 60 * 60 * 1000, // 3 hours
[ERROR_KIND_FATAL]: 15 * 60 * 1000, // 15 minutes
};
/**
* The ErrorCache is used to suppress the same error messages for the same error state.
* An example would be when mjolnir has been told to protect a room but is missing some permission such as the ability to send `m.room.server_acl`.
* Each time `Mjolnir` synchronizes policies to protected rooms Mjolnir will try to log to the management room that Mjolnir doesn't have permission to send `m.room.server_acl`.
* The ErrorCache is an attempt to make sure the error is reported only once.
*/
export default class ErrorCache {
private roomsToErrors: Map<string/*room id*/, Map<string /*error kind*/, number>> = new Map();
constructor() {
}
/**
* Reset the error cache for a room/kind in the situation where circumstances have changed e.g. if Mjolnir has been informed via sync of a `m.room.power_levels` event in the room, we would want to clear `ERROR_KIND_PERMISSION`
* so that a user can see if their changes worked.
* @param roomId The room to reset the error cache for.
* @param kind The kind of error we are resetting.
*/
@trace
public resetError(roomId: string, kind: string) {
if (!this.roomsToErrors.has(roomId)) {
this.roomsToErrors.set(roomId, new Map());
}
this.roomsToErrors.get(roomId)?.set(kind, 0);
}
/**
* Register the error with the cache.
* @param roomId The room where the error is occuring or related to.
* @param kind What kind of error, either `ERROR_KIND_PERMISSION` or `ERROR_KIND_FATAL`.
* @returns True if the error kind has been triggered in that room,
* meaning it has been longer than the time specified in `TRIGGER_INTERVALS` since the last trigger (or the first trigger). Otherwise false.
*/
@trace
public triggerError(roomId: string, kind: string): boolean {
if (!this.roomsToErrors.get(roomId)) {
this.roomsToErrors.set(roomId, new Map());
}
const triggers = this.roomsToErrors.get(roomId)!;
if (!triggers.has(kind)) {
triggers?.set(kind, 0);
}
const lastTriggerTime = triggers.get(kind)!;
const now = new Date().getTime();
const interval = TRIGGER_INTERVALS[kind];
if ((now - lastTriggerTime) >= interval) {
triggers.set(kind, now);
return true;
} else {
return false;
}
}
}
+3
View File
@@ -339,6 +339,9 @@ export class Mjolnir {
this.currentState = STATE_RUNNING;
await this.managementRoomOutput.logMessage(LogLevel.INFO, "Mjolnir@startup", "Startup complete. Now monitoring rooms.");
if (this.config.verboseLogging) {
await this.managementRoomOutput.logMessage(LogLevel.WARN, "Mjolnir@startup", "The use of verbose logging is deprecated and will be removed in a future version, check your config.");
}
} catch (err) {
try {
LogService.error("Mjolnir", "Error during startup:", err);
+58 -94
View File
@@ -25,16 +25,15 @@ limitations under the License.
* are NOT distributed, contributed, committed, or licensed under the Apache License.
*/
import { LogLevel, LogService, MatrixGlob, UserID } from "matrix-bot-sdk";
import { Permalinks } from "./commands/interface-manager/Permalinks";
import { LogLevel, MatrixGlob, UserID } from "matrix-bot-sdk";
import { CommandExceptionKind } from "./commands/interface-manager/CommandException";
import { IConfig } from "./config";
import ErrorCache, { ERROR_KIND_FATAL, ERROR_KIND_PERMISSION } from "./ErrorCache";
import ManagementRoomOutput from "./ManagementRoomOutput";
import { MatrixSendClient } from "./MatrixEmitter";
import AccessControlUnit, { Access } from "./models/AccessControlUnit";
import { RULE_ROOM, RULE_SERVER, RULE_USER } from "./models/ListRule";
import PolicyList, { ListRuleChange, Revision } from "./models/PolicyList";
import { RoomUpdateError } from "./models/RoomUpdateError";
import { printActionResult, IRoomUpdateError, RoomUpdateException } from "./models/RoomUpdateError";
import { ProtectionManager } from "./protections/ProtectionManager";
import { EventRedactionQueue, RedactUserInRoom } from "./queues/EventRedactionQueue";
import { ProtectedRoomActivityTracker } from "./queues/ProtectedRoomActivityTracker";
@@ -75,8 +74,6 @@ export class ProtectedRoomsSet {
*/
private readonly eventRedactionQueue = new EventRedactionQueue();
private readonly errorCache = new ErrorCache();
/**
* These are globs sourced from `config.automaticallyRedactForReasons` that are matched against the reason of an
* `m.ban` recommendation against a user.
@@ -191,7 +188,7 @@ export class ProtectedRoomsSet {
* @returns The list of errors encountered, for reporting to the management room.
*/
@trace
public async processRedactionQueue(roomId?: string): Promise<RoomUpdateError[]> {
public async processRedactionQueue(roomId?: string): Promise<IRoomUpdateError[]> {
return await this.eventRedactionQueue.process(this.client, this.managementRoomOutput, roomId);
}
@@ -213,24 +210,22 @@ export class ProtectedRoomsSet {
}
this.protectedRoomActivityTracker.handleEvent(roomId, event);
if (event['type'] === 'm.room.power_levels' && event['state_key'] === '') {
// power levels were updated - recheck permissions
this.errorCache.resetError(roomId, ERROR_KIND_PERMISSION);
await this.managementRoomOutput.logMessage(LogLevel.DEBUG, "Mjolnir", `Power levels changed in ${roomId} - checking permissions...`, roomId);
const errors = await this.protectionManager.verifyPermissionsIn(roomId);
const hadErrors = await this.printActionResult(errors);
if (!hadErrors) {
await this.managementRoomOutput.logMessage(LogLevel.DEBUG, "Mjolnir", `All permissions look OK.`);
}
await this.printActionResult(errors, { title: "There were errors verifying permissions.", noErrorsText: "All permissions look OK." });
return;
} else if (event['type'] === "m.room.member") {
// The reason we have to apply bans on each member change is because
// we cannot eagerly ban users (that is to ban them when they have never been a member)
// as they can be force joined to a room they might not have known existed.
// Only apply bans and then redactions in the room we are currently looking at.
const banErrors = await this.applyUserBans([roomId]);
const redactionErrors = await this.processRedactionQueue(roomId);
await this.printActionResult(banErrors);
await this.printActionResult(redactionErrors);
const errors = [
await this.applyUserBans([roomId]),
await this.processRedactionQueue(roomId),
].flat();
if (errors.length > 0) {
await this.printActionResult(errors, { title: 'There were errors updating member bans.' });
}
}
}
@@ -239,26 +234,12 @@ export class ProtectedRoomsSet {
*/
@trace
private async syncRoomsWithPolicies() {
let hadErrors = false;
const [aclErrors, banErrors] = await Promise.all([
const errors = (await Promise.all([
this.applyServerAcls(this.policyLists, this.protectedRoomsByActivity()),
this.applyUserBans(this.protectedRoomsByActivity())
]);
const redactionErrors = await this.processRedactionQueue();
hadErrors = hadErrors || await this.printActionResult(aclErrors, "Errors updating server ACLs:");
hadErrors = hadErrors || await this.printActionResult(banErrors, "Errors updating member bans:");
hadErrors = hadErrors || await this.printActionResult(redactionErrors, "Error updating redactions:");
if (!hadErrors) {
const html = `<font color="#00cc00">Done updating rooms - no errors</font>`;
const text = "Done updating rooms - no errors";
await this.client.sendMessage(this.managementRoomId, {
msgtype: "m.notice",
body: text,
format: "org.matrix.custom.html",
formatted_body: html,
});
}
this.applyUserBans(this.protectedRoomsByActivity()),
this.processRedactionQueue()
])).flat();
await this.printActionResult(errors, { title: "There were errors synchronising the protected rooms." });
}
/**
@@ -324,9 +305,12 @@ export class ProtectedRoomsSet {
* @param {Mjolnir} mjolnir The Mjolnir client to apply the ACLs with.
*/
@trace
private async applyServerAcls(lists: PolicyList[], roomIds: string[]): Promise<RoomUpdateError[]> {
private async applyServerAcls(lists: PolicyList[], roomIds: string[]): Promise<IRoomUpdateError[]> {
// we need to provide mutual exclusion so that we do not have requests updating the m.room.server_acl event
// finish out of order and therefore leave the room out of sync with the policy lists.
if (this.config.disableServerACL) {
return [];
}
return new Promise((resolve, reject) => {
this.aclChain = this.aclChain
.then(() => this._applyServerAcls(lists, roomIds))
@@ -334,7 +318,7 @@ export class ProtectedRoomsSet {
});
}
private async _applyServerAcls(lists: PolicyList[], roomIds: string[]): Promise<RoomUpdateError[]> {
private async _applyServerAcls(lists: PolicyList[], roomIds: string[]): Promise<IRoomUpdateError[]> {
const serverName: string = new UserID(await this.client.getUserId()).domain;
// Construct a server ACL first
@@ -346,7 +330,7 @@ export class ProtectedRoomsSet {
await this.client.sendNotice(this.managementRoomId, `Constructed server ACL:\n${JSON.stringify(finalAcl, null, 2)}`);
}
const errors: RoomUpdateError[] = [];
const errors: IRoomUpdateError[] = [];
for (const roomId of roomIds) {
try {
await this.managementRoomOutput.logMessage(LogLevel.DEBUG, "ApplyAcl", `Checking ACLs for ${roomId}`, roomId);
@@ -371,8 +355,8 @@ export class ProtectedRoomsSet {
}
} catch (e) {
const message = e.message || (e.body ? e.body.error : '<no message>');
const kind = message && message.includes("You don't have permission to post that to the room") ? ERROR_KIND_PERMISSION : ERROR_KIND_FATAL;
errors.push({ roomId, errorMessage: message, errorKind: kind });
const kind = message && message.includes("You don't have permission to post that to the room") ? CommandExceptionKind.Known : CommandExceptionKind.Unknown;
errors.push(new RoomUpdateException(roomId, kind, e, message))
}
}
return errors;
@@ -385,17 +369,18 @@ export class ProtectedRoomsSet {
* @param {Mjolnir} mjolnir The Mjolnir client to apply the bans with.
*/
@trace
private async applyUserBans(roomIds: string[]): Promise<RoomUpdateError[]> {
private async applyUserBans(roomIds: string[]): Promise<IRoomUpdateError[]> {
// We can only ban people who are not already banned, and who match the rules.
const errors: RoomUpdateError[] = [];
const errors: IRoomUpdateError[] = [];
const addErrorToReport = (roomId: string, e: any) => {
const message = e.message || (e.body ? e.body.error : '<no message>');
errors.push({
errors.push(new RoomUpdateException(
roomId,
errorMessage: message,
errorKind: message && message.includes("You don't have permission to ban") ? ERROR_KIND_PERMISSION : ERROR_KIND_FATAL,
});
message && message.includes("You don't have permission to ban") ? CommandExceptionKind.Known : CommandExceptionKind.Unknown,
e,
message
));
};
for (const roomId of roomIds) {
@@ -493,42 +478,29 @@ export class ProtectedRoomsSet {
return true;
}
private async printActionResult(errors: RoomUpdateError[], title: string | null = null, logAnyways = false) {
if (errors.length <= 0) return false;
private async printActionResult(
errors: IRoomUpdateError[],
renderOptions: { title?: string, noErrorsText?: string }
): Promise<void> {
await printActionResult(this.client, this.managementRoomId, errors, renderOptions);
}
if (!logAnyways) {
errors = errors.filter(e => this.errorCache.triggerError(e.roomId, e.errorKind));
if (errors.length <= 0) {
LogService.warn("Mjolnir", "Multiple errors are happening, however they are muted. Please check the management room.");
return true;
public async unbanUser(user: string): Promise<IRoomUpdateError[]> {
const errors: IRoomUpdateError[] = [];
for (const room of this.protectedRoomActivityTracker.protectedRoomsByActivity()) {
try {
await this.client.unbanUser(user, room);
} catch (e) {
const message = e.message || (e.body ? e.body.error : '<no message>');
errors.push(new RoomUpdateException(
room,
message && message.includes("You don't have permission to ban") ? CommandExceptionKind.Known : CommandExceptionKind.Unknown,
e,
message
));
}
}
let html = "";
let text = "";
const htmlTitle = title ? `${title}<br />` : '';
const textTitle = title ? `${title}\n` : '';
html += `<font color="#ff0000"><b>${htmlTitle}${errors.length} errors updating protected rooms!</b></font><br /><ul>`;
text += `${textTitle}${errors.length} errors updating protected rooms!\n`;
const viaServers = [(new UserID(await this.client.getUserId())).domain];
for (const error of errors) {
const alias = (await this.client.getPublishedAlias(error.roomId)) || error.roomId;
const url = Permalinks.forRoom(alias, viaServers);
html += `<li><a href="${url}">${alias}</a> - ${error.errorMessage}</li>`;
text += `${url} - ${error.errorMessage}\n`;
}
html += "</ul>";
const message = {
msgtype: "m.notice",
body: text,
format: "org.matrix.custom.html",
formatted_body: html,
};
await this.client.sendMessage(this.managementRoomId, message);
return true;
return errors;
}
public requiredProtectionPermissions() {
@@ -536,22 +508,14 @@ export class ProtectedRoomsSet {
}
@trace
public async verifyPermissions(verbose = true, printRegardless = false) {
const errors: RoomUpdateError[] = [];
public async verifyPermissions() {
const errors: IRoomUpdateError[] = [];
for (const roomId of this.protectedRooms) {
errors.push(...(await this.protectionManager.verifyPermissionsIn(roomId)));
}
const hadErrors = await this.printActionResult(errors, "Permission errors in protected rooms:", printRegardless);
if (!hadErrors && verbose) {
const html = `<font color="#00cc00">All permissions look OK.</font>`;
const text = "All permissions look OK.";
await this.client.sendMessage(this.managementRoomId, {
msgtype: "m.notice",
body: text,
format: "org.matrix.custom.html",
formatted_body: html,
});
}
await this.printActionResult(errors, {
title: "There are permission errors in protected rooms.",
noErrorsText: "All permissions look OK."
});
}
}
+2 -2
View File
@@ -48,7 +48,7 @@ import { BaseFunction, CommandTable, defineCommandTable, findCommandTable, findT
import { findMatrixInterfaceAdaptor, MatrixContext } from "./interface-manager/MatrixInterfaceAdaptor";
import { ArgumentStream } from "./interface-manager/ParameterParsing";
import { CommandResult } from "./interface-manager/Validation";
import { CommandException } from "./interface-manager/CommandException";
import { CommandException, CommandExceptionKind } from "./interface-manager/CommandException";
import { tickCrossRenderer } from "./interface-manager/MatrixHelpRenderer";
import "./interface-manager/MatrixPresentations";
@@ -133,7 +133,7 @@ export async function handleCommand(roomId: string, event: { content: { body: st
try {
return await adaptor.invoke(mjolnirContext, mjolnirContext, ...stream.rest());
} catch (e) {
const commandError = new CommandException(e, 'Unknown Unexpected Error');
const commandError = new CommandException(CommandExceptionKind.Unknown, e, 'Unknown Unexpected Error');
await tickCrossRenderer.call(mjolnirContext, mjolnir.client, roomId, event, CommandResult.Err(commandError));
}
}
+1 -1
View File
@@ -29,5 +29,5 @@ import { Mjolnir } from "../Mjolnir";
// !mjolnir verify
export async function execPermissionCheckCommand(roomId: string, event: any, mjolnir: Mjolnir) {
return mjolnir.protectedRoomsTracker.verifyPermissions(true, true);
return mjolnir.protectedRoomsTracker.verifyPermissions();
}
+6 -3
View File
@@ -30,7 +30,7 @@ import { findPresentationType, parameters } from "./interface-manager/ParameterP
import { MjolnirContext } from "./CommandHandler";
import { MatrixRoomID, MatrixRoomReference } from "./interface-manager/MatrixRoomReference";
import { CommandResult } from "./interface-manager/Validation";
import { CommandException } from "./interface-manager/CommandException";
import { CommandException, CommandExceptionKind } from "./interface-manager/CommandException";
import { defineMatrixInterfaceAdaptor } from "./interface-manager/MatrixInterfaceAdaptor";
import { tickCrossRenderer } from "./interface-manager/MatrixHelpRenderer";
import { DocumentNode } from "./interface-manager/DeadDocument";
@@ -92,7 +92,7 @@ defineInterfaceCommand({
return CommandException.Result<MatrixRoomID>(
`The homeserver that Draupnir is hosted on cannot join this room using the room reference provided.\
Try an alias or the "share room" button in your client to obtain a valid reference to the room.`,
{ exception: e }
{ exception: e, exceptionKind: CommandExceptionKind.Unknown }
);
}
})();
@@ -121,7 +121,10 @@ defineInterfaceCommand({
try {
await this.mjolnir.client.leaveRoom(roomID.toRoomIdOrAlias());
} catch (exception) {
return CommandException.Result(`Failed to leave ${roomRef.toPermalink()} - the room is no longer being protected, but the bot could not leave.`, { exception });
return CommandException.Result(
`Failed to leave ${roomRef.toPermalink()} - the room is no longer being protected, but the bot could not leave.`,
{ exceptionKind: CommandExceptionKind.Unknown, exception }
);
}
return CommandResult.Ok(undefined);
},
+1 -1
View File
@@ -60,7 +60,7 @@ async function renderListMatches(
)
}
function renderListRules(list: ListMatches) {
export function renderListRules(list: ListMatches) {
const renderRuleSummary = (rule: ListRule, entityDescription: string) => {
return <li>
{entityDescription} (<code>{rule.recommendation}</code>): <code>{rule.entity}</code> ({rule.reason})
@@ -6,6 +6,22 @@ import { randomUUID } from "crypto";
import * as api from '@opentelemetry/api';
import { CommandError, CommandResult } from "./Validation";
import { trace } from "../../utils";
import { LogService } from "matrix-bot-sdk";
export enum CommandExceptionKind {
/**
* This class is for exceptions that need to be reported to the user,
* but are mostly irrelevant to the developers because the behaviour is well
* understood and expected. These exceptions will never be logged to the error
* level.
*/
Known = 'Known',
/**
* This class is to be used for reporting unexpected or unknown exceptions
* that the developers need to know about.
*/
Unknown = 'Unknown',
}
// FIXME: I wonder if we could allow message to be JSX?
// Then room references could be put into the DM and actually mean something.
@@ -13,13 +29,25 @@ export class CommandException extends CommandError {
public readonly uuid: string = api.trace.getSpan(api.context.active())?.spanContext().traceId ?? randomUUID();
constructor(
public readonly exceptionKind: CommandExceptionKind,
public readonly exception: Error | unknown,
message: string) {
super(message)
this.log();
}
@trace
public static Result<Ok>(message: string, options: { exception: Error }): CommandResult<Ok, CommandException> {
return CommandResult.Err(new CommandException(options.exception, message));
public static Result<Ok>(message: string, options: { exception: Error, exceptionKind: CommandExceptionKind }): CommandResult<Ok, CommandException> {
return CommandResult.Err(new CommandException(options.exceptionKind, options.exception, message));
}
@trace
protected log(): void {
const logArguments: Parameters<typeof LogService['info']> = ["CommandException", this.exceptionKind, this.uuid, this.message, this.exception];
if (this.exceptionKind === CommandExceptionKind.Known) {
LogService.info(...logArguments);
} else {
LogService.error(...logArguments);
}
}
}
@@ -67,6 +67,7 @@ export enum NodeTag {
Fragment = 'fragment',
Details = 'details',
Summary = 'summary',
Font = 'font',
}
/**
@@ -4,9 +4,26 @@
*/
import { htmlEscape } from "../../utils";
import { FringeLeafRenderFunction, FringeType, LeafNode, NodeTag, SimpleFringeRenderer } from "./DeadDocument";
import { DocumentNode, FringeLeafRenderFunction, FringeType, LeafNode, NodeTag, SimpleFringeRenderer, TagDynamicEnvironment } from "./DeadDocument";
import { blank, staticString, TransactionalOutputContext } from "./DeadDocumentMarkdown";
function writeAttributableNode(tagName: string, _fringe: FringeType, node: DocumentNode, context: TransactionalOutputContext, _environment: TagDynamicEnvironment) {
context.output.writeString(`<${tagName}`);
if (node.attributeMap.size > 0) {
for (const [key, value] of node.attributeMap.entries()) {
context.output.writeString(` ${htmlEscape(key)}="${htmlEscape(value)}"`);
}
}
context.output.writeString('>')
}
function attributableNode(tagName: string) {
return function(fringe: FringeType, node: DocumentNode, context: TransactionalOutputContext, environment: TagDynamicEnvironment) {
writeAttributableNode(tagName, fringe, node, context, environment);
}
}
export const HTML_RENDERER = new SimpleFringeRenderer<TransactionalOutputContext>();
HTML_RENDERER.registerRenderer<FringeLeafRenderFunction<TransactionalOutputContext>>(
@@ -52,16 +69,11 @@ HTML_RENDERER.registerRenderer<FringeLeafRenderFunction<TransactionalOutputConte
staticString('<i>'),
staticString('</i>')
).registerInnerNode(NodeTag.Anchor,
function(_fringe, node, context, _environment) {
context.output.writeString('<a');
if (node.attributeMap.size > 0) {
for (const [key, value] of node.attributeMap.entries()) {
context.output.writeString(` ${htmlEscape(key)}="${htmlEscape(value)}"`);
}
}
context.output.writeString('>')
},
attributableNode('a'),
staticString('</a>')
).registerInnerNode(NodeTag.Font,
attributableNode('font'),
staticString('</font>')
).registerInnerNode(NodeTag.Root,
blank,
blank
@@ -142,4 +142,7 @@ MARKDOWN_RENDERER.registerRenderer<FringeLeafRenderFunction<TransactionalOutputC
).registerInnerNode(NodeTag.Summary,
staticString('<summary>'),
staticString('</summary>')
).registerInnerNode(NodeTag.Font,
blank,
blank
);
@@ -31,6 +31,7 @@ export async function renderMatrix(node: DocumentNode, cb: SendMatrixEventCB): P
context.output.commit(commitNode);
};
if (node.tag !== NodeTag.Root) {
// rendering has to start (and end) with a committable node.
throw new TypeError("Tried to render a node without a root, this will not be committable");
}
const markdownOutput = new PagedDuplexStream();
+1 -1
View File
@@ -95,7 +95,7 @@ export class CommandError {
public constructor(
public readonly message: string,
) {
// nothing to do.
}
/**
+2
View File
@@ -63,6 +63,7 @@ export interface IConfig {
logMutedModules: string[],
syncOnStartup: boolean;
verifyPermissionsOnStartup: boolean;
disableServerACL: boolean;
noop: boolean;
protectedRooms: string[]; // matrix.to urls
fasterMembershipChecks: boolean;
@@ -158,6 +159,7 @@ const defaultConfig: IConfig = {
syncOnStartup: true,
verifyPermissionsOnStartup: true,
noop: false,
disableServerACL: false,
protectedRooms: [],
fasterMembershipChecks: false,
automaticallyRedactForReasons: ["spam", "advertising"],
-32
View File
@@ -1,32 +0,0 @@
/**
* Copyright (C) 2022 Gnuxie <Gnuxie@protonmail.com>
* All rights reserved.
*
* This file is modified and is NOT licensed under the Apache License.
* This modified file incorperates work from mjolnir
* https://github.com/matrix-org/mjolnir
* which included the following license notice:
Copyright 2019 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.
*
* However, this file is modified and the modifications in this file
* are NOT distributed, contributed, committed, or licensed under the Apache License.
*/
export interface RoomUpdateError {
roomId: string;
errorMessage: string;
errorKind: string;
}
+131
View File
@@ -0,0 +1,131 @@
/**
* Copyright (C) 2022 Gnuxie <Gnuxie@protonmail.com>
* All rights reserved.
*
* This file is modified and is NOT licensed under the Apache License.
* This modified file incorperates work from mjolnir
* https://github.com/matrix-org/mjolnir
* which included the following license notice:
Copyright 2019 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.
*
* However, this file is modified and the modifications in this file
* are NOT distributed, contributed, committed, or licensed under the Apache License.
*/
import { UserID } from "matrix-bot-sdk";
import { CommandException, CommandExceptionKind } from "../commands/interface-manager/CommandException";
import { DocumentNode } from "../commands/interface-manager/DeadDocument";
import { renderMatrixAndSend } from "../commands/interface-manager/DeadDocumentMatrix";
import { JSXFactory } from "../commands/interface-manager/JSXFactory";
import { Permalinks } from "../commands/interface-manager/Permalinks";
import { CommandError, CommandResult } from "../commands/interface-manager/Validation";
import { MatrixSendClient } from "../MatrixEmitter";
export interface IRoomUpdateError extends CommandError {
readonly roomId: string,
}
export class PermissionError extends CommandError implements IRoomUpdateError {
constructor(
public readonly roomId: string,
message: string
) {
super(message);
}
}
export class RoomUpdateException extends CommandException implements IRoomUpdateError {
constructor(public readonly roomId: string, ...args: ConstructorParameters<typeof CommandException>) {
super(...args);
}
public static Result<Ok>(
message: string,
options: {
exception: Error,
exceptionKind: CommandExceptionKind,
roomId: string
}): CommandResult<Ok, RoomUpdateException> {
return CommandResult.Err(new RoomUpdateException(options.roomId, options.exceptionKind, options.exception, message));
}
}
function renderErrorItem(error: IRoomUpdateError, viaServers: string[]): DocumentNode {
return <li>
<a href={Permalinks.forRoom(error.roomId, viaServers)}>{error.roomId}</a> - {error.message}
</li>
}
/**
* Render a message to show to the user after taking an action in a room or a set of rooms.
* @param client A matrix client.
* @param errors Any errors associated with the action.
* @param options.title To give context about what the action was, shown when there are errors.
* @param options.noErrorsText To show when there are no errors.
* @param options.skipNoErrors is ineffective and does nothing, it is an option for the accompnying `printActionResult`.
* @returns A `DocumentNode` fragment that can be sent to Matrix or incorperated into another message.
*/
export async function renderActionResult(
client: MatrixSendClient,
errors: IRoomUpdateError[],
{ title = 'There were errors updating protected rooms.', noErrorsText = 'Done updating rooms - no errors.'}: { title?: string, noErrorsText?: string } = {}
): Promise<DocumentNode> {
if (errors.length === 0) {
return <fragment><font color="#00cc00">{noErrorsText}</font></fragment>
}
// This is a little unfortunate because for some reason we don't have a way to keep
// room references around that have vias and are meaningful
// there isn't really any easy way to do this :(
const viaServers = [(new UserID(await client.getUserId())).domain];
return <fragment>
<font color="#ff0000">
{title}<br/>
</font>
<details>
<summary>
<font color="#ff0000">
<b>{errors.length} errors updating protected rooms!</b><br/>
</font>
</summary>
<ul>
{errors.map(error => renderErrorItem(error, viaServers))}
</ul>
</details>
</fragment>
}
/**
* Render a message to represent the outcome of an action in an update.
* @param client A matrix client to send a notice with.
* @param roomId The room to send the notice to.
* @param errors Any errors that are a result of the action.
* @param options.title To give context about what the action was, shown when there are errors.
* @param options.noErrorsText To show when there are no errors.
* @returns
*/
export async function printActionResult(
client: MatrixSendClient,
roomId: string,
errors: IRoomUpdateError[],
renderOptions: { title?: string, noErrorsText?: string } = {}
): Promise<void> {
await renderMatrixAndSend(
<root>{await renderActionResult(client, errors, renderOptions)}</root>,
roomId,
undefined,
client,
)
}
+151 -36
View File
@@ -23,6 +23,9 @@ limitations under the License.
*
* However, this file is modified and the modifications in this file
* are NOT distributed, contributed, committed, or licensed under the Apache License.
*
* In addition to the above, I want to add that this protection was inspired by
* a Mjolnir PR that was originally created by Gergő Fándly https://github.com/matrix-org/mjolnir/pull/223
*/
import { Protection } from "./Protection";
@@ -31,18 +34,21 @@ import { LogService } from "matrix-bot-sdk";
import { JSXFactory } from "../commands/interface-manager/JSXFactory";
import { renderMatrixAndSend } from "../commands/interface-manager/DeadDocumentMatrix";
import { renderMentionPill } from "../commands/interface-manager/MatrixHelpRenderer";
import { RULE_USER } from "../models/ListRule";
import { RULE_USER, ListRule } from "../models/ListRule";
import { UserID } from "matrix-bot-sdk";
import { ReactionListener } from "../commands/interface-manager/MatrixReactionHandler";
import { PolicyListManager } from "../models/PolicyListManager";
import { MatrixRoomReference } from "../commands/interface-manager/MatrixRoomReference";
import { findPolicyListFromRoomReference } from "../commands/Ban";
import { trace } from '../utils';
import PolicyList from "../models/PolicyList";
import { renderListRules } from "../commands/Rules";
import { printActionResult, IRoomUpdateError, RoomUpdateException } from "../models/RoomUpdateError";
import { CommandExceptionKind } from "../commands/interface-manager/CommandException";
const PROPAGATION_PROMPT_LISTENER = 'ge.applied-langua.ge.draupnir.ban_propagation';
const BAN_PROPAGATION_PROMPT_LISTENER = 'ge.applied-langua.ge.draupnir.ban_propagation';
const UNBAN_PROPAGATION_PROMPT_LISTENER = 'ge.applied-langua.ge.draupnir.unban_propagation';
function makePolicyListShortcodeReferenceMap(lists: PolicyListManager): Map<string, string> {
return lists.lists.reduce((map, list, index) => (map.set(`${index + 1}.`, list.roomRef), map), new Map())
function makePolicyListShortcodeReferenceMap(lists: PolicyList[]): Map<string, string> {
return lists.reduce((map, list, index) => (map.set(`${index + 1}.`, list.roomRef), map), new Map())
}
// would be nice to be able to use presentation types here idk.
@@ -63,7 +69,7 @@ async function promptBanPropagation(
event: any,
roomId: string
): Promise</*event id*/string> {
const reactionMap = makePolicyListShortcodeReferenceMap(mjolnir.policyListManager);
const reactionMap = makePolicyListShortcodeReferenceMap(mjolnir.policyListManager.lists);
const promptEventId = (await renderMatrixAndSend(
<root>The user {renderMentionPill(event["state_key"], event["content"]?.["displayname"] ?? event["state_key"])} was banned
in <a href={`https://matrix.to/#/${roomId}`}>{roomId}</a> by {new UserID(event["sender"])} for <code>{event["content"]?.["reason"] ?? '<no reason supplied>'}</code>.<br />
@@ -76,7 +82,7 @@ async function promptBanPropagation(
undefined,
mjolnir.client,
mjolnir.reactionHandler.createAnnotation(
PROPAGATION_PROMPT_LISTENER,
BAN_PROPAGATION_PROMPT_LISTENER,
reactionMap,
{
target: event["state_key"],
@@ -88,6 +94,119 @@ async function promptBanPropagation(
return promptEventId;
}
async function promptUnbanPropagation(
mjolnir: Mjolnir,
event: any,
roomId: string,
rulesMatchingUser: Map<PolicyList, ListRule[]>
): Promise</*event id*/string> {
const reactionMap = new Map<string, string>(Object.entries({ 'unban from all': 'unban from all'}));
// shouldn't we warn them that the unban will be futile?
const promptEventId = (await renderMatrixAndSend(
<root>
The user {renderMentionPill(event["state_key"], event["content"]?.["displayname"] ?? event["state_key"])} was unbanned
from the room <a href={`https://matrix.to/#/${roomId}`}>{roomId}</a> by {new UserID(event["sender"])} for <code>{event["content"]?.["reason"] ?? '<no reason supplied>'}</code>.<br/>
However there are rules in Draupnir's watched lists matching this user:
<ul>
{
[...rulesMatchingUser.entries()]
.map(([list, rules]) => <li>{renderListRules({
shortcode: list.listShortcode,
roomRef: list.roomRef,
roomId: list.roomId,
matches: rules
})}</li>)
}
</ul>
Would you like to remove these rules and unban the user from all protected rooms?
</root>,
mjolnir.managementRoomId,
undefined,
mjolnir.client,
mjolnir.reactionHandler.createAnnotation(
UNBAN_PROPAGATION_PROMPT_LISTENER,
reactionMap,
{
target: event["state_key"],
reason: event["content"]?.["reason"],
}
)
)).at(0) as string;
await mjolnir.reactionHandler.addReactionsToEvent(mjolnir.client, mjolnir.managementRoomId, promptEventId, reactionMap);
return promptEventId;
}
interface ListenerContext {
mjolnir: Mjolnir,
}
async function banReactionListener(this: ListenerContext, key: string, item: unknown, context: BanPropagationMessageContext) {
try {
if (typeof item === 'string') {
const listRef = MatrixRoomReference.fromPermalink(item);
const listResult = await findPolicyListFromRoomReference(this.mjolnir, listRef);
if (listResult.isOk()) {
return await listResult.ok.banEntity(RULE_USER, context.target, context.reason);
} else {
LogService.warn("BanPropagation", "Timed out waiting for a response to a room level ban", listResult.err);
return;
}
} else {
throw new TypeError("The ban prompt event's reaction map is malformed.")
}
} catch (e) {
LogService.error('BanPropagation', "Encountered an error while prompting the user for instructions to propagate a room level ban", e);
}
}
async function unbanFromAllLists(mjolnir: Mjolnir, user: string): Promise<IRoomUpdateError[]> {
const errors: IRoomUpdateError[] = [];
for (const list of mjolnir.policyListManager.lists) {
try {
await list.unbanEntity(RULE_USER, user);
} catch (e) {
LogService.info('BanPropagation', `Could not unban ${user} from ${list.roomRef}`, e);
const message = e.message || (e.body ? e.body.error : '<no message>');
errors.push(new RoomUpdateException(
list.roomId,
message.includes("You don't have permission") ? CommandExceptionKind.Known : CommandExceptionKind.Unknown,
e,
message
));
}
}
return errors;
}
async function unbanUserReactionListener(this: ListenerContext, _key: string, item: unknown, context: BanPropagationMessageContext): Promise<void> {
try {
if (item === 'unban from all') {
const listErrors = await unbanFromAllLists(this.mjolnir, context.target);
if (listErrors.length > 0) {
await printActionResult(
this.mjolnir.client,
this.mjolnir.managementRoomId,
listErrors,
{ title: `There were errors unbanning ${context.target} from all lists.`}
);
} else {
const unbanErrors = await this.mjolnir.protectedRoomsTracker.unbanUser(context.target);
await printActionResult(
this.mjolnir.client,
this.mjolnir.managementRoomId,
unbanErrors,
{
title: `There were errors unbanning ${context.target} from protected rooms.`,
noErrorsText: `Done unbanning ${context.target} from protected rooms - no errors.`
}
);
}
}
} catch (e) {
LogService.error(`BanPropagationProtection`, "Unexpected error unbanning a user", e);
}
}
export class BanPropagation extends Protection {
settings = {};
@@ -103,40 +222,36 @@ export class BanPropagation extends Protection {
@trace
public async registerProtection(mjolnir: Mjolnir): Promise<void> {
const listener: ReactionListener = async (key, item, context: BanPropagationMessageContext) => {
try {
if (typeof item === 'string') {
const listRef = MatrixRoomReference.fromPermalink(item);
const listResult = await findPolicyListFromRoomReference(mjolnir, listRef);
if (listResult.isOk()) {
return listResult.ok.banEntity(RULE_USER, context.target, context.reason);
} else {
LogService.warn("BanPropagation", "Timed out waiting for a response to a room level ban", listResult.err);
return;
}
} else {
throw new TypeError("The ban prompt event's reaction map is malformed.")
}
} catch (e) {
LogService.error('BanPropagation', "Encountered an error while prompting the user for instructions to propagate a room level ban", e);
}
};
mjolnir.reactionHandler.on(PROPAGATION_PROMPT_LISTENER, listener);
mjolnir.reactionHandler.on(BAN_PROPAGATION_PROMPT_LISTENER, banReactionListener.bind({ mjolnir }));
mjolnir.reactionHandler.on(UNBAN_PROPAGATION_PROMPT_LISTENER, unbanUserReactionListener.bind({ mjolnir }));
}
@trace
public async handleEvent(mjolnir: Mjolnir, roomId: string, event: any): Promise<any> {
if (event['type'] !== 'm.room.member' || event['content']?.['membership'] !== 'ban') {
if (event['type'] !== 'm.room.member'
|| !(event['content']?.['membership'] === 'ban' || event['content']?.['membership'] === 'leave')) {
return;
}
if (mjolnir.policyListManager.lists.map(
list => list.rulesMatchingEntity(event['state_key'], RULE_USER)
).some(rules => rules.length > 0)
) {
return; // The user is already banned.
const rulesMatchingUser = mjolnir.policyListManager.lists.reduce(
(listMap, list) => {
const rules = list.rulesMatchingEntity(event['state_key'], RULE_USER);
if (rules.length > 0) {
listMap.set(list, rules)
};
return listMap
}, new Map<PolicyList, ListRule[]>()
);
const userMembership = event['content']?.['membership'];
if (userMembership === 'ban') {
if (rulesMatchingUser.size > 0) {
return; // The user is already banned.
}
// do not await, we don't want to block the protection manager
promptBanPropagation(mjolnir, event, roomId)
} else if (userMembership === 'leave' && rulesMatchingUser.size > 0) {
// Then this is a banned user being unbanned.
// do not await, we don't want to block the protection manager
promptUnbanPropagation(mjolnir, event, roomId, rulesMatchingUser);
}
// do not await, we don't want to block the protection manager
promptBanPropagation(mjolnir, event, roomId)
}
}
+15 -38
View File
@@ -39,11 +39,11 @@ import { LogLevel, LogService } from "matrix-bot-sdk";
import { ProtectionSettingValidationError } from "./ProtectionSettings";
import { Consequence } from "./consequence";
import { htmlEscape, trace } from "../utils";
import { ERROR_KIND_FATAL, ERROR_KIND_PERMISSION } from "../ErrorCache";
import { RoomUpdateError } from "../models/RoomUpdateError";
import { IRoomUpdateError, PermissionError, RoomUpdateException } from "../models/RoomUpdateError";
import { BanPropagation } from "./BanPropagation";
import { MatrixDataManager, RawSchemedData, SchemaMigration, SCHEMA_VERSION_KEY } from "../models/MatrixDataManager";
import { Permalinks } from "../commands/interface-manager/Permalinks";
import { CommandExceptionKind } from "../commands/interface-manager/CommandException";
const PROTECTIONS: Protection[] = [
new FirstMessageIsImage(),
@@ -403,8 +403,8 @@ export class ProtectionManager {
}
@trace
public async verifyPermissionsIn(roomId: string): Promise<RoomUpdateError[]> {
const errors: RoomUpdateError[] = [];
public async verifyPermissionsIn(roomId: string): Promise<IRoomUpdateError[]> {
const errors: IRoomUpdateError[] = [];
const additionalPermissions = this.requiredProtectionPermissions();
try {
@@ -432,35 +432,21 @@ export class ProtectionManager {
const userLevel = plDefault(users[ownUserId], usersDefault);
const aclLevel = plDefault(events["m.room.server_acl"], stateDefault);
// Wants: ban, kick, redact, m.room.server_acl
const addErrorToReport = (message: string) => {
errors.push(new PermissionError(roomId, message))
}
if (userLevel < ban) {
errors.push({
roomId,
errorMessage: `Missing power level for bans: ${userLevel} < ${ban}`,
errorKind: ERROR_KIND_PERMISSION,
});
addErrorToReport(`Missing power level for bans: ${userLevel} < ${ban}`);
}
if (userLevel < kick) {
errors.push({
roomId,
errorMessage: `Missing power level for kicks: ${userLevel} < ${kick}`,
errorKind: ERROR_KIND_PERMISSION,
});
addErrorToReport(`Missing power level for kicks: ${userLevel} < ${kick}`);
}
if (userLevel < redact) {
errors.push({
roomId,
errorMessage: `Missing power level for redactions: ${userLevel} < ${redact}`,
errorKind: ERROR_KIND_PERMISSION,
});
addErrorToReport(`Missing power level for redactions: ${userLevel} < ${redact}`);
}
if (userLevel < aclLevel) {
errors.push({
roomId,
errorMessage: `Missing power level for server ACLs: ${userLevel} < ${aclLevel}`,
errorKind: ERROR_KIND_PERMISSION,
});
if (!this.mjolnir.config.disableServerACL && userLevel < aclLevel) {
addErrorToReport(`Missing power level for server ACLs: ${userLevel} < ${aclLevel}`);
}
// Wants: Additional permissions
@@ -469,24 +455,15 @@ export class ProtectionManager {
const permLevel = plDefault(events[additionalPermission], stateDefault);
if (userLevel < permLevel) {
errors.push({
roomId,
errorMessage: `Missing power level for "${additionalPermission}" state events: ${userLevel} < ${permLevel}`,
errorKind: ERROR_KIND_PERMISSION,
});
addErrorToReport(`Missing power level for "${additionalPermission}" state events: ${userLevel} < ${permLevel}`);
}
}
// Otherwise OK
} catch (e) {
LogService.error("Mjolnir", e);
errors.push({
roomId,
errorMessage: e.message || (e.body ? e.body.error : '<no message>'),
errorKind: ERROR_KIND_FATAL,
});
const message = `Unexpected error when attempting to verify the permissions in ${roomId}`;
errors.push(new RoomUpdateException(roomId, CommandExceptionKind.Unknown, e, message));
}
return errors;
}
+12 -16
View File
@@ -25,11 +25,11 @@ limitations under the License.
* are NOT distributed, contributed, committed, or licensed under the Apache License.
*/
import { LogLevel, MatrixClient } from "matrix-bot-sdk"
import { ERROR_KIND_FATAL } from "../ErrorCache";
import { RoomUpdateError } from "../models/RoomUpdateError";
import { IRoomUpdateError, RoomUpdateException } from "../models/RoomUpdateError";
import { redactUserMessagesIn, trace } from "../utils";
import ManagementRoomOutput from "../ManagementRoomOutput";
import { MatrixSendClient } from "../MatrixEmitter";
import { CommandExceptionKind } from "../commands/interface-manager/CommandException";
export interface QueuedRedaction {
/** The room which the redaction will take place in. */
@@ -124,25 +124,21 @@ export class EventRedactionQueue {
* @returns A description of any errors encountered by each QueuedRedaction that was processed.
*/
@trace
public async process(client: MatrixSendClient, managementRoom: ManagementRoomOutput, limitToRoomId?: string): Promise<RoomUpdateError[]> {
const errors: RoomUpdateError[] = [];
public async process(client: MatrixSendClient, managementRoom: ManagementRoomOutput, limitToRoomId?: string): Promise<IRoomUpdateError[]> {
const errors: IRoomUpdateError[] = [];
const redact = async (currentBatch: QueuedRedaction[]) => {
for (const redaction of currentBatch) {
try {
await redaction.redact(client, managementRoom);
} catch (e) {
let roomError: RoomUpdateError;
if (e.roomId && e.errorMessage && e.errorKind) {
roomError = e;
} else {
const message = e.message || (e.body ? e.body.error : '<no message>');
roomError = {
roomId: redaction.roomId,
errorMessage: message,
errorKind: ERROR_KIND_FATAL,
};
}
errors.push(roomError);
const message = e.message || (e.body ? e.body.error : '<no message>');
const error = new RoomUpdateException(
redaction.roomId,
CommandExceptionKind.Unknown,
e,
message
);
errors.push(error);
}
}
}
+1 -1
View File
@@ -2,7 +2,7 @@ from setuptools import setup, find_packages
setup(
name="mjolnir",
version="1.84.0", # version automated in package.json - Do not edit this line, use `yarn version`.
version="1.85.1", # version automated in package.json - Do not edit this line, use `yarn version`.
packages=find_packages(),
description="Mjolnir Antispam",
include_package_data=True,
+1 -1
View File
@@ -597,7 +597,7 @@ describe('Test: Creating policy lists.', function() {
})
})
describe.only('Test: Continue to ban other marked members when one member cannot be banned', function() {
describe('Test: Continue to ban other marked members when one member cannot be banned', function() {
it('Failing to ban a moderator should not stop other members being banned.', async function(this: MjolnirTestContext) {
if (this.mjolnir === undefined) {
throw new TypeError("Mjolnir was never created.")
+21
View File
@@ -54,5 +54,26 @@ describe("Ban propagation test", function() {
expect(rules.length).toBe(1);
expect(rules[0].entity).toBe('@test:example.com');
expect(rules[0].reason).toBe('spam');
// now unban them >:3
const unbanPrompt = await getFirstEventMatching({
matrix: mjolnir.matrixEmitter,
targetRoom: mjolnir.managementRoomId,
lookAfterEvent: async function () {
// ban a user in one of our protected rooms using the moderator
await moderator.unbanUser('@test:example.com', protectedRooms[0]);
return undefined;
},
predicate: function (event: any): boolean {
return (event['content']?.['body'] ?? '').startsWith('The user')
}
});
await moderator.unstableApis.addReactionToEvent(
mjolnir.managementRoomId, unbanPrompt['event_id'], 'unban from all'
);
await new Promise(resolve => setTimeout(resolve, 10000));
const rulesAfterUnban = policyList.rulesMatchingEntity('@test:example.com', RULE_USER);
expect(rulesAfterUnban.length).toBe(0);
})
})
+369 -351
View File
File diff suppressed because it is too large Load Diff