├── test
├── draupnir_news.json.license
├── tsconfig.json
├── draupnir_news.json
├── tsnode.cjs
├── integration
│ ├── commands
│ │ ├── recoverCommandTest.ts
│ │ ├── shutdownCommandTest.ts
│ │ └── hijackRoomCommandTest.ts
│ ├── manualLaunchScript.ts
│ ├── helloTest.ts
│ ├── throttleTest.ts
│ ├── utilsTest.ts
│ ├── httpAntispamTest.ts
│ └── reportPollingTest.ts
├── unit
│ ├── config
│ │ └── unknownPropertiesTest.ts
│ ├── protections
│ │ ├── MentionLimitProtectionTest.ts
│ │ └── RedactionSynchronisationTest.ts
│ ├── stores
│ │ ├── roomAuditLogTest.ts
│ │ └── userRestrictionAuditLogTest.ts
│ └── commands
│ │ └── WatchUnwatchCommandTest.ts
├── appservice
│ ├── utils
│ │ ├── ProvisionHelper.ts
│ │ ├── harness.ts
│ │ └── webAPIClient.ts
│ └── integration
│ │ ├── listUnstartedDraupnirTest.ts
│ │ ├── safeModeRecoverTest.ts
│ │ ├── safeModeToggleTest.ts
│ │ └── provisionTest.ts
└── nginx.conf
├── src
├── protections
│ ├── DraupnirNews
│ │ ├── news.json.license
│ │ └── news.json
│ ├── RoomTakedown
│ │ ├── SynapseAdminRoomDetails.ts
│ │ ├── RoomAuditLog.ts
│ │ ├── RoomDiscoveryRenderer.tsx
│ │ └── RoomDiscovery.ts
│ ├── Protection.ts
│ ├── invitation
│ │ └── inviteCore.ts
│ ├── HomeserverUserPolicyApplication
│ │ ├── UserRestrictionAuditLog.ts
│ │ ├── UserRestrictionCapability.ts
│ │ └── deactivateUser.ts
│ ├── DraupnirProtectionsIndex.ts
│ ├── ConfigHooks.ts
│ ├── ProtectedRoomsSetRenderers.tsx
│ ├── ConfigMigration
│ │ └── CapabilitySetProviderMigration.ts
│ └── ProtectedRooms
│ │ └── UnprotectPartedRooms.tsx
├── safemode
│ ├── BootOption.ts
│ ├── SafeModeCause.ts
│ ├── commands
│ │ ├── RestartDraupnirCommand.ts
│ │ ├── SafeModeAdaptor.ts
│ │ ├── HelpCommand.tsx
│ │ └── SafeModeCommands.tsx
│ ├── SafeModeToggle.ts
│ ├── RecoveryOptions.tsx
│ ├── SafeModeCommandDispatcher.ts
│ └── ManagementRoom.ts
├── appservice
│ ├── postgres
│ │ ├── schema
│ │ │ ├── v2.ts
│ │ │ └── v1.ts
│ │ └── PgDataStore.ts
│ ├── config
│ │ ├── REUSE.toml
│ │ ├── config.harness.yaml
│ │ └── config.example.yaml
│ ├── bot
│ │ ├── AppserviceBotCommandTable.ts
│ │ ├── AppserviceBotPrerequisite.ts
│ │ ├── AppserviceBotCommands.ts
│ │ ├── AppserviceBotInterfaceAdaptor.ts
│ │ ├── AppserviceBotHelp.tsx
│ │ ├── AccessCommands.tsx
│ │ ├── AppserviceBotCommandDispatcher.ts
│ │ └── ListCommand.tsx
│ ├── datastore.ts
│ └── cli.ts
├── capabilities
│ ├── capabilityIndex.ts
│ ├── RoomTakedownCapability.tsx
│ ├── RendererMessageCollector.ts
│ ├── DraupnirRendererMessageCollector.tsx
│ └── StandardEventConsequencesRenderer.tsx
├── webapis
│ └── SynapseHTTPAntispam
│ │ ├── PingEndpoint.ts
│ │ ├── CheckEventForSpamEndpoint.ts
│ │ ├── UserMayJoinRoomEndpoint.ts
│ │ ├── SpamCheckEndpointPluginManager.ts
│ │ ├── UserMayInviteEndpoint.ts
│ │ └── SynapseHttpAntispam.ts
├── commands
│ ├── DraupnirCommandTable.ts
│ ├── DraupnirCommandPrerequisites.ts
│ ├── SafeModeCommand.ts
│ ├── SetDisplayNameCommand.ts
│ ├── Help.tsx
│ ├── ResolveAlias.tsx
│ ├── server-admin
│ │ ├── UnrestrictCommand.tsx
│ │ ├── HijackRoomCommand.ts
│ │ └── ShutdownRoomCommand.ts
│ ├── CreateBanListCommand.ts
│ ├── SetPowerLevelCommand.ts
│ ├── unban
│ │ └── UnbanEntity.tsx
│ └── DraupnirCommandDispatcher.ts
├── health
│ └── healthz.ts
├── managementroom
│ └── ManagementRoomDetail.ts
├── queues
│ ├── UnlistedUserRedactionQueue.ts
│ ├── ProtectedRoomActivityTracker.ts
│ ├── LeakyBucket.ts
│ └── TimelineRedactionQueue.ts
└── backingstore
│ └── DraupnirStores.ts
├── docs
├── ban-command-prompt.gif
├── ban-propagation-prompt.gif
├── appservice.md
├── setup_docker.md
├── setup_selfbuild.md
├── synapse_module.md
├── setup.md
├── code-style.md
├── triaging.md
├── development.md
├── context.md
├── moderators.md
├── development-environment.md
└── REUSE.toml
├── .dockerignore
├── .gitattributes
├── .git-blame-ignore-revs
├── .github
├── renovate.json5
├── ISSUE_TEMPLATE
│ └── bug-report.md
└── workflows
│ ├── docker-hub-develop.yml
│ ├── docker-hub-latest.yml
│ ├── docker-hub-release.yml
│ ├── mjolnir.yml
│ └── sign-off.yml
├── .prettierrc.yaml
├── CONTRIBUTING.md
├── config
└── REUSE.toml
├── .editorconfig
├── NOTICE
├── tsconfig.json
├── draupnir-entrypoint.sh
├── Dockerfile
├── .pre-commit-config.yaml
├── REUSE.toml
├── .gitignore
└── mx-tester.yml
/test/draupnir_news.json.license:
--------------------------------------------------------------------------------
1 | # SPDX-FileCopyrightText: 2025 Gnuxie
2 | #
3 | # SPDX-License-Identifier: CC0-1.0
4 |
--------------------------------------------------------------------------------
/test/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "../tsconfig.json",
3 | "include": ["."],
4 | "compilerOptions": {
5 | "noEmit": true
6 | }
7 | }
8 |
--------------------------------------------------------------------------------
/src/protections/DraupnirNews/news.json.license:
--------------------------------------------------------------------------------
1 | # SPDX-FileCopyrightText: 2025 Gnuxie
2 | #
3 | # SPDX-License-Identifier: CC0-1.0
4 |
--------------------------------------------------------------------------------
/docs/ban-command-prompt.gif:
--------------------------------------------------------------------------------
1 | version https://git-lfs.github.com/spec/v1
2 | oid sha256:6d3506f39bd50b3e986ba272b14c0c8cc77094f2fe75ab88cca6b3144d63ec5a
3 | size 288619
4 |
--------------------------------------------------------------------------------
/src/protections/RoomTakedown/SynapseAdminRoomDetails.ts:
--------------------------------------------------------------------------------
1 | // SPDX-FileCopyrightText: 2025 Gnuxie
2 | //
3 | // SPDX-License-Identifier: AFL-3.0
4 |
--------------------------------------------------------------------------------
/docs/ban-propagation-prompt.gif:
--------------------------------------------------------------------------------
1 | version https://git-lfs.github.com/spec/v1
2 | oid sha256:0d23fd7a118a4db0ef60e44c70f3c6d258d830cdb01ebb3496cd2971a4ef0e76
3 | size 415683
4 |
--------------------------------------------------------------------------------
/.dockerignore:
--------------------------------------------------------------------------------
1 | # SPDX-FileCopyrightText: 2024 Gnuxie
2 | #
3 | # SPDX-License-Identifier: CC0-1.0
4 |
5 | node_modules
6 | config
7 | lib
8 | logs
9 | storage
10 | db
11 | *.db
12 |
--------------------------------------------------------------------------------
/.gitattributes:
--------------------------------------------------------------------------------
1 | # SPDX-FileCopyrightText: 2024 Aminda Suomalainen
2 | #
3 | # SPDX-License-Identifier: CC0-1.0
4 |
5 | * text=auto eol=lf
6 | *.gif filter=lfs diff=lfs merge=lfs -text
7 |
--------------------------------------------------------------------------------
/.git-blame-ignore-revs:
--------------------------------------------------------------------------------
1 | # SPDX-FileCopyrightText: 2024 Gnuxie
2 | #
3 | # SPDX-License-Identifier: CC0-1.0
4 | # The first time that we ran prettier.
5 | 3b2036c2db205e7f9a10a6b4fef2ec1760b8f51b
6 |
--------------------------------------------------------------------------------
/.github/renovate.json5:
--------------------------------------------------------------------------------
1 | // SPDX-FileCopyrightText: 2024 Aminda Suomalainen
2 | //
3 | // SPDX-License-Identifier: CC0-1.0
4 | {
5 | extends: ["github>the-draupnir-project/.github:renovate-shared"],
6 | }
7 |
--------------------------------------------------------------------------------
/.prettierrc.yaml:
--------------------------------------------------------------------------------
1 | # SPDX-FileCopyrightText: 2024 Gnuxie
2 | #
3 | # SPDX-License-Identifier: CC0-1.0
4 |
5 | tabWidth: 2
6 | useTabs: false
7 | semi: true
8 | trailingComma: "es5"
9 | proseWrap: always
10 |
--------------------------------------------------------------------------------
/docs/appservice.md:
--------------------------------------------------------------------------------
1 |
6 |
7 | This Document has moved to
8 | https://the-draupnir-project.github.io/draupnir-documentation/appservice
9 |
--------------------------------------------------------------------------------
/docs/setup_docker.md:
--------------------------------------------------------------------------------
1 |
6 |
7 | This document has moved to
8 | https://the-draupnir-project.github.io/draupnir-documentation/bot/setup_docker
9 |
--------------------------------------------------------------------------------
/docs/setup_selfbuild.md:
--------------------------------------------------------------------------------
1 |
6 |
7 | This Document has moved to
8 | https://the-draupnir-project.github.io/draupnir-documentation/bot/setup_selfbuild
9 |
--------------------------------------------------------------------------------
/docs/synapse_module.md:
--------------------------------------------------------------------------------
1 |
6 |
7 | This Document has moved to
8 | https://the-draupnir-project.github.io/draupnir-documentation/bot/synapse_module
9 |
--------------------------------------------------------------------------------
/docs/setup.md:
--------------------------------------------------------------------------------
1 |
6 |
7 | # Setting up Draupnir
8 |
9 | This Document has moved to
10 | https://the-draupnir-project.github.io/draupnir-documentation/bot/setup
11 |
--------------------------------------------------------------------------------
/src/safemode/BootOption.ts:
--------------------------------------------------------------------------------
1 | // SPDX-FileCopyrightText: 2024 Gnuxie
2 | //
3 | // SPDX-License-Identifier: AFL-3.0
4 |
5 | export enum SafeModeBootOption {
6 | RecoveryOnly = "RecoveryOnly",
7 | AllErrors = "AllErrors",
8 | Never = "Never",
9 | }
10 |
--------------------------------------------------------------------------------
/docs/code-style.md:
--------------------------------------------------------------------------------
1 |
6 |
7 | # Code style
8 |
9 | This Document has moved to
10 | https://the-draupnir-project.github.io/draupnir-documentation/contributing/code-style
11 |
--------------------------------------------------------------------------------
/docs/triaging.md:
--------------------------------------------------------------------------------
1 |
6 |
7 | # Triaging issues
8 |
9 | This Document has moved to
10 | https://the-draupnir-project.github.io/draupnir-documentation/contributing/triaging
11 |
--------------------------------------------------------------------------------
/docs/development.md:
--------------------------------------------------------------------------------
1 |
6 |
7 | # Developing Draupnir
8 |
9 | This Document has moved to
10 | https://the-draupnir-project.github.io/draupnir-documentation/contributing/development
11 |
--------------------------------------------------------------------------------
/docs/context.md:
--------------------------------------------------------------------------------
1 |
6 |
7 | ## Context for developing Draupnir
8 |
9 | This Document has moved to
10 | https://the-draupnir-project.github.io/draupnir-documentation/contributing/context
11 |
--------------------------------------------------------------------------------
/docs/moderators.md:
--------------------------------------------------------------------------------
1 |
6 |
7 | # Moderator's guide to Draupnir (bot edition)
8 |
9 | This Document has moved to
10 | https://the-draupnir-project.github.io/draupnir-documentation/bot/moderators
11 |
--------------------------------------------------------------------------------
/docs/development-environment.md:
--------------------------------------------------------------------------------
1 |
6 |
7 | # Developing Draupnir - tests, tools, and environment
8 |
9 | This Document has moved to
10 | https://the-draupnir-project.github.io/draupnir-documentation/contributing/development-environment
11 |
--------------------------------------------------------------------------------
/src/appservice/postgres/schema/v2.ts:
--------------------------------------------------------------------------------
1 | // SPDX-FileCopyrightText: 2024 Gnuxie
2 | //
3 | // SPDX-License-Identifier: CC0-1.0
4 |
5 | import postgres from "postgres";
6 |
7 | export async function runSchema(sql: postgres.Sql) {
8 | await sql.begin((s) => [
9 | s`ALTER TABLE IF EXISTS mjolnir RENAME TO draupnir;`,
10 | ]);
11 | }
12 |
--------------------------------------------------------------------------------
/CONTRIBUTING.md:
--------------------------------------------------------------------------------
1 |
6 |
7 | # Contributing to Draupnir
8 |
9 | Our contributing guidelines can be found as part of our documentation.
10 | [This link](https://the-draupnir-project.github.io/draupnir-documentation/contributing)
11 | leads to the contributing guidelines.
12 |
--------------------------------------------------------------------------------
/config/REUSE.toml:
--------------------------------------------------------------------------------
1 | version = 1
2 |
3 | [[annotations]]
4 | path = "*.yaml"
5 | precedence = "aggregate"
6 | SPDX-FileCopyrightText = "Gnuxie "
7 | SPDX-License-Identifier = "CC0-1.0"
8 |
9 | [[annotations]]
10 | path = "REUSE.toml"
11 | precedence = "aggregate"
12 | SPDX-FileCopyrightText = "Gnuxie "
13 | SPDX-License-Identifier = "CC0-1.0"
14 |
--------------------------------------------------------------------------------
/docs/REUSE.toml:
--------------------------------------------------------------------------------
1 | version = 1
2 |
3 | [[annotations]]
4 | path = "*.gif"
5 | precedence = "aggregate"
6 | SPDX-FileCopyrightText = "Gnuxie "
7 | SPDX-License-Identifier = "CC0-1.0"
8 |
9 | [[annotations]]
10 | path = "REUSE.toml"
11 | precedence = "aggregate"
12 | SPDX-FileCopyrightText = "Gnuxie "
13 | SPDX-License-Identifier = "CC0-1.0"
14 |
--------------------------------------------------------------------------------
/src/appservice/config/REUSE.toml:
--------------------------------------------------------------------------------
1 | version = 1
2 |
3 | [[annotations]]
4 | path = "*.yaml"
5 | precedence = "aggregate"
6 | SPDX-FileCopyrightText = "Gnuxie "
7 | SPDX-License-Identifier = "CC0-1.0"
8 |
9 | [[annotations]]
10 | path = "REUSE.toml"
11 | precedence = "aggregate"
12 | SPDX-FileCopyrightText = "Gnuxie "
13 | SPDX-License-Identifier = "CC0-1.0"
14 |
--------------------------------------------------------------------------------
/src/appservice/postgres/schema/v1.ts:
--------------------------------------------------------------------------------
1 | // SPDX-FileCopyrightText: 2024 Gnuxie
2 | //
3 | // SPDX-License-Identifier: CC0-1.0
4 |
5 | import postgres from "postgres";
6 |
7 | export async function runSchema(sql: postgres.Sql) {
8 | await sql.begin((s) => [
9 | s`CREATE TABLE mjolnir (local_part VARCHAR(255), owner VARCHAR(255), management_room TEXT);`,
10 | ]);
11 | }
12 |
--------------------------------------------------------------------------------
/test/draupnir_news.json:
--------------------------------------------------------------------------------
1 | {
2 | "news": [
3 | {
4 | "news_id": "59e0dd6e-87da-4459-98ae-627c0f2a7d8b",
5 | "matrix_event_content": {
6 | "body": "Announcing the Draupnir Longhouse Assembly! https://matrix.to/#/!DtwZFWORUIApKsOVWi:matrix.org/%24GdBN1XqoOnAfc5tJgxhoXNoAdW2YUbS1Mtsb8LbzIJ4?via=matrix.org&via=feline.support&via=asgard.chat",
7 | "msgtype": "m.notice"
8 | }
9 | }
10 | ]
11 | }
12 |
--------------------------------------------------------------------------------
/.editorconfig:
--------------------------------------------------------------------------------
1 | # SPDX-FileCopyrightText: 2024 Aminda Suomalainen
2 | #
3 | # SPDX-License-Identifier: CC0-1.0
4 |
5 | root = true
6 |
7 | [*]
8 | end_of_line = lf
9 | trim_trailing_whitespace = true
10 | insert_final_newline = true
11 | charset = utf-8
12 | indent_style = space
13 | indent_size = 2
14 |
15 | [{LICENSE,NOTICE,*.{md,yml,yaml,json,txt}}]
16 | trim_trailing_whitespace = false
17 | indent_style = space
18 | indent_size = unset
19 |
--------------------------------------------------------------------------------
/src/appservice/bot/AppserviceBotCommandTable.ts:
--------------------------------------------------------------------------------
1 | // SPDX-FileCopyrightText: 2024 Gnuxie
2 | //
3 | // SPDX-License-Identifier: Apache-2.0
4 | //
5 | // SPDX-FileAttributionText:
6 | // This modified file incorporates work from Draupnir
7 | // https://github.com/the-draupnir-project/Draupnir
8 | //
9 |
10 | import { StandardCommandTable } from "@the-draupnir-project/interface-manager";
11 |
12 | export const AppserviceBotCommands = new StandardCommandTable("admin");
13 |
--------------------------------------------------------------------------------
/src/appservice/config/config.harness.yaml:
--------------------------------------------------------------------------------
1 | homeserver:
2 | domain: "localhost:9999"
3 | url: http://localhost:8081
4 |
5 | db:
6 | engine: "postgres"
7 | connectionString: "postgres://mjolnir-tester:mjolnir-test@127.0.0.1:8083/mjolnir-test-db"
8 |
9 | adminRoom: "#draupnir-admin:localhost:9999"
10 |
11 | webAPI:
12 | port: 9001
13 |
14 | # The directory the bot should store various bits of information in
15 | dataPath: "./test/harness/mjolnir-data/"
16 |
17 | roomStateBackingStore:
18 | enabled: false
19 |
--------------------------------------------------------------------------------
/src/protections/Protection.ts:
--------------------------------------------------------------------------------
1 | // Copyright 2022 Gnuxie
2 | // Copyright 2019 The Matrix.org Foundation C.I.C.
3 | //
4 | // SPDX-License-Identifier: AFL-3.0 AND Apache-2.0
5 | //
6 | // SPDX-FileAttributionText:
7 | // This modified file incorporates work from mjolnir
8 | // https://github.com/matrix-org/mjolnir
9 | //
10 |
11 | import { Protection } from "matrix-protection-suite";
12 |
13 | export interface DraupnirProtection
14 | extends Protection {}
15 |
--------------------------------------------------------------------------------
/test/tsnode.cjs:
--------------------------------------------------------------------------------
1 | // SPDX-FileCopyrightText: 2024 Gnuxie
2 | //
3 | // SPDX-License-Identifier: CC0-1.0
4 |
5 | const tsAutoMockTransformer = require("ts-auto-mock/transformer").default;
6 | require("ts-node").register({
7 | project: "./tsconfig.json",
8 | transformers: (program) => ({
9 | before: [tsAutoMockTransformer(program)],
10 | }),
11 | });
12 |
13 | // Mocha apparently suppresses unhandled rejections for some crazy reason??
14 | process.on("unhandledRejection", (reason) => {
15 | throw reason;
16 | });
17 |
--------------------------------------------------------------------------------
/src/safemode/SafeModeCause.ts:
--------------------------------------------------------------------------------
1 | // SPDX-FileCopyrightText: 2024 Gnuxie
2 | //
3 | // SPDX-License-Identifier: AFL-3.0
4 |
5 | import { ResultError } from "@gnuxie/typescript-result";
6 | import { StringUserID } from "@the-draupnir-project/matrix-basic-types";
7 |
8 | export enum SafeModeReason {
9 | InitializationError = "InitializationError",
10 | ByRequest = "ByRequest",
11 | }
12 |
13 | export type SafeModeCause =
14 | | { reason: SafeModeReason.ByRequest; user: StringUserID }
15 | | { reason: SafeModeReason.InitializationError; error: ResultError };
16 |
--------------------------------------------------------------------------------
/NOTICE:
--------------------------------------------------------------------------------
1 | This project incorporates modified work from:
2 |
3 | - mjolnir https://github.com/matrix-org/mjolnir
4 |
5 | Any source file incorporating work retains copyright notices.
6 | All source files include an SPDX license identifier.
7 | Licenses relating to the identifiers can be found in the LICENSES directory.
8 | Any source file incorporating work includes an attribution notice to indicate modification and origin.
9 | These attribution notices use the SPDX file attribution text field and must be
10 | respected as attribution notices to be reproduced in all copies or derivatives of the source file.
11 |
--------------------------------------------------------------------------------
/src/appservice/bot/AppserviceBotPrerequisite.ts:
--------------------------------------------------------------------------------
1 | // SPDX-FileCopyrightText: 2024 Gnuxie
2 | //
3 | // SPDX-License-Identifier: Apache-2.0
4 | //
5 | // SPDX-FileAttributionText:
6 | // This modified file incorporates work from Draupnir
7 | // https://github.com/the-draupnir-project/Draupnir
8 | //
9 |
10 | import { MatrixAdaptorContext } from "@the-draupnir-project/mps-interface-adaptor";
11 | import { MjolnirAppService } from "../AppService";
12 | import { MatrixSendClient } from "matrix-protection-suite-for-matrix-bot-sdk";
13 |
14 | export interface AppserviceAdaptorContext extends MatrixAdaptorContext {
15 | appservice: MjolnirAppService;
16 | client: MatrixSendClient;
17 | }
18 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "alwaysStrict": true,
4 | "esModuleInterop": true,
5 | "exactOptionalPropertyTypes": true,
6 | "experimentalDecorators": true,
7 | "emitDecoratorMetadata": true,
8 | "module": "commonjs",
9 | "moduleResolution": "node",
10 | "newLine": "LF",
11 | "noImplicitAny": true,
12 | "noImplicitReturns": true,
13 | "noImplicitThis": true,
14 | "noUncheckedIndexedAccess": true,
15 | "noUnusedLocals": true,
16 | "outDir": "./lib",
17 | "sourceMap": true,
18 | "strictNullChecks": true,
19 | "strictPropertyInitialization": true,
20 | "target": "es2021",
21 | "types": ["node", "mocha"],
22 | "jsx": "react",
23 | "jsxFactory": "DeadDocumentJSX.JSXFactory"
24 | },
25 | "include": ["./src/**/*"]
26 | }
27 |
--------------------------------------------------------------------------------
/src/protections/RoomTakedown/RoomAuditLog.ts:
--------------------------------------------------------------------------------
1 | // SPDX-FileCopyrightText: 2025 Gnuxie
2 | //
3 | // SPDX-License-Identifier: Apache-2.0
4 |
5 | import { Result } from "@gnuxie/typescript-result";
6 | import {
7 | StringEventID,
8 | StringRoomID,
9 | } from "@the-draupnir-project/matrix-basic-types";
10 | import { LiteralPolicyRule, RoomBasicDetails } from "matrix-protection-suite";
11 |
12 | export type RoomTakedownDetails = Omit & {
13 | policy_id: StringEventID;
14 | created_at: number;
15 | };
16 |
17 | export interface RoomAuditLog {
18 | takedownRoom(
19 | policy: LiteralPolicyRule,
20 | details: RoomBasicDetails
21 | ): Promise>;
22 | isRoomTakendown(roomID: StringRoomID): boolean;
23 | getTakedownDetails(
24 | roomID: StringRoomID
25 | ): Promise>;
26 | destroy(): void;
27 | }
28 |
--------------------------------------------------------------------------------
/src/appservice/config/config.example.yaml:
--------------------------------------------------------------------------------
1 | homeserver:
2 | # The Matrix server name, this will be the name of the server in your matrix id.
3 | domain: "localhost:9999"
4 | # The url for the appservice to call the client server API from.
5 | url: http://localhost:8081
6 |
7 | # Database configuration for storing which Draupnirs have been provisioned.
8 | db:
9 | engine: "postgres"
10 | connectionString: "postgres://mjolnir-tester:mjolnir-test@localhost:8083/mjolnir-test-db"
11 |
12 | # A room you have created that scopes who can access the appservice.
13 | # See docs/access_control.md
14 | adminRoom: "#draupnir-admin:localhost:9999"
15 |
16 | # This is a web api that the widget connects to in order to interact with the appservice.
17 | webAPI:
18 | port: 9001
19 |
20 | # The directory the bot should store various bits of information in
21 | dataPath: "/data/storage"
22 |
23 | roomStateBackingStore:
24 | enabled: false
25 |
--------------------------------------------------------------------------------
/src/capabilities/capabilityIndex.ts:
--------------------------------------------------------------------------------
1 | // Copyright 2022-2024 Gnuxie
2 | // Copyright 2019 2021 The Matrix.org Foundation C.I.C.
3 | //
4 | // SPDX-License-Identifier: AFL-3.0 AND Apache-2.0
5 | //
6 | // SPDX-FileAttributionText:
7 | // This modified file incorporates work from mjolnir
8 | // https://github.com/matrix-org/mjolnir
9 | //
10 |
11 | import "../protections/HomeserverUserPolicyApplication/UserRestrictionCapability";
12 | import "../protections/HomeserverUserPolicyApplication/UserRestrictionCapabilityRenderer";
13 | import "../protections/HomeserverUserPolicyApplication/UserSuspensionCapability";
14 | import "./StandardEventConsequencesRenderer";
15 | import "./ServerACLConsequencesRenderer";
16 | import "./StandardUserConsequencesRenderer";
17 | import "./RoomTakedownCapability";
18 | import "./RoomTakedownCapabilityRenderer";
19 | import "./SynapseAdminRoomTakedown/SynapseAdminRoomTakedown";
20 |
--------------------------------------------------------------------------------
/src/safemode/commands/RestartDraupnirCommand.ts:
--------------------------------------------------------------------------------
1 | // SPDX-FileCopyrightText: 2024 Gnuxie
2 | //
3 | // SPDX-License-Identifier: AFL-3.0
4 |
5 | import { describeCommand } from "@the-draupnir-project/interface-manager";
6 | import { Draupnir } from "../../Draupnir";
7 | import { SafeModeDraupnir } from "../DraupnirSafeMode";
8 | import { Result } from "@gnuxie/typescript-result";
9 | import { SafeModeInterfaceAdaptor } from "./SafeModeAdaptor";
10 |
11 | export const SafeModeRestartCommand = describeCommand({
12 | summary: "Restart Draupnir, quitting safe mode.",
13 | parameters: [],
14 | async executor({
15 | safeModeToggle,
16 | }: SafeModeDraupnir): Promise> {
17 | return safeModeToggle.switchToDraupnir({ sendStatusOnStart: true });
18 | },
19 | });
20 |
21 | SafeModeInterfaceAdaptor.describeRenderer(SafeModeRestartCommand, {
22 | isAlwaysSupposedToUseDefaultRenderer: true,
23 | });
24 |
--------------------------------------------------------------------------------
/src/protections/invitation/inviteCore.ts:
--------------------------------------------------------------------------------
1 | // SPDX-FileCopyrightText: 2024 Gnuxie
2 | //
3 | // SPDX-License-Identifier: AFL-3.0
4 |
5 | import { StringUserID } from "@the-draupnir-project/matrix-basic-types";
6 | import {
7 | Membership,
8 | MembershipEvent,
9 | RoomMembershipRevision,
10 | } from "matrix-protection-suite";
11 |
12 | export function isInvitationForUser(
13 | event: MembershipEvent,
14 | clientUserID: StringUserID
15 | ): event is MembershipEvent & { content: { membership: Membership.Invite } } {
16 | return (
17 | event.state_key === clientUserID &&
18 | event.content.membership === Membership.Invite
19 | );
20 | }
21 |
22 | export function isSenderJoinedInRevision(
23 | senderUserID: StringUserID,
24 | membership: RoomMembershipRevision
25 | ): boolean {
26 | const senderMembership = membership.membershipForUser(senderUserID);
27 | return Boolean(senderMembership?.content.membership === Membership.Join);
28 | }
29 |
--------------------------------------------------------------------------------
/draupnir-entrypoint.sh:
--------------------------------------------------------------------------------
1 | #!/bin/sh
2 |
3 | # SPDX-FileCopyrightText: 2024 Gnuxie
4 | # SPDX-FileCopyrightText: 2022 The Matrix.org Foundation C.I.C.
5 | #
6 | # SPDX-License-Identifier: Apache-2.0 AND AFL-3.0
7 |
8 | # This is used as the entrypoint in the draupnir Dockerfile.
9 | # We want to transition away form people running the image without specifying `bot` or `appservice`.
10 | # So if eventually cli arguments are provided for the bot version, we want this to be the opportunity to move to `bot`.
11 | # Therefore using arguments without specifying `bot` (or appservice) is unsupported.
12 | # We maintain the behaviour where if it looks like someone is providing an executable to `docker run`, then we will execute that instead.
13 | # This aids configuration and debugging of the image if for example node needed to be started via another method.
14 | case "$1" in
15 | bot) shift; set -- node /draupnir/index.js "$@";;
16 | appservice) shift; set -- node /draupnir/appservice/cli.js "$@";;
17 | esac
18 |
19 | exec "$@";
20 |
--------------------------------------------------------------------------------
/src/safemode/commands/SafeModeAdaptor.ts:
--------------------------------------------------------------------------------
1 | // SPDX-FileCopyrightText: 2024 Gnuxie
2 | //
3 | // SPDX-License-Identifier: AFL-3.0
4 |
5 | import {
6 | StandardAdaptorContextToCommandContextTranslator,
7 | StandardMatrixInterfaceAdaptor,
8 | } from "@the-draupnir-project/interface-manager";
9 | import {
10 | MatrixEventContext,
11 | invocationInformationFromMatrixEventcontext,
12 | MPSMatrixInterfaceAdaptorCallbacks,
13 | MPSCommandDispatcherCallbacks,
14 | } from "@the-draupnir-project/mps-interface-adaptor";
15 | import { SafeModeDraupnir } from "../DraupnirSafeMode";
16 |
17 | export const SafeModeContextToCommandContextTranslator =
18 | new StandardAdaptorContextToCommandContextTranslator();
19 |
20 | export const SafeModeInterfaceAdaptor = new StandardMatrixInterfaceAdaptor<
21 | SafeModeDraupnir,
22 | MatrixEventContext
23 | >(
24 | SafeModeContextToCommandContextTranslator,
25 | invocationInformationFromMatrixEventcontext,
26 | MPSMatrixInterfaceAdaptorCallbacks,
27 | MPSCommandDispatcherCallbacks
28 | );
29 |
--------------------------------------------------------------------------------
/src/protections/HomeserverUserPolicyApplication/UserRestrictionAuditLog.ts:
--------------------------------------------------------------------------------
1 | // SPDX-FileCopyrightText: 2025 Gnuxie
2 | //
3 | // SPDX-License-Identifier: AFL-3.0
4 |
5 | import { Result } from "@gnuxie/typescript-result";
6 | import { StringUserID } from "@the-draupnir-project/matrix-basic-types";
7 | import { LiteralPolicyRule } from "matrix-protection-suite";
8 | import { AccountRestriction } from "matrix-protection-suite-for-matrix-bot-sdk";
9 |
10 | export interface UserRestrictionAuditLog {
11 | isUserRestricted(userID: StringUserID): Promise>;
12 | recordUserRestriction(
13 | userID: StringUserID,
14 | restriction: AccountRestriction,
15 | options: {
16 | rule: LiteralPolicyRule | null;
17 | sender: StringUserID;
18 | }
19 | ): Promise>;
20 | recordExistingUserRestriction(
21 | uesrID: StringUserID,
22 | restriction: AccountRestriction
23 | ): Promise>;
24 | unrestrictUser(
25 | userID: StringUserID,
26 | sender: StringUserID
27 | ): Promise>;
28 | destroy(): void;
29 | }
30 |
--------------------------------------------------------------------------------
/src/appservice/bot/AppserviceBotCommands.ts:
--------------------------------------------------------------------------------
1 | // SPDX-FileCopyrightText: 2024 Gnuxie
2 | //
3 | // SPDX-License-Identifier: Apache-2.0
4 | //
5 | // SPDX-FileAttributionText:
6 | // This modified file incorporates work from Draupnir
7 | // https://github.com/the-draupnir-project/Draupnir
8 | //
9 |
10 | import {
11 | AppserviceAllowCommand,
12 | AppserviceRemoveCommand,
13 | } from "./AccessCommands";
14 | import { AppserviceBotCommands } from "./AppserviceBotCommandTable";
15 | import { AppserviceBotHelpCommand } from "./AppserviceBotHelp";
16 | import {
17 | AppserviceListUnstartedCommand,
18 | AppserviceRestartDraupnirCommand,
19 | } from "./ListCommand";
20 |
21 | AppserviceBotCommands.internCommand(AppserviceBotHelpCommand, ["admin", "help"])
22 | .internCommand(AppserviceAllowCommand, ["admin", "allow"])
23 | .internCommand(AppserviceRemoveCommand, ["admin", "remove"])
24 | .internCommand(AppserviceRestartDraupnirCommand, ["admin", "restart"])
25 | .internCommand(AppserviceListUnstartedCommand, [
26 | "admin",
27 | "list",
28 | "unstarted",
29 | ]);
30 |
--------------------------------------------------------------------------------
/test/integration/commands/recoverCommandTest.ts:
--------------------------------------------------------------------------------
1 | // SPDX-FileCopyrightText: 2024 Gnuxie
2 | //
3 | // SPDX-License-Identifier: AFL-3.0
4 |
5 | import { StringUserID } from "@the-draupnir-project/matrix-basic-types";
6 | import { newTestUser } from "../clientHelper";
7 | import { DraupnirTestContext, draupnir } from "../mjolnirSetupUtils";
8 | import { testRecoverAndRestart } from "./recoverCommandDetail";
9 |
10 | describe("We should be able to restart and recover draupnir when it has bad account data", function () {
11 | it("Recovering protected rooms", async function (this: DraupnirTestContext) {
12 | const moderator = await newTestUser(this.config.homeserverUrl, {
13 | name: { contains: "moderator" },
14 | });
15 | await draupnir().client.inviteUser(
16 | await moderator.getUserId(),
17 | draupnir().managementRoomID
18 | );
19 | await moderator.joinRoom(draupnir().managementRoomID);
20 | await testRecoverAndRestart(
21 | (await moderator.getUserId()) as StringUserID,
22 | draupnir()
23 | );
24 | } as unknown as Mocha.AsyncFunc);
25 | });
26 |
--------------------------------------------------------------------------------
/src/protections/DraupnirProtectionsIndex.ts:
--------------------------------------------------------------------------------
1 | // Copyright 2024 Gnuxie
2 | //
3 | // SPDX-License-Identifier: AFL-3.0
4 |
5 | /**
6 | * This file exists as a way to register all protections.
7 | * In future, we should maybe try to dogfood the dynamic plugin load sytem
8 | * instead. For now that system doesn't even exist.
9 | */
10 |
11 | // import capability renderers and glue too.
12 | import "../capabilities/capabilityIndex";
13 |
14 | // keep alphabetical please.
15 | import "./BanPropagation";
16 | import "./BasicFlooding";
17 | import "./DraupnirNews/DraupnirNews";
18 | import "./FirstMessageIsImage";
19 | import "./HomeserverUserPolicyApplication/HomeserverUserPolicyProtection";
20 | import "./InvalidEventProtection";
21 | import "./JoinWaveShortCircuit";
22 | import "./RedactionSynchronisation";
23 | import "./MembershipChangeProtection";
24 | import "./MentionLimitProtection";
25 | import "./MessageIsMedia";
26 | import "./MessageIsVoice";
27 | import "./NewJoinerProtection";
28 | import "./PolicyChangeNotification";
29 | import "./ProtectedRooms/RoomsSetBehaviourProtection";
30 | import "./TrustedReporters";
31 | import "./WordList";
32 |
--------------------------------------------------------------------------------
/src/webapis/SynapseHTTPAntispam/PingEndpoint.ts:
--------------------------------------------------------------------------------
1 | // SPDX-FileCopyrightText: 2025 Gnuxie
2 | //
3 | // SPDX-License-Identifier: Apache-2.0
4 | //
5 | // SPDX-FileAttributionText:
6 | // This modified file incorporates work from Draupnir
7 | // https://github.com/the-draupnir-project/Draupnir
8 | //
9 |
10 | import { Type } from "@sinclair/typebox";
11 | import { Request, Response } from "express";
12 | import { isError, Logger, Value } from "matrix-protection-suite";
13 |
14 | const log = new Logger("PingEndpoint");
15 |
16 | const PingBody = Type.Object({
17 | id: Type.Unknown(),
18 | });
19 |
20 | export function handleHttpAntispamPing(
21 | request: Request,
22 | response: Response
23 | ): void {
24 | const decodedBody = Value.Decode(PingBody, request.body);
25 | if (isError(decodedBody)) {
26 | log.error("Error decoding request body:", decodedBody.error);
27 | response.status(400).send({
28 | errcode: "M_INVALID_PARAM",
29 | error: "Error decoding request body",
30 | });
31 | return;
32 | }
33 | response.status(200).send({
34 | id: decodedBody.ok.id,
35 | status: "ok",
36 | });
37 | }
38 |
--------------------------------------------------------------------------------
/src/commands/DraupnirCommandTable.ts:
--------------------------------------------------------------------------------
1 | // SPDX-FileCopyrightText: 2024 Gnuxie
2 | //
3 | // SPDX-License-Identifier: Apache-2.0
4 | //
5 | // SPDX-FileAttributionText:
6 | // This modified file incorporates work from Draupnir
7 | // https://github.com/the-draupnir-project/Draupnir
8 | //
9 |
10 | import {
11 | StandardCommandTable,
12 | StringFromMatrixEventReferenceTranslator,
13 | StringFromMatrixRoomAliasTranslator,
14 | StringFromMatrixRoomIDTranslator,
15 | StringFromMatrixUserIDTranslator,
16 | StringFromNumberTranslator,
17 | StringfromBooleanTranslator,
18 | } from "@the-draupnir-project/interface-manager";
19 |
20 | export const DraupnirTopLevelCommands = new StandardCommandTable(
21 | "draupnir top level"
22 | )
23 | .internPresentationTypeTranslator(StringFromNumberTranslator)
24 | .internPresentationTypeTranslator(StringfromBooleanTranslator)
25 | .internPresentationTypeTranslator(StringFromMatrixRoomIDTranslator)
26 | .internPresentationTypeTranslator(StringFromMatrixRoomAliasTranslator)
27 | .internPresentationTypeTranslator(StringFromMatrixUserIDTranslator)
28 | .internPresentationTypeTranslator(StringFromMatrixEventReferenceTranslator);
29 |
--------------------------------------------------------------------------------
/src/commands/DraupnirCommandPrerequisites.ts:
--------------------------------------------------------------------------------
1 | // SPDX-FileCopyrightText: 2024 Gnuxie
2 | //
3 | // SPDX-License-Identifier: Apache-2.0
4 | //
5 | // SPDX-FileAttributionText:
6 | // This modified file incorporates work from Draupnir
7 | // https://github.com/the-draupnir-project/Draupnir
8 | //
9 |
10 | import {
11 | StandardAdaptorContextToCommandContextTranslator,
12 | StandardMatrixInterfaceAdaptor,
13 | } from "@the-draupnir-project/interface-manager";
14 | import { Draupnir } from "../Draupnir";
15 | import {
16 | MPSCommandDispatcherCallbacks,
17 | MPSMatrixInterfaceAdaptorCallbacks,
18 | MatrixEventContext,
19 | invocationInformationFromMatrixEventcontext,
20 | } from "@the-draupnir-project/mps-interface-adaptor";
21 |
22 | export const DraupnirContextToCommandContextTranslator =
23 | new StandardAdaptorContextToCommandContextTranslator();
24 | export const DraupnirInterfaceAdaptor = new StandardMatrixInterfaceAdaptor<
25 | Draupnir,
26 | MatrixEventContext
27 | >(
28 | DraupnirContextToCommandContextTranslator,
29 | invocationInformationFromMatrixEventcontext,
30 | MPSMatrixInterfaceAdaptorCallbacks,
31 | MPSCommandDispatcherCallbacks
32 | );
33 |
--------------------------------------------------------------------------------
/src/commands/SafeModeCommand.ts:
--------------------------------------------------------------------------------
1 | // SPDX-FileCopyrightText: 2024 Gnuxie
2 | //
3 | // SPDX-License-Identifier: AFL-3.0
4 |
5 | import {
6 | BasicInvocationInformation,
7 | describeCommand,
8 | } from "@the-draupnir-project/interface-manager";
9 | import { SafeModeDraupnir } from "../safemode/DraupnirSafeMode";
10 | import { Result } from "@gnuxie/typescript-result";
11 | import { Draupnir } from "../Draupnir";
12 | import { SafeModeReason } from "../safemode/SafeModeCause";
13 | import { DraupnirInterfaceAdaptor } from "./DraupnirCommandPrerequisites";
14 |
15 | export const DraupnirSafeModeCommand = describeCommand({
16 | summary: "Enter into safe mode.",
17 | parameters: [],
18 | async executor(
19 | { safeModeToggle }: Draupnir,
20 | info: BasicInvocationInformation
21 | ): Promise> {
22 | return safeModeToggle.switchToSafeMode(
23 | {
24 | reason: SafeModeReason.ByRequest,
25 | user: info.commandSender,
26 | },
27 | { sendStatusOnStart: true }
28 | );
29 | },
30 | });
31 |
32 | DraupnirInterfaceAdaptor.describeRenderer(DraupnirSafeModeCommand, {
33 | isAlwaysSupposedToUseDefaultRenderer: true,
34 | });
35 |
--------------------------------------------------------------------------------
/Dockerfile:
--------------------------------------------------------------------------------
1 | # Copyright 2024 Gnuxie
2 | # Copyright 2019 The Matrix.org Foundation C.I.C.
3 | #
4 | # SPDX-License-Identifier: Apache-2.0 AND AFL-3.0
5 |
6 | FROM node:20-slim as build-stage
7 | RUN apt-get update && apt-get install -y git && rm -rf /var/lib/apt/lists/*
8 | COPY . /tmp/src
9 | # describe the version.
10 | RUN cd /tmp/src && git describe > version.txt.tmp && mv version.txt.tmp version.txt
11 | # build and install
12 | RUN cd /tmp/src \
13 | && yarn install --frozen-lockfile --network-timeout 100000 \
14 | && yarn build \
15 | && yarn install --frozen-lockfile --production --network-timeout 100000
16 |
17 | FROM node:20-slim as final-stage
18 | COPY --from=build-stage /tmp/src/version.txt version.txt
19 | COPY --from=build-stage /tmp/src/lib/ /draupnir/
20 | COPY --from=build-stage /tmp/src/node_modules /node_modules
21 | COPY --from=build-stage /tmp/src/draupnir-entrypoint.sh /
22 | COPY --from=build-stage /tmp/src/package.json /
23 |
24 | ENV NODE_ENV=production
25 | ENV NODE_CONFIG_DIR=/data/config
26 | # Set SQLite's temporary directory. See #746 for context.
27 | ENV SQLITE_TMPDIR=/data
28 |
29 | CMD ["bot"]
30 | ENTRYPOINT ["./draupnir-entrypoint.sh"]
31 | VOLUME ["/data"]
32 |
--------------------------------------------------------------------------------
/src/protections/RoomTakedown/RoomDiscoveryRenderer.tsx:
--------------------------------------------------------------------------------
1 | // SPDX-FileCopyrightText: 2025 Gnuxie
2 | //
3 | // SPDX-License-Identifier: AFL-3.0
4 |
5 | import { DocumentNode } from "@the-draupnir-project/interface-manager";
6 | import { RoomBasicDetails } from "matrix-protection-suite";
7 | import { DeadDocumentJSX } from "@the-draupnir-project/interface-manager";
8 |
9 | export function renderDiscoveredRoom(details: RoomBasicDetails): DocumentNode {
10 | return (
11 |
12 | Room Discovered
13 |
14 |
15 | {details.room_id}
16 |
17 |
34 |
35 |
36 | );
37 | }
38 |
--------------------------------------------------------------------------------
/src/appservice/bot/AppserviceBotInterfaceAdaptor.ts:
--------------------------------------------------------------------------------
1 | // SPDX-FileCopyrightText: 2024 Gnuxie
2 | //
3 | // SPDX-License-Identifier: Apache-2.0
4 | //
5 | // SPDX-FileAttributionText:
6 | // This modified file incorporates work from Draupnir
7 | // https://github.com/the-draupnir-project/Draupnir
8 | //
9 |
10 | import {
11 | StandardAdaptorContextToCommandContextTranslator,
12 | StandardMatrixInterfaceAdaptor,
13 | } from "@the-draupnir-project/interface-manager";
14 | import { AppserviceAdaptorContext } from "./AppserviceBotPrerequisite";
15 | import {
16 | invocationInformationFromMatrixEventcontext,
17 | MatrixEventContext,
18 | MPSCommandDispatcherCallbacks,
19 | MPSMatrixInterfaceAdaptorCallbacks,
20 | } from "@the-draupnir-project/mps-interface-adaptor";
21 |
22 | export const AppserviceAdaptorContextToCommandContextTranslator =
23 | new StandardAdaptorContextToCommandContextTranslator();
24 |
25 | export const AppserviceBotInterfaceAdaptor = new StandardMatrixInterfaceAdaptor<
26 | AppserviceAdaptorContext,
27 | MatrixEventContext
28 | >(
29 | AppserviceAdaptorContextToCommandContextTranslator,
30 | invocationInformationFromMatrixEventcontext,
31 | MPSMatrixInterfaceAdaptorCallbacks,
32 | MPSCommandDispatcherCallbacks
33 | );
34 |
--------------------------------------------------------------------------------
/test/integration/manualLaunchScript.ts:
--------------------------------------------------------------------------------
1 | // Copyright 2022 - 2024 Gnuxie
2 | // Copyright 2021 - 2022 The Matrix.org Foundation C.I.C.
3 | //
4 | // SPDX-License-Identifier: Apache-2.0
5 | //
6 | // SPDX-FileAttributionText:
7 | // This modified file incorporates work from mjolnir
8 | // https://github.com/matrix-org/mjolnir
9 | //
10 |
11 | /**
12 | * This file is used to launch mjolnir for manual testing, creating a user and management room automatically if it doesn't already exist.
13 | */
14 |
15 | import { draupnirClient, makeBotModeToggle } from "./mjolnirSetupUtils";
16 | import { configRead, getStoragePath } from "../../src/config";
17 | import { DefaultEventDecoder } from "matrix-protection-suite";
18 | import { makeTopLevelStores } from "../../src/backingstore/DraupnirStores";
19 |
20 | void (async () => {
21 | const config = configRead();
22 | const storagePath = getStoragePath(config.dataPath);
23 | const toggle = await makeBotModeToggle(config, {
24 | stores: makeTopLevelStores(storagePath, DefaultEventDecoder, {
25 | isRoomStateBackingStoreEnabled:
26 | config.roomStateBackingStore.enabled ?? false,
27 | }),
28 | allowSafeMode: true,
29 | });
30 | await draupnirClient()?.start();
31 | await toggle.encryptionInitialized();
32 | })();
33 |
--------------------------------------------------------------------------------
/src/protections/RoomTakedown/RoomDiscovery.ts:
--------------------------------------------------------------------------------
1 | // SPDX-FileCopyrightText: 2025 Gnuxie
2 | //
3 | // SPDX-License-Identifier: Apache-2.0
4 |
5 | import { PolicyRuleChange, RoomBasicDetails } from "matrix-protection-suite";
6 | import { RoomToCheck } from "./DiscoveredRoomStore";
7 | import { Result } from "@gnuxie/typescript-result";
8 | import { StringRoomID } from "@the-draupnir-project/matrix-basic-types";
9 |
10 | // FIXME: This should really have its own "Room acknowledged" table in the audit log...
11 | // which only sends when a notification has been sent to the admin room.
12 | // This would allow for complete coverage...
13 |
14 | export type RoomDiscoveryListener = (details: RoomBasicDetails) => void;
15 |
16 | export interface RoomDiscovery {
17 | on(event: "RoomDiscovery", listener: RoomDiscoveryListener): this;
18 | off(event: "RoomDiscovery", listener: RoomDiscoveryListener): this;
19 | emit(event: "RoomDiscovery", details: RoomBasicDetails): void;
20 | checkRoomsDiscovered(
21 | roomsToCheck: RoomToCheck[]
22 | ): Promise>;
23 | /** FIXME: I do not like that this is exposed */
24 | isRoomDiscovered(roomID: StringRoomID): boolean;
25 | }
26 |
27 | export interface RoomExplorer {
28 | handlePolicyChange(change: PolicyRuleChange[]): void;
29 | unregisterListeners(): void;
30 | }
31 |
--------------------------------------------------------------------------------
/src/safemode/commands/HelpCommand.tsx:
--------------------------------------------------------------------------------
1 | // SPDX-FileCopyrightText: 2024 Gnuxie
2 | //
3 | // SPDX-License-Identifier: AFL-3.0
4 |
5 | import { SafeModeCommands } from "./SafeModeCommands";
6 | import { SafeModeInterfaceAdaptor } from "./SafeModeAdaptor";
7 |
8 | import { Result } from "@gnuxie/typescript-result";
9 | import {
10 | DeadDocumentJSX,
11 | describeCommand,
12 | TopPresentationSchema,
13 | CommandTable,
14 | } from "@the-draupnir-project/interface-manager";
15 | import { Ok } from "matrix-protection-suite";
16 | import { renderTableHelp } from "@the-draupnir-project/mps-interface-adaptor";
17 | import { safeModeHeader } from "./StatusCommand";
18 | import { DOCUMENTATION_URL } from "../../config";
19 |
20 | export const SafeModeHelpCommand = describeCommand({
21 | rest: {
22 | name: "command parts",
23 | acceptor: TopPresentationSchema,
24 | },
25 | summary: "Display this message",
26 | executor: async function (
27 | _context,
28 | _keywords
29 | ): Promise> {
30 | return Ok(SafeModeCommands);
31 | },
32 | parameters: [],
33 | });
34 |
35 | SafeModeInterfaceAdaptor.describeRenderer(SafeModeHelpCommand, {
36 | JSXRenderer() {
37 | return Ok(
38 |
39 | {safeModeHeader()}
40 | {renderTableHelp(SafeModeCommands, DOCUMENTATION_URL)}
41 |
42 | );
43 | },
44 | });
45 |
--------------------------------------------------------------------------------
/.pre-commit-config.yaml:
--------------------------------------------------------------------------------
1 | # SPDX-FileCopyrightText: 2024 Aminda Suomalainen
2 | #
3 | # SPDX-License-Identifier: CC0-1.0
4 |
5 | # See https://pre-commit.com for more information
6 | # See https://pre-commit.com/hooks.html for more hooks
7 | # See https://pre-commit.ci for more information
8 | ci:
9 | autoupdate_schedule: weekly
10 | skip: [yarn-lint]
11 | repos:
12 | - repo: https://github.com/pre-commit/pre-commit-hooks
13 | rev: v5.0.0
14 | hooks:
15 | - id: trailing-whitespace
16 | args: ["--markdown-linebreak-ext", "md"]
17 | exclude_types: [svg]
18 | - id: end-of-file-fixer
19 | - id: check-yaml
20 | - id: check-added-large-files
21 | - id: check-executables-have-shebangs
22 | - id: check-shebang-scripts-are-executable
23 | - id: check-illegal-windows-names
24 | - repo: https://github.com/editorconfig-checker/editorconfig-checker.python
25 | rev: "3.0.3"
26 | hooks:
27 | - id: editorconfig-checker
28 | alias: ec
29 | - repo: https://github.com/fsfe/reuse-tool
30 | rev: v4.0.3
31 | hooks:
32 | - id: reuse
33 | - repo: local
34 | hooks:
35 | - id: yarn-lint
36 | name: linter
37 | entry: corepack yarn lint
38 | language: system
39 | - repo: https://github.com/python-jsonschema/check-jsonschema
40 | rev: 0.29.3
41 | hooks:
42 | - id: check-renovate
43 | additional_dependencies: ["pyjson5"]
44 |
--------------------------------------------------------------------------------
/test/unit/config/unknownPropertiesTest.ts:
--------------------------------------------------------------------------------
1 | // SPDX-FileCopyrightText: 2024 Gnuxie
2 | //
3 | // SPDX-License-Identifier: AFL-3.0
4 |
5 | import expect from "expect";
6 | import {
7 | getNonDefaultConfigProperties,
8 | getUnknownConfigPropertyPaths,
9 | } from "../../../src/config";
10 | import { IConfig } from "../../../src/config";
11 |
12 | describe("Test unknown properties detection", () => {
13 | it("Should detect when there are typos in the config", function () {
14 | const config = {
15 | pantalaimon: {
16 | use: true,
17 | passweird: "my password hehe",
18 | },
19 | };
20 | const unknownProperties = getUnknownConfigPropertyPaths(config);
21 | expect(unknownProperties.length).toBe(1);
22 | expect(unknownProperties[0]).toBe("/pantalaimon/passweird");
23 | });
24 | });
25 |
26 | describe("Test non-default values detection", () => {
27 | it("Should detect when there are non-default values in the config", function () {
28 | const config = {
29 | pantalaimon: {
30 | use: true,
31 | password: "my password hehe",
32 | },
33 | };
34 | const differentProperties = getNonDefaultConfigProperties(
35 | config as IConfig
36 | ) as unknown as IConfig;
37 | expect(Object.entries(differentProperties).length).toBe(1);
38 | expect(differentProperties.pantalaimon.password).toBe("REDACTED");
39 | expect(differentProperties.pantalaimon.use).toBe(true);
40 | });
41 | });
42 |
--------------------------------------------------------------------------------
/REUSE.toml:
--------------------------------------------------------------------------------
1 | version = 1
2 | SPDX-PackageName = "Draupnir"
3 | SPDX-PackageSupplier = "Gnuxie "
4 | SPDX-PackageDownloadLocation = "https://github.com/the-draupnir-project/Draupnir"
5 |
6 | [[annotations]]
7 | path = ".github/ISSUE_TEMPLATE/bug-report.md"
8 | precedence = "aggregate"
9 | SPDX-FileCopyrightText = "Gnuxie "
10 | SPDX-License-Identifier = "CC0-1.0"
11 |
12 | [[annotations]]
13 | path = "tsconfig.json"
14 | precedence = "aggregate"
15 | SPDX-FileCopyrightText = "Gnuxie "
16 | SPDX-License-Identifier = "CC0-1.0"
17 |
18 | [[annotations]]
19 | path = "REUSE.toml"
20 | precedence = "aggregate"
21 | SPDX-FileCopyrightText = "Gnuxie "
22 | SPDX-License-Identifier = "CC0-1.0"
23 |
24 | [[annotations]]
25 | path = "yarn.lock"
26 | precedence = "aggregate"
27 | SPDX-FileCopyrightText = "Gnuxie "
28 | SPDX-License-Identifier = "CC0-1.0"
29 |
30 | [[annotations]]
31 | path = "NOTICE"
32 | precedence = "aggregate"
33 | SPDX-FileCopyrightText = "Gnuxie "
34 | SPDX-License-Identifier = "CC0-1.0"
35 |
36 | [[annotations]]
37 | path = "test/tsconfig.json"
38 | precedence = "aggregate"
39 | SPDX-FileCopyrightText = "Gnuxie "
40 | SPDX-License-Identifier = "CC0-1.0"
41 |
42 | [[annotations]]
43 | path = "package.json"
44 | precedence = "aggregate"
45 | SPDX-FileCopyrightText = "Gnuxie "
46 | SPDX-License-Identifier = "AFL-3.0"
47 |
--------------------------------------------------------------------------------
/src/commands/SetDisplayNameCommand.ts:
--------------------------------------------------------------------------------
1 | // Copyright 2023 Marcel
2 | //
3 | // SPDX-License-Identifier: AFL-3.0
4 |
5 | import { Ok, Result } from "@gnuxie/typescript-result";
6 | import {
7 | StringPresentationType,
8 | describeCommand,
9 | } from "@the-draupnir-project/interface-manager";
10 | import { Draupnir } from "../Draupnir";
11 | import { ActionError } from "matrix-protection-suite";
12 | import { DraupnirInterfaceAdaptor } from "./DraupnirCommandPrerequisites";
13 |
14 | export const DraupnirDisplaynameCommand = describeCommand({
15 | summary:
16 | "Sets the displayname of the draupnir instance to the specified value in all rooms.",
17 | parameters: [],
18 | rest: {
19 | name: "displayname",
20 | acceptor: StringPresentationType,
21 | },
22 | async executor(
23 | draupnir: Draupnir,
24 | _info,
25 | _keywords,
26 | displaynameParts
27 | ): Promise> {
28 | const displayname = displaynameParts.join(" ");
29 | try {
30 | await draupnir.client.setDisplayName(displayname);
31 | } catch (e) {
32 | const message = e.message || (e.body ? e.body.error : "");
33 | return ActionError.Result(
34 | `Failed to set displayname to ${displayname}: ${message}`
35 | );
36 | }
37 | return Ok(undefined);
38 | },
39 | });
40 |
41 | DraupnirInterfaceAdaptor.describeRenderer(DraupnirDisplaynameCommand, {
42 | isAlwaysSupposedToUseDefaultRenderer: true,
43 | });
44 |
--------------------------------------------------------------------------------
/src/capabilities/RoomTakedownCapability.tsx:
--------------------------------------------------------------------------------
1 | // SPDX-FileCopyrightText: 2025 Gnuxie
2 | //
3 | // SPDX-License-Identifier: Apache-2.0
4 |
5 | import { Result } from "@gnuxie/typescript-result";
6 | import { Type } from "@sinclair/typebox";
7 | import { StringRoomID } from "@the-draupnir-project/matrix-basic-types";
8 | import {
9 | Capability,
10 | CapabilityMethodSchema,
11 | describeCapabilityInterface,
12 | RoomBasicDetails,
13 | } from "matrix-protection-suite";
14 |
15 | export interface RoomDetailsProvider {
16 | getRoomDetails(roomID: StringRoomID): Promise>;
17 | }
18 |
19 | export const RoomTakedownCapability = Type.Intersect([
20 | Type.Object({
21 | isRoomTakendown: CapabilityMethodSchema,
22 | takedownRoom: CapabilityMethodSchema,
23 | }),
24 | Capability,
25 | ]);
26 | // we'll probably want to include room details too so we can provide some
27 | // rudinemtary information to takedown.
28 | // I'd make the fields optional however because it may be impossible
29 | // to get those details on conduwuit.
30 | export type RoomTakedownCapability = {
31 | isRoomTakendown(roomID: StringRoomID): Promise>;
32 | takedownRoom(roomID: StringRoomID): Promise>;
33 | } & RoomDetailsProvider &
34 | Capability;
35 |
36 | describeCapabilityInterface({
37 | name: "RoomTakedownCapability",
38 | description: "Capability that targets matrix rooms",
39 | schema: RoomTakedownCapability,
40 | });
41 |
--------------------------------------------------------------------------------
/src/protections/HomeserverUserPolicyApplication/UserRestrictionCapability.ts:
--------------------------------------------------------------------------------
1 | // SPDX-FileCopyrightText: 2025 Gnuxie
2 | //
3 | // SPDX-License-Identifier: AFL-3.0
4 |
5 | import { Result } from "@gnuxie/typescript-result";
6 | import { Type } from "@sinclair/typebox";
7 | import { StringUserID } from "@the-draupnir-project/matrix-basic-types";
8 | import {
9 | Capability,
10 | CapabilityMethodSchema,
11 | describeCapabilityInterface,
12 | LiteralPolicyRule,
13 | } from "matrix-protection-suite";
14 | import { AccountRestriction } from "matrix-protection-suite-for-matrix-bot-sdk";
15 |
16 | export interface UserRestrictionCapability extends Capability {
17 | isUserRestricted(userID: StringUserID): Promise>;
18 | restrictUser(
19 | userID: StringUserID,
20 | options: {
21 | rule: LiteralPolicyRule | null;
22 | sender: StringUserID;
23 | }
24 | ): Promise>;
25 | unrestrictUser(
26 | userID: StringUserID,
27 | sender: StringUserID
28 | ): Promise>;
29 | }
30 |
31 | const UserRestrictionCapability = Type.Object({
32 | unrestrictUser: CapabilityMethodSchema,
33 | restrictUser: CapabilityMethodSchema,
34 | isUserRestricted: CapabilityMethodSchema,
35 | });
36 |
37 | describeCapabilityInterface({
38 | name: "UserRestrictionCapability",
39 | description:
40 | "Capabilities to restrict and unrestrict users from the homeserver",
41 | schema: UserRestrictionCapability,
42 | });
43 |
--------------------------------------------------------------------------------
/src/safemode/commands/SafeModeCommands.tsx:
--------------------------------------------------------------------------------
1 | // SPDX-FileCopyrightText: 2024 Gnuxie
2 | //
3 | // SPDX-License-Identifier: AFL-3.0
4 |
5 | import {
6 | StandardCommandTable,
7 | StringfromBooleanTranslator,
8 | StringFromMatrixEventReferenceTranslator,
9 | StringFromMatrixRoomAliasTranslator,
10 | StringFromMatrixRoomIDTranslator,
11 | StringFromMatrixUserIDTranslator,
12 | StringFromNumberTranslator,
13 | } from "@the-draupnir-project/interface-manager";
14 | import { SafeModeHelpCommand } from "./HelpCommand";
15 | import { SafeModeStatusCommand } from "./StatusCommand";
16 | import { SafeModeRestartCommand } from "./RestartDraupnirCommand";
17 | import { SafeModeRecoverCommand } from "./RecoverCommand";
18 |
19 | export const SafeModeCommands = new StandardCommandTable("safe mode")
20 | .internPresentationTypeTranslator(StringFromNumberTranslator)
21 | .internPresentationTypeTranslator(StringfromBooleanTranslator)
22 | .internPresentationTypeTranslator(StringFromMatrixRoomIDTranslator)
23 | .internPresentationTypeTranslator(StringFromMatrixRoomAliasTranslator)
24 | .internPresentationTypeTranslator(StringFromMatrixUserIDTranslator)
25 | .internPresentationTypeTranslator(StringFromMatrixEventReferenceTranslator)
26 | .internCommand(SafeModeHelpCommand, ["draupnir", "help"])
27 | .internCommand(SafeModeStatusCommand, ["draupnir", "status"])
28 | .internCommand(SafeModeRecoverCommand, ["draupnir", "recover"])
29 | .internCommand(SafeModeRestartCommand, ["draupnir", "restart"]);
30 |
--------------------------------------------------------------------------------
/test/appservice/utils/ProvisionHelper.ts:
--------------------------------------------------------------------------------
1 | // SPDX-FileCopyrightText: 2024 Gnuxie
2 | //
3 | // SPDX-License-Identifier: AFL-3.0
4 |
5 | import { Ok, Result, ResultError, isError } from "@gnuxie/typescript-result";
6 | import { Draupnir } from "../../../src/Draupnir";
7 | import { MjolnirAppService } from "../../../src/appservice/AppService";
8 | import { StringUserID } from "@the-draupnir-project/matrix-basic-types";
9 |
10 | export interface ProvisionHelper {
11 | /**
12 | * Automatically make a draupnir and a management room.
13 | */
14 | provisionDraupnir(requestingUserID: StringUserID): Promise>;
15 | }
16 |
17 | export class StandardProvisionHelper implements ProvisionHelper {
18 | public constructor(private readonly appservice: MjolnirAppService) {
19 | // nothing to do.
20 | }
21 | async provisionDraupnir(
22 | requestingUserID: StringUserID
23 | ): Promise> {
24 | const provisionResult =
25 | await this.appservice.draupnirManager.provisionNewDraupnir(
26 | requestingUserID
27 | );
28 | if (isError(provisionResult)) {
29 | return provisionResult;
30 | }
31 | const draupnir = await this.appservice.draupnirManager.getRunningDraupnir(
32 | this.appservice.draupnirManager.draupnirMXID(provisionResult.ok),
33 | requestingUserID
34 | );
35 | if (draupnir === undefined) {
36 | return ResultError.Result(`Failed to find draupnir after provisioning`);
37 | }
38 | return Ok(draupnir);
39 | }
40 | }
41 |
--------------------------------------------------------------------------------
/src/appservice/datastore.ts:
--------------------------------------------------------------------------------
1 | // Copyright 2022 Gnuxie
2 | // Copyright 2022 The Matrix.org Foundation C.I.C.
3 | //
4 | // SPDX-License-Identifier: AFL-3.0 AND Apache-2.0
5 | //
6 | // SPDX-FileAttributionText:
7 | // This modified file incorporates work from mjolnir
8 | // https://github.com/matrix-org/mjolnir
9 | //
10 |
11 | import {
12 | StringUserID,
13 | StringRoomID,
14 | } from "@the-draupnir-project/matrix-basic-types";
15 |
16 | export interface MjolnirRecord {
17 | local_part: string;
18 | owner: StringUserID;
19 | management_room: StringRoomID;
20 | }
21 |
22 | /**
23 | * Used to persist draupnirs that have been provisioned by the draupnir manager.
24 | */
25 | export interface DataStore {
26 | /**
27 | * Initialize any resources that the datastore needs to function.
28 | */
29 | init(): Promise;
30 |
31 | /**
32 | * Close any resources that the datastore is using.
33 | */
34 | close(): Promise;
35 |
36 | /**
37 | * List all of the draupnirs we have provisioned.
38 | */
39 | list(): Promise;
40 |
41 | /**
42 | * Persist a new `MjolnirRecord`.
43 | */
44 | store(mjolnirRecord: MjolnirRecord): Promise;
45 |
46 | /**
47 | * @param owner The mxid of the user who provisioned this draupnir.
48 | */
49 | lookupByOwner(owner: string): Promise;
50 |
51 | /**
52 | * @param localPart the mxid of the provisioned draupnir.
53 | */
54 | lookupByLocalPart(localPart: string): Promise;
55 | }
56 |
--------------------------------------------------------------------------------
/src/safemode/SafeModeToggle.ts:
--------------------------------------------------------------------------------
1 | // SPDX-FileCopyrightText: 2024 Gnuxie
2 | //
3 | // SPDX-License-Identifier: AFL-3.0
4 |
5 | import { Err, Result, ResultError } from "@gnuxie/typescript-result";
6 | import { Draupnir } from "../Draupnir";
7 | import { SafeModeDraupnir } from "./DraupnirSafeMode";
8 | import { SafeModeCause } from "./SafeModeCause";
9 |
10 | export type SafeModeToggleOptions = { sendStatusOnStart?: boolean };
11 |
12 | export class DraupnirRestartError extends ResultError {
13 | constructor(
14 | message: string,
15 | public readonly safeModeDraupnir: SafeModeDraupnir
16 | ) {
17 | super(message);
18 | }
19 |
20 | public static Result(
21 | message: string,
22 | options: { safeModeDraupnir: SafeModeDraupnir }
23 | ): Result {
24 | return Err(new DraupnirRestartError(message, options.safeModeDraupnir));
25 | }
26 | }
27 |
28 | export interface SafeModeToggle {
29 | /**
30 | * Switch the bot to Draupnir mode.
31 | * We expect that the result represents the entire conversion.
32 | * We expect that the same matrix client is shared between the bots.
33 | * That means that by the command responds with ticks and crosses,
34 | * draupnir will be running or we will still be in safe mode.
35 | */
36 | switchToDraupnir(
37 | options?: SafeModeToggleOptions
38 | ): Promise>;
39 | switchToSafeMode(
40 | cause: SafeModeCause,
41 | options?: SafeModeToggleOptions
42 | ): Promise>;
43 | }
44 |
--------------------------------------------------------------------------------
/test/appservice/integration/listUnstartedDraupnirTest.ts:
--------------------------------------------------------------------------------
1 | // Copyright 2022 Gnuxie
2 | //
3 | // SPDX-License-Identifier: AFL-3.0
4 |
5 | import expect from "expect";
6 | import { MjolnirAppService } from "../../../src/appservice/AppService";
7 | import { setupHarness } from "../utils/harness";
8 | import { isError } from "matrix-protection-suite";
9 | import { StringUserID } from "@the-draupnir-project/matrix-basic-types";
10 |
11 | interface Context extends Mocha.Context {
12 | appservice?: MjolnirAppService;
13 | }
14 |
15 | describe("Just test some commands innit", function () {
16 | beforeEach(async function (this: Context) {
17 | this.appservice = await setupHarness();
18 | });
19 | afterEach(function (this: Context) {
20 | if (this.appservice) {
21 | return this.appservice.close();
22 | } else {
23 | console.warn("Missing Appservice in this context, so cannot stop it.");
24 | return Promise.resolve(); // TS7030: Not all code paths return a value.
25 | }
26 | });
27 | it("Can list any unstarted draupnir", async function (this: Context) {
28 | const appservice = this.appservice;
29 | if (appservice === undefined) {
30 | throw new TypeError(`Test setup failed`);
31 | }
32 | const result = await appservice.commands.sendTextCommand(
33 | "@test:localhost:9999" as StringUserID,
34 | "!admin list unstarted"
35 | );
36 | if (isError(result)) {
37 | throw new TypeError(`Command should have succeeded`);
38 | }
39 | expect(result.ok).toBeInstanceOf(Array);
40 | });
41 | });
42 |
--------------------------------------------------------------------------------
/src/appservice/bot/AppserviceBotHelp.tsx:
--------------------------------------------------------------------------------
1 | // Copyright 2023 - 2024 Gnuxie
2 | //
3 | // SPDX-License-Identifier: AFL-3.0
4 |
5 | import { Result } from "@gnuxie/typescript-result";
6 | import {
7 | CommandTable,
8 | DeadDocumentJSX,
9 | DocumentNode,
10 | ParsedKeywords,
11 | TopPresentationSchema,
12 | describeCommand,
13 | } from "@the-draupnir-project/interface-manager";
14 | import { ActionResult, Ok } from "matrix-protection-suite";
15 | import { AppserviceBotCommands } from "./AppserviceBotCommandTable";
16 | import { AppserviceBotInterfaceAdaptor } from "./AppserviceBotInterfaceAdaptor";
17 | import {
18 | MatrixAdaptorContext,
19 | renderTableHelp,
20 | } from "@the-draupnir-project/mps-interface-adaptor";
21 | import { DOCUMENTATION_URL } from "../../config";
22 |
23 | export const AppserviceBotHelpCommand = describeCommand({
24 | rest: {
25 | name: "command parts",
26 | acceptor: TopPresentationSchema,
27 | },
28 | summary: "Display this message",
29 | executor: async function (
30 | _context: MatrixAdaptorContext,
31 | _keywords: ParsedKeywords,
32 | ..._args: unknown[]
33 | ): Promise> {
34 | return Ok(AppserviceBotCommands);
35 | },
36 | parameters: [],
37 | });
38 |
39 | function renderAppserviceBotHelp(): Result {
40 | return Ok(
41 | {renderTableHelp(AppserviceBotCommands, DOCUMENTATION_URL)}
42 | );
43 | }
44 |
45 | AppserviceBotInterfaceAdaptor.describeRenderer(AppserviceBotHelpCommand, {
46 | JSXRenderer: renderAppserviceBotHelp,
47 | });
48 |
--------------------------------------------------------------------------------
/src/safemode/RecoveryOptions.tsx:
--------------------------------------------------------------------------------
1 | // SPDX-FileCopyrightText: 2024 Gnuxie
2 | //
3 | // SPDX-License-Identifier: AFL-3.0
4 |
5 | import {
6 | DeadDocumentJSX,
7 | DocumentNode,
8 | } from "@the-draupnir-project/interface-manager";
9 | import { ConfigRecoverableError } from "matrix-protection-suite";
10 | import { SafeModeCause, SafeModeReason } from "./SafeModeCause";
11 | import { MatrixReactionHandler } from "@the-draupnir-project/mps-interface-adaptor";
12 |
13 | export function renderRecoveryOptions(cause: SafeModeCause): DocumentNode {
14 | const recoveryOptions =
15 | cause.reason === SafeModeReason.ByRequest
16 | ? []
17 | : cause.error instanceof ConfigRecoverableError
18 | ? cause.error.recoveryOptions
19 | : [];
20 | if (recoveryOptions.length === 0) {
21 | return (
22 |
23 | No recovery options are available for this failure mode.
24 |
25 | );
26 | }
27 | return (
28 |
29 |
30 | Recovery options are available for this failure mode:
31 |
32 | {recoveryOptions.map((option) => (
33 | - {option.description}
34 | ))}
35 |
36 |
37 |
38 |
39 | To use a recovery option, click on one of the reactions (
40 | {recoveryOptions
41 | .map((_, index) => MatrixReactionHandler.numberToEmoji(index + 1))
42 | .join(", ")}
43 | ), or use the recover command: !draupnir recover 1.
44 |
45 |
46 | );
47 | }
48 |
--------------------------------------------------------------------------------
/src/health/healthz.ts:
--------------------------------------------------------------------------------
1 | // Copyright 2022 Gnuxie
2 | // Copyright 2020 The Matrix.org Foundation C.I.C.
3 | //
4 | // SPDX-License-Identifier: AFL-3.0 AND Apache-2.0
5 | //
6 | // SPDX-FileAttributionText:
7 | // This modified file incorporates work from mjolnir
8 | // https://github.com/matrix-org/mjolnir
9 | //
10 |
11 | import * as http from "http";
12 | import { LogService } from "matrix-bot-sdk";
13 | import { IConfig } from "../config";
14 | // allowed to use the global configuration since this is only intended to be used by `src/index.ts`.
15 |
16 | export class Healthz {
17 | private healthCode: number;
18 |
19 | constructor(private config: IConfig) {
20 | this.healthCode = this.config.health.healthz.unhealthyStatus;
21 | }
22 |
23 | public set isHealthy(val: boolean) {
24 | this.healthCode = val
25 | ? this.config.health.healthz.healthyStatus
26 | : this.config.health.healthz.unhealthyStatus;
27 | }
28 |
29 | public get isHealthy(): boolean {
30 | return this.healthCode === this.config.health.healthz.healthyStatus;
31 | }
32 |
33 | public listen() {
34 | const server = http.createServer((req, res) => {
35 | res.writeHead(this.healthCode);
36 | res.end(`health code: ${this.healthCode}`);
37 | });
38 | server.listen(
39 | this.config.health.healthz.port,
40 | this.config.health.healthz.address,
41 | () => {
42 | LogService.info(
43 | "Healthz",
44 | `Listening for health requests on ${this.config.health.healthz.address}:${this.config.health.healthz.port}`
45 | );
46 | }
47 | );
48 | }
49 | }
50 |
--------------------------------------------------------------------------------
/src/appservice/cli.ts:
--------------------------------------------------------------------------------
1 | // Copyright 2022 Gnuxie
2 | // Copyright 2022 The Matrix.org Foundation C.I.C.
3 | //
4 | // SPDX-License-Identifier: AFL-3.0 AND Apache-2.0
5 | //
6 | // SPDX-FileAttributionText:
7 | // This modified file incorporates work from mjolnir
8 | // https://github.com/matrix-org/mjolnir
9 | //
10 |
11 | import { Cli } from "matrix-appservice-bridge";
12 | import { MjolnirAppService } from "./AppService";
13 | import { AppserviceConfig } from "./config/config";
14 | import { Value } from "@sinclair/typebox/value";
15 |
16 | /**
17 | * This file provides the entrypoint for the appservice mode for draupnir.
18 | * A registration file can be generated `ts-node src/appservice/cli.ts -r -u "http://host.docker.internal:9000"`
19 | * and the appservice can be started with `ts-node src/appservice/cli -p 9000 -c your-confg.yaml`.
20 | */
21 | const cli = new Cli({
22 | registrationPath: "draupnir-registration.yaml",
23 | bridgeConfig: {
24 | schema: {},
25 | affectsRegistration: false,
26 | defaults: {},
27 | },
28 | generateRegistration: MjolnirAppService.generateRegistration,
29 | run: function (port: number) {
30 | const config = cli.getConfig();
31 | if (config === null) {
32 | throw new Error("Couldn't load config");
33 | }
34 | void MjolnirAppService.run(
35 | port,
36 | // we use the matrix-appservice-bridge library to handle cli arguments for loading the config
37 | // but we have to still validate it ourselves.
38 | Value.Decode(AppserviceConfig, config),
39 | cli.getRegistrationFilePath()
40 | );
41 | },
42 | });
43 |
44 | cli.run();
45 |
--------------------------------------------------------------------------------
/src/commands/Help.tsx:
--------------------------------------------------------------------------------
1 | // Copyright 2022 - 2024 Gnuxie
2 | // Copyright 2019 - 2022 The Matrix.org Foundation C.I.C.
3 | //
4 | // SPDX-License-Identifier: AFL-3.0 AND Apache-2.0
5 | //
6 | // SPDX-FileAttributionText:
7 | // This modified file incorporates work from mjolnir
8 | // https://github.com/matrix-org/mjolnir
9 | //
10 |
11 | import { Ok } from "matrix-protection-suite";
12 | import {
13 | CommandTable,
14 | DocumentNode,
15 | DeadDocumentJSX,
16 | describeCommand,
17 | describeRestParameters,
18 | } from "@the-draupnir-project/interface-manager";
19 | import { DraupnirTopLevelCommands } from "./DraupnirCommandTable";
20 | import { TopPresentationSchema } from "@the-draupnir-project/interface-manager/dist/Command/PresentationSchema";
21 | import { renderTableHelp } from "@the-draupnir-project/mps-interface-adaptor";
22 | import { DraupnirInterfaceAdaptor } from "./DraupnirCommandPrerequisites";
23 | import { DOCUMENTATION_URL } from "../config";
24 |
25 | function renderDraupnirHelp(mjolnirTable: CommandTable): DocumentNode {
26 | return {renderTableHelp(mjolnirTable, DOCUMENTATION_URL)};
27 | }
28 |
29 | export const DraupnirHelpCommand = describeCommand({
30 | async executor() {
31 | return Ok(DraupnirTopLevelCommands);
32 | },
33 | parameters: [],
34 | rest: describeRestParameters({
35 | name: "command parts",
36 | acceptor: TopPresentationSchema,
37 | }),
38 | summary: "Display this message",
39 | });
40 |
41 | DraupnirInterfaceAdaptor.describeRenderer(DraupnirHelpCommand, {
42 | JSXRenderer() {
43 | return Ok(renderDraupnirHelp(DraupnirTopLevelCommands));
44 | },
45 | });
46 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/bug-report.md:
--------------------------------------------------------------------------------
1 | ---
2 | name: Draupnir Bot Mode Bug Template
3 | about: A template for a bug encountered in Bot Mode
4 | title: "Draupnir will not X while using the Z command"
5 | labels: ["bug"]
6 | ---
7 |
8 | _It is not required to fill out each of these fields, your bug will still be
9 | considered, even if you do not follow the template. However, please do try
10 | provide as much detail as possible to save us time._
11 |
12 | **How is Draupnir setup?**
13 |
14 | _Try to include any details about non default configuration or any context to
15 | its use that might be relevant to the issue. If Draupnir has crashed, then these
16 | will be printed at the end of the log, you should copy that output here._
17 |
18 | I was using Draupnir with the `roomStateBacking` store enabled. All other
19 | configuration options were default.
20 |
21 | **What were you trying to achieve?**
22 |
23 | _Include details about what you were trying to accomplish without workarounds._
24 |
25 | I was trying to ban a server from my rooms by using the ban command.
26 |
27 | **What steps did you take to encounter the bug**
28 |
29 | _Try to describe clear concise steps or any details about how you encountered
30 | the bug._
31 |
32 | 1. I ran `!draupnir ban server.example.com coc spam` in the management room.
33 | 2. I checked my protected rooms
34 | 3. I could not see the server ACL event updating.
35 |
36 | **What is the bug or what did you expect to happen instead?**
37 |
38 | _Try describe the outcome you would expect, or explicitly state what the bug
39 | is_.
40 |
41 | I expected Draupnir to have changed the `m.room.server_acl` event and ban the
42 | server from all my rooms.
43 |
--------------------------------------------------------------------------------
/src/protections/ConfigHooks.ts:
--------------------------------------------------------------------------------
1 | // Copyright 2024 Gnuxie
2 | //
3 | // SPDX-License-Identifier: AFL-3.0
4 |
5 | import {
6 | ActionResult,
7 | Ok,
8 | ProtectionsConfig,
9 | ServerBanSynchronisationProtection,
10 | isError,
11 | } from "matrix-protection-suite";
12 | import { IConfig } from "../config";
13 | import { RoomTakedownProtection } from "./RoomTakedown/RoomTakedownProtection";
14 | import { BlockInvitationsOnServerProtection } from "./BlockInvitationsOnServerProtection";
15 |
16 | type ConfigHook = (
17 | config: IConfig,
18 | protectionsConfig: ProtectionsConfig
19 | ) => Promise>;
20 |
21 | export const ServerAdminProtections = [
22 | RoomTakedownProtection,
23 | BlockInvitationsOnServerProtection,
24 | ];
25 |
26 | const hooks: ConfigHook[] = [
27 | async function disableServerACL(config, protectionsConfig) {
28 | if (config.disableServerACL) {
29 | return await protectionsConfig.disableProtection(
30 | ServerBanSynchronisationProtection.name
31 | );
32 | } else {
33 | return Ok(undefined);
34 | }
35 | },
36 | ];
37 |
38 | /**
39 | * Introduced to allow the legacy option `config.disableServerACL` to map onto
40 | * MPS's ServerBanSynchronisationProtection. I think we need to deprecate the
41 | * option and offer something else.
42 | */
43 | export async function runProtectionConfigHooks(
44 | config: IConfig,
45 | protectionsConfig: ProtectionsConfig
46 | ): Promise> {
47 | for (const hook of hooks) {
48 | const result = await hook(config, protectionsConfig);
49 | if (isError(result)) {
50 | return result;
51 | }
52 | }
53 | return Ok(undefined);
54 | }
55 |
--------------------------------------------------------------------------------
/src/protections/ProtectedRoomsSetRenderers.tsx:
--------------------------------------------------------------------------------
1 | // Copyright 2022 - 2024 Gnuxie
2 | // Copyright 2019 - 2021 The Matrix.org Foundation C.I.C.
3 | //
4 | // SPDX-License-Identifier: AFL-3.0 AND Apache-2.0
5 | //
6 | // SPDX-FileAttributionText:
7 | // This modified file incorporates work from mjolnir
8 | // https://github.com/matrix-org/mjolnir
9 | //
10 |
11 | import {
12 | ActionError,
13 | ProtectionDescription,
14 | RoomMessageSender,
15 | Task,
16 | } from "matrix-protection-suite";
17 | import { StringRoomID } from "@the-draupnir-project/matrix-basic-types";
18 | import { sendMatrixEventsFromDeadDocument } from "@the-draupnir-project/mps-interface-adaptor";
19 | import { DeadDocumentJSX } from "@the-draupnir-project/interface-manager";
20 | import { Result } from "@gnuxie/typescript-result";
21 |
22 | // The callback that this is required for in MPS, is kinda silly and should
23 | // really be `void` and not `Promise`. If it wanted to be `Promise`,
24 | // then it should really be `Promise>`.
25 | export async function renderProtectionFailedToStart(
26 | roomMessageSender: RoomMessageSender,
27 | managementRoomID: StringRoomID,
28 | error: ActionError,
29 | protectionName: string,
30 | _protectionDescription?: ProtectionDescription
31 | ): Promise {
32 | void Task(
33 | sendMatrixEventsFromDeadDocument(
34 | roomMessageSender,
35 | managementRoomID,
36 |
37 |
38 | A protection {protectionName} failed to start for the following
39 | reason:
40 |
41 | {error.message}
42 | ,
43 | {}
44 | ) as Promise>
45 | );
46 | }
47 |
--------------------------------------------------------------------------------
/src/managementroom/ManagementRoomDetail.ts:
--------------------------------------------------------------------------------
1 | // SPDX-FileCopyrightText: 2024 Gnuxie
2 | //
3 | // SPDX-License-Identifier: AFL-3.0
4 |
5 | import {
6 | MatrixRoomID,
7 | StringRoomID,
8 | StringUserID,
9 | } from "@the-draupnir-project/matrix-basic-types";
10 | import {
11 | JoinRulesEvent,
12 | Membership,
13 | RoomMembershipRevisionIssuer,
14 | RoomStateRevisionIssuer,
15 | } from "matrix-protection-suite";
16 |
17 | export interface ManagementRoomDetail {
18 | isRoomPublic(): boolean;
19 | isModerator(userID: StringUserID): boolean;
20 | managementRoom: MatrixRoomID;
21 | managementRoomID: StringRoomID;
22 | }
23 |
24 | export class StandardManagementRoomDetail implements ManagementRoomDetail {
25 | public constructor(
26 | public readonly managementRoom: MatrixRoomID,
27 | private readonly membershipIssuer: RoomMembershipRevisionIssuer,
28 | private readonly stateIssuer: RoomStateRevisionIssuer
29 | ) {
30 | // nothing to do mare.
31 | }
32 |
33 | public isRoomPublic(): boolean {
34 | const joinRuleEvent =
35 | this.stateIssuer.currentRevision.getStateEvent(
36 | "m.room.join_rules",
37 | ""
38 | );
39 | if (joinRuleEvent === undefined) {
40 | return false; // auth rules are fail safe.
41 | }
42 | return joinRuleEvent.content.join_rule === "public";
43 | }
44 |
45 | public isModerator(userID: StringUserID): boolean {
46 | return (
47 | this.membershipIssuer.currentRevision.membershipForUser(userID)
48 | ?.membership === Membership.Join
49 | );
50 | }
51 |
52 | public get managementRoomID(): StringRoomID {
53 | return this.managementRoom.toRoomIDOrAlias();
54 | }
55 | }
56 |
--------------------------------------------------------------------------------
/src/commands/ResolveAlias.tsx:
--------------------------------------------------------------------------------
1 | // Copyright 2022 Gnuxie
2 | // Copyright 2020 The Matrix.org Foundation C.I.C.
3 | //
4 | // SPDX-License-Identifier: AFL-3.0 AND Apache-2.0
5 | //
6 | // SPDX-FileAttributionText:
7 | // This modified file incorporates work from mjolnir
8 | // https://github.com/matrix-org/mjolnir
9 | //
10 |
11 | import { renderRoomPill } from "@the-draupnir-project/mps-interface-adaptor";
12 | import { MatrixRoomID } from "@the-draupnir-project/matrix-basic-types";
13 | import {
14 | DeadDocumentJSX,
15 | MatrixRoomAliasPresentationType,
16 | describeCommand,
17 | tuple,
18 | } from "@the-draupnir-project/interface-manager";
19 | import { Draupnir } from "../Draupnir";
20 | import { Ok, Result, isError } from "@gnuxie/typescript-result";
21 | import { DraupnirInterfaceAdaptor } from "./DraupnirCommandPrerequisites";
22 |
23 | export const DraupnirResolveAliasCommand = describeCommand({
24 | summary: "Resolve a room alias.",
25 | parameters: tuple({
26 | name: "alias",
27 | acceptor: MatrixRoomAliasPresentationType,
28 | }),
29 | async executor(
30 | { clientPlatform }: Draupnir,
31 | _info,
32 | _keywords,
33 | _rest,
34 | alias
35 | ): Promise> {
36 | return await clientPlatform.toRoomResolver().resolveRoom(alias);
37 | },
38 | });
39 |
40 | DraupnirInterfaceAdaptor.describeRenderer(DraupnirResolveAliasCommand, {
41 | JSXRenderer(result) {
42 | if (isError(result)) {
43 | return Ok(undefined);
44 | } else {
45 | return Ok(
46 |
47 | {result.ok.toRoomIDOrAlias()} -{" "}
48 | {renderRoomPill(result.ok)}
49 |
50 | );
51 | }
52 | },
53 | });
54 |
--------------------------------------------------------------------------------
/src/protections/HomeserverUserPolicyApplication/deactivateUser.ts:
--------------------------------------------------------------------------------
1 | // SPDX-FileCopyrightText: 2025 Gnuxie
2 | //
3 | // SPDX-License-Identifier: AFL-3.0
4 |
5 | import { isError, Ok, Result } from "@gnuxie/typescript-result";
6 | import { StringUserID } from "@the-draupnir-project/matrix-basic-types";
7 | import { LiteralPolicyRule, Logger } from "matrix-protection-suite";
8 | import {
9 | AccountRestriction,
10 | SynapseAdminClient,
11 | } from "matrix-protection-suite-for-matrix-bot-sdk";
12 | import { UserRestrictionAuditLog } from "./UserRestrictionAuditLog";
13 |
14 | const log = new Logger("deactivateUser");
15 |
16 | export async function deactivateUser(
17 | userID: StringUserID,
18 | synapseAdminClient: SynapseAdminClient,
19 | userAuditLog: UserRestrictionAuditLog,
20 | options: {
21 | rule: LiteralPolicyRule | null;
22 | sender: StringUserID;
23 | }
24 | ): Promise> {
25 | const userDetails = await synapseAdminClient.getUserDetails(userID);
26 | if (isError(userDetails)) {
27 | log.error("Unable to query user details", userID);
28 | return userDetails;
29 | }
30 | if (userDetails.ok?.deactivated) {
31 | log.debug("User is already deactivated");
32 | return Ok(undefined);
33 | }
34 | const deactivationResult = await synapseAdminClient.deactivateUser(userID, {
35 | erase: true,
36 | });
37 | if (isError(deactivationResult)) {
38 | return deactivationResult;
39 | }
40 | const auditResult = await userAuditLog.recordUserRestriction(
41 | userID,
42 | AccountRestriction.Deactivated,
43 | options
44 | );
45 | if (isError(auditResult)) {
46 | log.error("Unable to audit deactivation", userID);
47 | }
48 | return deactivationResult;
49 | }
50 |
--------------------------------------------------------------------------------
/.github/workflows/docker-hub-develop.yml:
--------------------------------------------------------------------------------
1 | # SPDX-FileCopyrightText: 2024 Gnuxie
2 | #
3 | # SPDX-License-Identifier: CC0-1.0
4 |
5 | # Copied from https://github.com/matrix-org/matrix-bifrost/blob/develop/.github/workflows/docker-hub-latest.yml
6 |
7 | name: "Docker Hub - Develop"
8 |
9 | on:
10 | push:
11 | branches:
12 | - main
13 |
14 | env:
15 | DOCKER_NAMESPACE: gnuxie
16 | PLATFORMS: linux/amd64,linux/arm64
17 | # Only push if this is main, otherwise we just want to build
18 | PUSH: ${{ github.ref == 'refs/heads/main' }}
19 |
20 | jobs:
21 | docker-latest:
22 | runs-on: ubuntu-latest
23 | permissions:
24 | id-token: write
25 | packages: write
26 | contents: read
27 | attestations: write
28 | steps:
29 | - name: Check out
30 | uses: actions/checkout@v4
31 | - name: Unshallow for git describe so we can create version.txt
32 | run: git fetch --prune --unshallow --tags --all --force
33 |
34 | # Needed for multi platform builds
35 | - name: Set up QEMU
36 | uses: docker/setup-qemu-action@v3
37 | with:
38 | platforms: ${{ env.PLATFORMS }}
39 |
40 | - name: Set up Docker Buildx
41 | uses: docker/setup-buildx-action@v3
42 |
43 | - name: Log in to Docker Hub
44 | uses: docker/login-action@v3
45 | with:
46 | username: ${{ secrets.DOCKERHUB_USERNAME }}
47 | password: ${{ secrets.DOCKERHUB_TOKEN }}
48 |
49 | - name: Build image
50 | id: push
51 | uses: docker/build-push-action@v5
52 | with:
53 | context: .
54 | file: ./Dockerfile
55 | platforms: ${{ env.PLATFORMS }}
56 | push: ${{ env.PUSH }}
57 | tags: |
58 | ${{ env.DOCKER_NAMESPACE }}/draupnir:develop
59 |
--------------------------------------------------------------------------------
/src/commands/server-admin/UnrestrictCommand.tsx:
--------------------------------------------------------------------------------
1 | // SPDX-FileCopyrightText: 2025 Gnuxie
2 | //
3 | // SPDX-License-Identifier: AFL-3.0
4 |
5 | import {
6 | BasicInvocationInformation,
7 | describeCommand,
8 | MatrixUserIDPresentationType,
9 | tuple,
10 | } from "@the-draupnir-project/interface-manager";
11 | import { Draupnir } from "../../Draupnir";
12 | import { Result, ResultError } from "@gnuxie/typescript-result";
13 | import { SynapseAdminUserSuspensionCapability } from "../../protections/HomeserverUserPolicyApplication/UserSuspensionCapability";
14 | import { DraupnirInterfaceAdaptor } from "../DraupnirCommandPrerequisites";
15 |
16 | export const SynpaseAdminUnrestrictUserCommand = describeCommand({
17 | summary:
18 | "Unrestrict a user on the homeserver if their account has been suspended or shadowbanned",
19 | parameters: tuple({
20 | name: "user",
21 | description: "The user to unrestrict",
22 | acceptor: MatrixUserIDPresentationType,
23 | }),
24 | async executor(
25 | draupnir: Draupnir,
26 | info: BasicInvocationInformation,
27 | _keywords,
28 | _rest,
29 | targetUser
30 | ): Promise> {
31 | if (
32 | draupnir.synapseAdminClient === undefined ||
33 | draupnir.stores.userRestrictionAuditLog === undefined
34 | ) {
35 | return ResultError.Result(
36 | "This command cannot be used without synapse admin"
37 | );
38 | }
39 | const capability = new SynapseAdminUserSuspensionCapability(
40 | draupnir.synapseAdminClient,
41 | draupnir.stores.userRestrictionAuditLog
42 | );
43 | return await capability.unrestrictUser(
44 | targetUser.toString(),
45 | info.commandSender
46 | );
47 | },
48 | });
49 |
50 | DraupnirInterfaceAdaptor.describeRenderer(SynpaseAdminUnrestrictUserCommand, {
51 | isAlwaysSupposedToUseDefaultRenderer: true,
52 | });
53 |
--------------------------------------------------------------------------------
/src/capabilities/RendererMessageCollector.ts:
--------------------------------------------------------------------------------
1 | // SPDX-FileCopyrightText: 2024 Gnuxie
2 | //
3 | // SPDX-License-Identifier: AFL-3.0
4 |
5 | import { DocumentNode } from "@the-draupnir-project/interface-manager";
6 | import { Capability, DescriptionMeta } from "matrix-protection-suite";
7 |
8 | export enum MessageType {
9 | Document = "Document",
10 | OneLine = "OneLine",
11 | SingleEffectError = "SingleEffectError",
12 | }
13 |
14 | export interface RendererMessageCollector {
15 | addMessage(
16 | protection: DescriptionMeta,
17 | capability: Capability,
18 | message: DocumentNode
19 | ): void;
20 | addOneliner(
21 | protection: DescriptionMeta,
22 | capability: Capability,
23 | message: DocumentNode
24 | ): void;
25 | getMessages(): RendererMessage[];
26 | }
27 |
28 | export interface RendererMessage {
29 | protection: DescriptionMeta;
30 | capability: Capability;
31 | message: DocumentNode;
32 | type: MessageType;
33 | }
34 |
35 | /**
36 | * Used by capabilities to send messages to the users of Draupnir.
37 | */
38 | export class AbstractRendererMessageCollector
39 | implements RendererMessageCollector
40 | {
41 | private readonly messages: RendererMessage[] = [];
42 | public getMessages(): RendererMessage[] {
43 | return this.messages;
44 | }
45 | addMessage(
46 | protection: DescriptionMeta,
47 | capability: Capability,
48 | message: DocumentNode
49 | ): void {
50 | this.messages.push({
51 | protection,
52 | capability,
53 | message,
54 | type: MessageType.Document,
55 | });
56 | }
57 |
58 | addOneliner(
59 | protection: DescriptionMeta,
60 | capability: Capability,
61 | message: DocumentNode
62 | ): void {
63 | this.messages.push({
64 | protection,
65 | capability,
66 | message,
67 | type: MessageType.OneLine,
68 | });
69 | }
70 | }
71 |
--------------------------------------------------------------------------------
/.github/workflows/docker-hub-latest.yml:
--------------------------------------------------------------------------------
1 | # SPDX-FileCopyrightText: 2024 Gnuxie
2 | #
3 | # SPDX-License-Identifier: CC0-1.0
4 |
5 | # Copied from https://github.com/matrix-org/matrix-bifrost/blob/develop/.github/workflows/docker-hub-release.yml
6 | # This is duplicated with docker-hub-release.yml the only difference is the tag and hook
7 |
8 | name: "Docker Hub - Latest"
9 |
10 | on:
11 | release:
12 | types: [released]
13 |
14 | env:
15 | DOCKER_NAMESPACE: gnuxie
16 | PLATFORMS: linux/amd64,linux/arm64
17 |
18 | jobs:
19 | docker-release:
20 | runs-on: ubuntu-latest
21 | permissions:
22 | id-token: write
23 | packages: write
24 | contents: read
25 | attestations: write
26 | steps:
27 | - name: Check out
28 | uses: actions/checkout@v4
29 | - name: Get release tag
30 | run: echo "RELEASE_VERSION=${GITHUB_REF#refs/*/}" >> $GITHUB_ENV
31 | - name: Unshallow for git describe so we can create version.txt
32 | run: git fetch --prune --unshallow --tags --all --force
33 | # Needed for multi platform builds
34 | - name: Set up QEMU
35 | uses: docker/setup-qemu-action@v3
36 | with:
37 | platforms: ${{ env.PLATFORMS }}
38 |
39 | - name: Set up Docker Buildx
40 | uses: docker/setup-buildx-action@v3
41 |
42 | - name: Log in to Docker Hub
43 | uses: docker/login-action@v3
44 | with:
45 | username: ${{ secrets.DOCKERHUB_USERNAME }}
46 | password: ${{ secrets.DOCKERHUB_TOKEN }}
47 |
48 | - name: Build image
49 | id: push
50 | uses: docker/build-push-action@v5
51 | with:
52 | context: .
53 | file: ./Dockerfile
54 | platforms: ${{ env.PLATFORMS }}
55 | push: true
56 | sbom: true
57 | tags: |
58 | ${{ env.DOCKER_NAMESPACE }}/draupnir:latest
59 |
--------------------------------------------------------------------------------
/.github/workflows/docker-hub-release.yml:
--------------------------------------------------------------------------------
1 | # SPDX-FileCopyrightText: 2024 Gnuxie
2 | #
3 | # SPDX-License-Identifier: CC0-1.0
4 |
5 | # Copied from https://github.com/matrix-org/matrix-bifrost/blob/develop/.github/workflows/docker-hub-release.yml
6 | # This is duplicated with docker-hub-latest.yml the only difference is the tag and hook
7 |
8 | name: "Docker Hub - Release"
9 |
10 | on:
11 | release:
12 | types: [published]
13 |
14 | env:
15 | DOCKER_NAMESPACE: gnuxie
16 | PLATFORMS: linux/amd64,linux/arm64
17 |
18 | jobs:
19 | docker-release:
20 | runs-on: ubuntu-latest
21 | permissions:
22 | id-token: write
23 | packages: write
24 | contents: read
25 | attestations: write
26 | steps:
27 | - name: Check out
28 | uses: actions/checkout@v4
29 | - name: Get release tag
30 | run: echo "RELEASE_VERSION=${GITHUB_REF#refs/*/}" >> $GITHUB_ENV
31 | - name: Unshallow for git describe so we can create version.txt
32 | run: git fetch --prune --unshallow --tags --all --force
33 |
34 | # Needed for multi platform builds
35 | - name: Set up QEMU
36 | uses: docker/setup-qemu-action@v3
37 | with:
38 | platforms: ${{ env.PLATFORMS }}
39 |
40 | - name: Set up Docker Buildx
41 | uses: docker/setup-buildx-action@v3
42 |
43 | - name: Log in to Docker Hub
44 | uses: docker/login-action@v3
45 | with:
46 | username: ${{ secrets.DOCKERHUB_USERNAME }}
47 | password: ${{ secrets.DOCKERHUB_TOKEN }}
48 |
49 | - name: Build image
50 | id: push
51 | uses: docker/build-push-action@v5
52 | with:
53 | context: .
54 | file: ./Dockerfile
55 | platforms: ${{ env.PLATFORMS }}
56 | push: true
57 | sbom: true
58 | tags: |
59 | ${{ env.DOCKER_NAMESPACE }}/draupnir:${{ env.RELEASE_VERSION }}
60 |
--------------------------------------------------------------------------------
/test/nginx.conf:
--------------------------------------------------------------------------------
1 | # SPDX-FileCopyrightText: 2024 Gnuxie
2 | #
3 | # SPDX-License-Identifier: CC0-1.0
4 |
5 | events {
6 |
7 | }
8 |
9 | http {
10 | server {
11 | listen [::]:8081 ipv6only=off;
12 |
13 | location ~ ^/_matrix/client/(r0|v3)/rooms/([^/]*)/report/(.*)$ {
14 | mirror /report_mirror;
15 | # Abuse reports should be sent to Draupnir.
16 | # The r0 endpoint is deprecated but still used by many clients.
17 | # As of this writing, the v3 endpoint is the up-to-date version.
18 |
19 | # Add CORS, otherwise a browser will refuse this request.
20 | add_header 'Access-Control-Allow-Origin' '*' always; # Note: '*' is for testing purposes. For your own server, you probably want to tighten this.
21 | add_header 'Access-Control-Allow-Credentials' 'true' always;
22 | add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS' always;
23 | add_header 'Access-Control-Allow-Headers' 'Authorization,Content-Type,Accept,Origin,User-Agent,DNT,Cache-Control,X-Mx-ReqToken,Keep-Alive,X-Requested-With,If-Modified-Since' always;
24 | add_header 'Access-Control-Expose-Headers' 'Content-Length,Content-Range' always;
25 | add_header 'Access-Control-Max-Age' 1728000; # cache preflight value for 20 days
26 |
27 | # Alias the regexps, to ensure that they're not rewritten.
28 | set $room_id $2;
29 | set $event_id $3;
30 | proxy_pass http://127.0.0.1:8082/api/1/report/$room_id/$event_id;
31 | }
32 | location / {
33 | # Everything else should be sent to Synapse.
34 | proxy_pass http://127.0.0.1:9999;
35 | }
36 | location /report_mirror {
37 | internal;
38 | proxy_pass http://127.0.0.1:9999$request_uri;
39 | }
40 | # draupnir news blob
41 | location /draupnir_news.json {
42 | root /var/www/test;
43 | default_type application/json;
44 | }
45 | }
46 | }
47 |
--------------------------------------------------------------------------------
/test/appservice/integration/safeModeRecoverTest.ts:
--------------------------------------------------------------------------------
1 | // SPDX-FileCopyrightText: 2024 Gnuxie
2 | //
3 | // SPDX-License-Identifier: AFL-3.0
4 |
5 | import { StringUserID } from "@the-draupnir-project/matrix-basic-types";
6 | import { MjolnirAppService } from "../../../src/appservice/AppService";
7 | import { newTestUser } from "../../integration/clientHelper";
8 | import { StandardProvisionHelper } from "../utils/ProvisionHelper";
9 | import { setupHarness } from "../utils/harness";
10 | import { testRecoverAndRestart } from "../../integration/commands/recoverCommandDetail";
11 |
12 | interface Context extends Mocha.Context {
13 | appservice?: MjolnirAppService;
14 | }
15 |
16 | describe("Test safe mode recovery on a provisioned Draupnir", function () {
17 | beforeEach(async function (this: Context) {
18 | this.appservice = await setupHarness();
19 | });
20 | afterEach(function (this: Context) {
21 | if (this.appservice) {
22 | return this.appservice.close();
23 | } else {
24 | console.warn("Missing Appservice in this context, so cannot stop it.");
25 | return Promise.resolve(); // TS7030: Not all code paths return a value.
26 | }
27 | });
28 | it("Provisioned draupnir can be recovered from safe mode.", async function (this: Context) {
29 | const appservice = this.appservice;
30 | if (appservice === undefined) {
31 | throw new TypeError(`Test setup failed`);
32 | }
33 | const provisionHelper = new StandardProvisionHelper(appservice);
34 | const moderator = await newTestUser(appservice.config.homeserver.url, {
35 | name: { contains: "moderator" },
36 | });
37 | const moderatorUserID = (await moderator.getUserId()) as StringUserID;
38 | const initialDraupnir = (
39 | await provisionHelper.provisionDraupnir(moderatorUserID)
40 | ).expect("Failed to provision a draupnir for the test");
41 | await testRecoverAndRestart(moderatorUserID, initialDraupnir);
42 | });
43 | });
44 |
--------------------------------------------------------------------------------
/src/appservice/bot/AccessCommands.tsx:
--------------------------------------------------------------------------------
1 | // Copyright 2022 Gnuxie
2 | //
3 | // SPDX-License-Identifier: AFL-3.0
4 |
5 | import { AppserviceAdaptorContext } from "./AppserviceBotPrerequisite";
6 | import { ActionResult } from "matrix-protection-suite";
7 | import {
8 | MatrixUserIDPresentationType,
9 | describeCommand,
10 | tuple,
11 | } from "@the-draupnir-project/interface-manager";
12 | import { AppserviceBotInterfaceAdaptor } from "./AppserviceBotInterfaceAdaptor";
13 |
14 | export const AppserviceAllowCommand = describeCommand({
15 | parameters: tuple({
16 | name: "user",
17 | acceptor: MatrixUserIDPresentationType,
18 | description: "The user that should be allowed to provision a bot",
19 | }),
20 | summary:
21 | "Allow a user to provision themselves a draupnir using the appservice.",
22 | async executor(
23 | context: AppserviceAdaptorContext,
24 | _info,
25 | _keywords,
26 | _rest,
27 | user
28 | ): Promise> {
29 | return await context.appservice.accessControl.allow(user.toString());
30 | },
31 | });
32 |
33 | AppserviceBotInterfaceAdaptor.describeRenderer(AppserviceAllowCommand, {
34 | isAlwaysSupposedToUseDefaultRenderer: true,
35 | });
36 |
37 | export const AppserviceRemoveCommand = describeCommand({
38 | parameters: tuple({
39 | name: "user",
40 | acceptor: MatrixUserIDPresentationType,
41 | description:
42 | "The user which shall not be allowed to provision bots anymore",
43 | }),
44 | summary: "Stop a user from using any provisioned draupnir in the appservice.",
45 | async executor(
46 | context: AppserviceAdaptorContext,
47 | _info,
48 | _keywords,
49 | _rest,
50 | user
51 | ): Promise> {
52 | return await context.appservice.accessControl.remove(user.toString());
53 | },
54 | });
55 |
56 | AppserviceBotInterfaceAdaptor.describeRenderer(AppserviceRemoveCommand, {
57 | isAlwaysSupposedToUseDefaultRenderer: true,
58 | });
59 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # SPDX-FileCopyrightText: 2024 Gnuxie
2 | #
3 | # SPDX-License-Identifier: CC0-1.0
4 |
5 | # Draupnir config
6 | config/*
7 | !config/default.yaml
8 |
9 | # Draupnir test data
10 | draupnir-registration.yaml
11 | test/harness/mjolnir-data/*
12 |
13 | # Misc
14 |
15 | lib/
16 | storage/
17 | venv/
18 |
19 | /.idea
20 |
21 | /db
22 |
23 | # version file generated from yarn build
24 | version.txt
25 | # Logs
26 | logs
27 | *.log
28 | npm-debug.log*
29 | yarn-debug.log*
30 | yarn-error.log*
31 |
32 | # Runtime data
33 | pids
34 | *.pid
35 | *.seed
36 | *.pid.lock
37 |
38 | # Directory for instrumented libs generated by jscoverage/JSCover
39 | lib-cov
40 |
41 | # Coverage directory used by tools like istanbul
42 | coverage
43 |
44 | # nyc test coverage
45 | .nyc_output
46 |
47 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files)
48 | .grunt
49 |
50 | # Bower dependency directory (https://bower.io/)
51 | bower_components
52 |
53 | # node-waf configuration
54 | .lock-wscript
55 |
56 | # Compiled binary addons (https://nodejs.org/api/addons.html)
57 | build/Release
58 |
59 | # Dependency directories
60 | node_modules/
61 | jspm_packages/
62 |
63 | # TypeScript v1 declaration files
64 | typings/
65 |
66 | # Optional npm cache directory
67 | .npm
68 |
69 | # Optional eslint cache
70 | .eslintcache
71 |
72 | # Optional REPL history
73 | .node_repl_history
74 |
75 | # Output of 'npm pack'
76 | *.tgz
77 |
78 | # Yarn Integrity file
79 | .yarn-integrity
80 |
81 | # dotenv environment variables file
82 | .env
83 |
84 | # next.js build output
85 | .next
86 |
87 | # Python packing directories.
88 | mjolnir.egg-info/
89 |
90 | # VS
91 | .vs
92 |
93 | # Yarn versions through corepack
94 | .yarn
95 | # Only contains possibly broken path, replaced by .yarnrc.yml in modern Yarn
96 | .yarnrc
97 |
98 | # VS Code workspace settings
99 | /.vscode
100 |
--------------------------------------------------------------------------------
/test/unit/protections/MentionLimitProtectionTest.ts:
--------------------------------------------------------------------------------
1 | // Copyright 2024 Gnuxie
2 | // Copyright 2024 The Matrix.org Foundation C.I.C.
3 | //
4 | // SPDX-License-Identifier: Apache-2.0
5 |
6 | import {
7 | DefaultMixinExtractor,
8 | EventWithMixins,
9 | randomRoomID,
10 | randomUserID,
11 | RoomEvent,
12 | } from "matrix-protection-suite";
13 | import { isContainingMentionsOverLimit } from "../../../src/protections/MentionLimitProtection";
14 | import expect from "expect";
15 |
16 | function messageEvent(content: {
17 | body?: string;
18 | formatted_body?: string;
19 | "m.mentions"?: { user_ids: string[] };
20 | }): EventWithMixins {
21 | return DefaultMixinExtractor.parseEvent({
22 | content,
23 | type: "m.room.message",
24 | sender: randomUserID(),
25 | room_id: randomRoomID([]).toRoomIDOrAlias(),
26 | } as RoomEvent);
27 | }
28 |
29 | describe("MentionLimitProtection test", function () {
30 | it("Allows normal events", function () {
31 | expect(
32 | isContainingMentionsOverLimit(
33 | messageEvent({ body: "Hello", formatted_body: "Hello" }),
34 | 1,
35 | true
36 | )
37 | ).toBe(false);
38 | });
39 | it("Detects mentions in the body", function () {
40 | expect(
41 | isContainingMentionsOverLimit(
42 | messageEvent({ body: "Hello @admin:example.com" }),
43 | 0,
44 | true
45 | )
46 | ).toBe(true);
47 | });
48 | it("Detects mentions from m.mentions", function () {
49 | expect(
50 | isContainingMentionsOverLimit(
51 | messageEvent({ "m.mentions": { user_ids: ["@admin:example.com"] } }),
52 | 0,
53 | true
54 | )
55 | ).toBe(true);
56 | });
57 | it("Allows mentions under the limit", function () {
58 | expect(
59 | isContainingMentionsOverLimit(
60 | messageEvent({ "m.mentions": { user_ids: ["@admin:example.com"] } }),
61 | 1,
62 | true
63 | )
64 | ).toBe(false);
65 | });
66 | });
67 |
--------------------------------------------------------------------------------
/src/capabilities/DraupnirRendererMessageCollector.tsx:
--------------------------------------------------------------------------------
1 | // SPDX-FileCopyrightText: 2024 Gnuxie
2 | //
3 | // SPDX-License-Identifier: AFL-3.0
4 |
5 | import {
6 | RendererMessage,
7 | RendererMessageCollector,
8 | } from "./RendererMessageCollector";
9 | import {
10 | Capability,
11 | DescriptionMeta,
12 | RoomMessageSender,
13 | Task,
14 | } from "matrix-protection-suite";
15 | import { StringRoomID } from "@the-draupnir-project/matrix-basic-types";
16 | import {
17 | DeadDocumentJSX,
18 | DocumentNode,
19 | } from "@the-draupnir-project/interface-manager";
20 | import { sendMatrixEventsFromDeadDocument } from "@the-draupnir-project/mps-interface-adaptor";
21 |
22 | export class DraupnirRendererMessageCollector
23 | implements RendererMessageCollector
24 | {
25 | constructor(
26 | private readonly roomMessageSender: RoomMessageSender,
27 | private readonly managementRoomID: StringRoomID
28 | ) {
29 | // nothing to do.
30 | }
31 | private sendMessage(capability: Capability, document: DocumentNode): void {
32 | void Task(
33 | sendMatrixEventsFromDeadDocument(
34 | this.roomMessageSender,
35 | this.managementRoomID,
36 |
37 | {capability.isSimulated ? (
38 | ⚠️ (preview)
39 | ) : (
40 |
41 | )}
42 | {document}
43 | ,
44 | {}
45 | )
46 | );
47 | }
48 | addMessage(
49 | protection: DescriptionMeta,
50 | capability: Capability,
51 | message: DocumentNode
52 | ): void {
53 | this.sendMessage(capability, message);
54 | }
55 | addOneliner(
56 | protection: DescriptionMeta,
57 | capability: Capability,
58 | message: DocumentNode
59 | ): void {
60 | this.sendMessage(
61 | capability,
62 |
63 | {protection.name}: {message}
64 |
65 | );
66 | }
67 | getMessages(): RendererMessage[] {
68 | return [];
69 | }
70 | }
71 |
--------------------------------------------------------------------------------
/src/protections/DraupnirNews/news.json:
--------------------------------------------------------------------------------
1 | {
2 | "news": [
3 | {
4 | "news_id": "cd1881d2-60d0-49ad-9951-321205efa64b",
5 | "matrix_event_content": {
6 | "msgtype": "m.notice",
7 | "body": "#### 📰 Draupnir Assembly: Call for Participation\n\nThe Longhouse Assembly is discussing the next direction of the project.\n\nIf you value what the Draupnir project does for your community, then we really want to hear from you:\n\n- ➡️ [Read about the current longhouse cycle](https://the-draupnir-project.github.io/draupnir-documentation/governance/reports/2510A-cycle-review)\n- ➡️ [Read about the pathways going forward](https://the-draupnir-project.github.io/draupnir-documentation/governance/reports/2510A-selection)\n- ➡️ [Cast your vote](https://cryptpad.fr/form/#/2/form/view/ewtgdO-YIwCjLhfJpwsj87m7RU7v6hJKHbu3BWqa1kg/)\n- ➡️ [Join the Assembly Discussion](https://matrix.to/#/!UMROhYUQcvtGuoIIka:matrix.org/%247C2Sv-B-6HJ7fVMlCRd3R9jlZqe2rUxlPliEaB-M4yE?via=matrix.org&via=feline.support&via=asgard.chat)",
8 | "format": "org.matrix.custom.html",
9 | "formatted_body": "📰 Draupnir Assembly: Call for Participation
\nThe Longhouse Assembly is discussing the next direction of the project.
\nIf you value what the Draupnir project does for your community, then we really want to hear from you:
\n\n"
10 | }
11 | }
12 | ]
13 | }
14 |
--------------------------------------------------------------------------------
/test/appservice/utils/harness.ts:
--------------------------------------------------------------------------------
1 | // Copyright 2022 Gnuxie
2 | // Copyright 2022 The Matrix.org Foundation C.I.C.
3 | //
4 | // SPDX-License-Identifier: AFL-3.0 AND Apache-2.0
5 | //
6 | // SPDX-FileAttributionText:
7 | // This modified file incorporates work from mjolnir
8 | // https://github.com/matrix-org/mjolnir
9 | //
10 |
11 | import path from "path";
12 | import { MjolnirAppService } from "../../../src/appservice/AppService";
13 | import { ensureAliasedRoomExists } from "../../integration/mjolnirSetupUtils";
14 | import {
15 | read as configRead,
16 | AppserviceConfig,
17 | } from "../../../src/appservice/config/config";
18 | import { newTestUser } from "../../integration/clientHelper";
19 | import { CreateEvent, MatrixClient } from "matrix-bot-sdk";
20 | import { POLICY_ROOM_TYPE_VARIANTS } from "matrix-protection-suite";
21 | import { isStringRoomAlias } from "@the-draupnir-project/matrix-basic-types";
22 |
23 | export function readTestConfig(): AppserviceConfig {
24 | return configRead(
25 | path.join(__dirname, "../../../src/appservice/config/config.harness.yaml")
26 | );
27 | }
28 |
29 | export async function setupHarness(): Promise {
30 | const config = readTestConfig();
31 | const utilityUser = await newTestUser(config.homeserver.url, {
32 | name: { contains: "utility" },
33 | });
34 | if (
35 | typeof config.adminRoom !== "string" ||
36 | !isStringRoomAlias(config.adminRoom)
37 | ) {
38 | throw new TypeError(
39 | "This test expects the harness config to have a room alias."
40 | );
41 | }
42 | await ensureAliasedRoomExists(utilityUser, config.adminRoom);
43 | return await MjolnirAppService.run(
44 | 9000,
45 | config,
46 | "draupnir-registration.yaml"
47 | );
48 | }
49 |
50 | export async function isPolicyRoom(
51 | user: MatrixClient,
52 | roomId: string
53 | ): Promise {
54 | const createEvent = new CreateEvent(
55 | await user.getRoomStateEvent(roomId, "m.room.create", "")
56 | );
57 | return POLICY_ROOM_TYPE_VARIANTS.includes(createEvent.type);
58 | }
59 |
--------------------------------------------------------------------------------
/src/appservice/postgres/PgDataStore.ts:
--------------------------------------------------------------------------------
1 | // Copyright 2022 The Matrix.org Foundation C.I.C.
2 | //
3 | // SPDX-License-Identifier: Apache-2.0
4 |
5 | import { PostgresStore, SchemaUpdateFunction } from "matrix-appservice-bridge";
6 | import { DataStore, MjolnirRecord } from "../datastore";
7 |
8 | function getSchema(): SchemaUpdateFunction[] {
9 | const nSchema = 2;
10 | const schema = [];
11 | for (let schemaID = 1; schemaID < nSchema + 1; schemaID++) {
12 | // eslint-disable-next-line @typescript-eslint/no-var-requires
13 | schema.push(require(`./schema/v${schemaID}`).runSchema);
14 | }
15 | return schema;
16 | }
17 |
18 | export class PgDataStore extends PostgresStore implements DataStore {
19 | constructor(connectionString: string) {
20 | super(getSchema(), { url: connectionString });
21 | }
22 |
23 | public async init(): Promise {
24 | await this.ensureSchema();
25 | }
26 |
27 | public async close(): Promise {
28 | await this.destroy();
29 | }
30 |
31 | public async list(): Promise {
32 | const result = await this
33 | .sql`SELECT local_part, owner, management_room FROM draupnir`;
34 | if (!result.count) {
35 | return [];
36 | }
37 |
38 | return result.flat() as MjolnirRecord[];
39 | }
40 |
41 | public async store(mjolnirRecord: MjolnirRecord): Promise {
42 | await this.sql`INSERT INTO draupnir (local_part, owner, management_room)
43 | VALUES (${mjolnirRecord.local_part}, ${mjolnirRecord.owner}, ${mjolnirRecord.management_room})`;
44 | }
45 |
46 | public async lookupByOwner(owner: string): Promise {
47 | const result = await this
48 | .sql`SELECT local_part, owner, management_room FROM draupnir
49 | WHERE owner = ${owner}`;
50 | return result.flat() as MjolnirRecord[];
51 | }
52 |
53 | public async lookupByLocalPart(localPart: string): Promise {
54 | const result = await this
55 | .sql`SELECT local_part, owner, management_room FROM draupnir
56 | WHERE local_part = ${localPart}`;
57 | return result.flat() as MjolnirRecord[];
58 | }
59 | }
60 |
--------------------------------------------------------------------------------
/test/integration/commands/shutdownCommandTest.ts:
--------------------------------------------------------------------------------
1 | // Copyright 2022 Gnuxie
2 | // Copyright 2022 The Matrix.org Foundation C.I.C.
3 | //
4 | // SPDX-License-Identifier: AFL-3.0 AND Apache-2.0
5 | //
6 | // SPDX-FileAttributionText:
7 | // This modified file incorporates work from mjolnir
8 | // https://github.com/matrix-org/mjolnir
9 | //
10 |
11 | import { strict as assert } from "assert";
12 |
13 | import { newTestUser } from "../clientHelper";
14 | import {
15 | DraupnirTestContext,
16 | draupnirClient,
17 | draupnirSafeEmitter,
18 | } from "../mjolnirSetupUtils";
19 | import { MatrixClient, MatrixError } from "matrix-bot-sdk";
20 | import { getFirstReaction } from "./commandUtils";
21 |
22 | describe("Test: shutdown command", function () {
23 | let client: MatrixClient;
24 | this.beforeEach(async function () {
25 | client = await newTestUser(this.config.homeserverUrl, {
26 | name: { contains: "shutdown-command" },
27 | });
28 | await client.start();
29 | });
30 | this.afterEach(async function () {
31 | client.stop();
32 | });
33 | it("Draupnir asks synapse to shut down a channel", async function (
34 | this: DraupnirTestContext
35 | ) {
36 | this.timeout(20000);
37 | const badRoom = await client.createRoom();
38 | const draupnir = this.draupnir;
39 | const draupnirMatrixClient = draupnirClient();
40 | if (draupnir === undefined || draupnirMatrixClient === null) {
41 | throw new TypeError(`setup code is wrong`);
42 | }
43 | await client.joinRoom(draupnir.managementRoomID);
44 |
45 | await getFirstReaction(
46 | draupnirSafeEmitter(),
47 | draupnir.managementRoomID,
48 | "✅",
49 | async () =>
50 | await client.sendMessage(draupnir.managementRoomID, {
51 | msgtype: "m.text",
52 | body: `!draupnir shutdown room ${badRoom} closure test`,
53 | })
54 | );
55 |
56 | await assert.rejects(client.joinRoom(badRoom), (e: MatrixError) => {
57 | assert.equal(e.statusCode, 403);
58 | assert.equal(e.body.error, "This room has been blocked on this server");
59 | return true;
60 | });
61 | } as unknown as Mocha.AsyncFunc);
62 | });
63 |
--------------------------------------------------------------------------------
/test/integration/helloTest.ts:
--------------------------------------------------------------------------------
1 | // Copyright 2022 - 2024 Gnuxie
2 | // Copyright 2021 - 2022 The Matrix.org Foundation C.I.C.
3 | //
4 | // SPDX-License-Identifier: AFL-3.0 AND Apache-2.0
5 | //
6 | // SPDX-FileAttributionText:
7 | // This modified file incorporates work from mjolnir
8 | // https://github.com/matrix-org/mjolnir
9 | //
10 |
11 | import { MatrixClient } from "matrix-bot-sdk";
12 | import { newTestUser, noticeListener } from "./clientHelper";
13 | import { DraupnirTestContext } from "./mjolnirSetupUtils";
14 | import { SafeMatrixEmitterWrapper } from "matrix-protection-suite-for-matrix-bot-sdk";
15 | import { DefaultEventDecoder } from "matrix-protection-suite";
16 |
17 | describe("Test: !help command", function () {
18 | let client: MatrixClient;
19 | this.beforeEach(async function (this: DraupnirTestContext) {
20 | client = await newTestUser(this.config.homeserverUrl, {
21 | name: { contains: "-" },
22 | });
23 | await client.start();
24 | } as unknown as Mocha.AsyncFunc);
25 | this.afterEach(async function () {
26 | client.stop();
27 | } as unknown as Mocha.AsyncFunc);
28 | it("Draupnir responded to !mjolnir help", async function (
29 | this: DraupnirTestContext
30 | ) {
31 | this.timeout(30000);
32 | // send a messgage
33 | const draupnir = this.draupnir;
34 | const clientEmitter = new SafeMatrixEmitterWrapper(
35 | client,
36 | DefaultEventDecoder
37 | );
38 | if (draupnir === undefined) {
39 | throw new TypeError(`setup code is wrong`);
40 | }
41 | await client.joinRoom(this.config.managementRoom);
42 | // listener for getting the event reply
43 | const reply = new Promise((resolve) => {
44 | clientEmitter.on(
45 | "room.message",
46 | noticeListener(draupnir.managementRoomID, (event) => {
47 | if (event.content.body.includes("which can be used")) {
48 | resolve(event);
49 | }
50 | })
51 | );
52 | });
53 | await client.sendMessage(draupnir.managementRoomID, {
54 | msgtype: "m.text",
55 | body: "!draupnir help",
56 | });
57 | await reply;
58 | } as unknown as Mocha.AsyncFunc);
59 | });
60 |
--------------------------------------------------------------------------------
/src/safemode/SafeModeCommandDispatcher.ts:
--------------------------------------------------------------------------------
1 | // SPDX-FileCopyrightText: 2024 Gnuxie
2 | //
3 | // SPDX-License-Identifier: AFL-3.0
4 |
5 | import {
6 | BasicInvocationInformation,
7 | JSInterfaceCommandDispatcher,
8 | MatrixInterfaceCommandDispatcher,
9 | StandardJSInterfaceCommandDispatcher,
10 | StandardMatrixInterfaceCommandDispatcher,
11 | } from "@the-draupnir-project/interface-manager";
12 | import { SafeModeDraupnir } from "./DraupnirSafeMode";
13 | import {
14 | MPSCommandDispatcherCallbacks,
15 | MatrixEventContext,
16 | invocationInformationFromMatrixEventcontext,
17 | } from "@the-draupnir-project/mps-interface-adaptor";
18 | import { SafeModeCommands } from "./commands/SafeModeCommands";
19 | import { SafeModeHelpCommand } from "./commands/HelpCommand";
20 | import {
21 | SafeModeContextToCommandContextTranslator,
22 | SafeModeInterfaceAdaptor,
23 | } from "./commands/SafeModeAdaptor";
24 | import { makeDraupnirCommandNormaliser } from "../commands/DraupnirCommandDispatcher";
25 |
26 | export function makeSafeModeCommandDispatcher(
27 | safeModeDraupnir: SafeModeDraupnir
28 | ): MatrixInterfaceCommandDispatcher {
29 | return new StandardMatrixInterfaceCommandDispatcher(
30 | SafeModeInterfaceAdaptor,
31 | safeModeDraupnir,
32 | SafeModeCommands,
33 | SafeModeHelpCommand,
34 | invocationInformationFromMatrixEventcontext,
35 | {
36 | ...MPSCommandDispatcherCallbacks,
37 | commandNormaliser: makeDraupnirCommandNormaliser(
38 | safeModeDraupnir.clientUserID,
39 | safeModeDraupnir,
40 | safeModeDraupnir.config
41 | ),
42 | }
43 | );
44 | }
45 |
46 | export function makeSafeModeJSDispatcher(
47 | safeModeDraupnir: SafeModeDraupnir
48 | ): JSInterfaceCommandDispatcher {
49 | return new StandardJSInterfaceCommandDispatcher(
50 | SafeModeCommands,
51 | SafeModeHelpCommand,
52 | safeModeDraupnir,
53 | {
54 | ...MPSCommandDispatcherCallbacks,
55 | commandNormaliser: makeDraupnirCommandNormaliser(
56 | safeModeDraupnir.clientUserID,
57 | safeModeDraupnir,
58 | safeModeDraupnir.config
59 | ),
60 | },
61 | SafeModeContextToCommandContextTranslator
62 | );
63 | }
64 |
--------------------------------------------------------------------------------
/test/appservice/integration/safeModeToggleTest.ts:
--------------------------------------------------------------------------------
1 | // SPDX-FileCopyrightText: 2024 Gnuxie
2 | //
3 | // SPDX-License-Identifier: AFL-3.0
4 |
5 | import { StringUserID } from "@the-draupnir-project/matrix-basic-types";
6 | import { MjolnirAppService } from "../../../src/appservice/AppService";
7 | import { newTestUser } from "../../integration/clientHelper";
8 | import { StandardProvisionHelper } from "../utils/ProvisionHelper";
9 | import { setupHarness } from "../utils/harness";
10 | import { SafeModeDraupnir } from "../../../src/safemode/DraupnirSafeMode";
11 |
12 | interface Context extends Mocha.Context {
13 | appservice?: MjolnirAppService;
14 | }
15 |
16 | describe("Test safe mode commands on a provisioned Draupnir", function () {
17 | beforeEach(async function (this: Context) {
18 | this.appservice = await setupHarness();
19 | });
20 | afterEach(function (this: Context) {
21 | if (this.appservice) {
22 | return this.appservice.close();
23 | } else {
24 | console.warn("Missing Appservice in this context, so cannot stop it.");
25 | return Promise.resolve(); // TS7030: Not all code paths return a value.
26 | }
27 | });
28 | it("Provisioned draupnir can switch to safe mode and back.", async function (this: Context) {
29 | const appservice = this.appservice;
30 | if (appservice === undefined) {
31 | throw new TypeError(`Test setup failed`);
32 | }
33 | const provisionHelper = new StandardProvisionHelper(appservice);
34 | const moderator = await newTestUser(appservice.config.homeserver.url, {
35 | name: { contains: "moderator" },
36 | });
37 | const moderatorUserID = (await moderator.getUserId()) as StringUserID;
38 | const initialDraupnir = (
39 | await provisionHelper.provisionDraupnir(moderatorUserID)
40 | ).expect("Failed to provision a draupnir for the test");
41 | const safeModeDraupnir = (
42 | await initialDraupnir.sendTextCommand(
43 | moderatorUserID,
44 | "!draupnir safe mode"
45 | )
46 | ).expect("Failed to switch to safe mode");
47 | (
48 | await safeModeDraupnir.sendTextCommand(
49 | moderatorUserID,
50 | "!draupnir restart"
51 | )
52 | ).expect("Failed to restart back to draupnir from safe mode");
53 | });
54 | });
55 |
--------------------------------------------------------------------------------
/test/integration/throttleTest.ts:
--------------------------------------------------------------------------------
1 | // Copyright 2022 - 2024 Gnuxie
2 | // Copyright 2021 - 2022 The Matrix.org Foundation C.I.C.
3 | //
4 | // SPDX-License-Identifier: AFL-3.0 AND Apache-2.0
5 | //
6 | // SPDX-FileAttributionText:
7 | // This modified file incorporates work from mjolnir
8 | // https://github.com/matrix-org/mjolnir
9 | //
10 |
11 | import { strict as assert } from "assert";
12 | import { newTestUser } from "./clientHelper";
13 | import { getMessagesByUserIn } from "../../src/utils";
14 |
15 | describe("Test: throttled users can function with Draupnir.", function () {
16 | it("throttled users survive being throttled by synapse", async function () {
17 | const throttledUser = await newTestUser(this.config.homeserverUrl, {
18 | name: { contains: "throttled" },
19 | isThrottled: true,
20 | });
21 | const throttledUserId = await throttledUser.getUserId();
22 | const targetRoom = await throttledUser.createRoom();
23 | // send enough messages to hit the rate limit.
24 | await Promise.all(
25 | [...Array(25).keys()].map((i) =>
26 | throttledUser.sendMessage(targetRoom, {
27 | msgtype: "m.text.",
28 | body: `Message #${i}`,
29 | })
30 | )
31 | );
32 | let messageCount = 0;
33 | await getMessagesByUserIn(
34 | throttledUser,
35 | throttledUserId,
36 | targetRoom,
37 | 25,
38 | (events) => {
39 | messageCount += events.length;
40 | }
41 | );
42 | assert.equal(
43 | messageCount,
44 | 25,
45 | "There should have been 25 messages in this room"
46 | );
47 | });
48 | });
49 |
50 | /**
51 | * We used to have a test here that tested whether Draupnir was going to carry out a redact order the default limits in a reasonable time scale.
52 | * Now I think that's never going to happen without writing a new algorithm for respecting rate limiting.
53 | * Which is not something there is time for.
54 | *
55 | * https://github.com/matrix-org/synapse/pull/13018
56 | *
57 | * Synapse rate limits were broken and very permitting so that's why the current hack worked so well.
58 | * Now it is not broken, so our rate limit handling is.
59 | *
60 | * https://github.com/matrix-org/mjolnir/commit/b850e4554c6cbc9456e23ab1a92ede547d044241
61 | *
62 | * Honestly I don't think we can expect anyone to be able to use Draupnir under default rate limits.
63 | */
64 |
--------------------------------------------------------------------------------
/src/commands/CreateBanListCommand.ts:
--------------------------------------------------------------------------------
1 | // Copyright 2022 Gnuxie
2 | // Copyright 2019 The Matrix.org Foundation C.I.C.
3 | //
4 | // SPDX-License-Identifier: AFL-3.0 AND Apache-2.0
5 | //
6 | // SPDX-FileAttributionText:
7 | // This modified file incorporates work from mjolnir
8 | // https://github.com/matrix-org/mjolnir
9 | //
10 |
11 | import { ActionResult, isError } from "matrix-protection-suite";
12 | import { Draupnir } from "../Draupnir";
13 | import { MatrixRoomID } from "@the-draupnir-project/matrix-basic-types";
14 | import {
15 | BasicInvocationInformation,
16 | ParsedKeywords,
17 | StringPresentationType,
18 | describeCommand,
19 | tuple,
20 | } from "@the-draupnir-project/interface-manager";
21 | import { DraupnirInterfaceAdaptor } from "./DraupnirCommandPrerequisites";
22 |
23 | export async function createList(
24 | draupnir: Draupnir,
25 | info: BasicInvocationInformation,
26 | _keywords: ParsedKeywords,
27 | _rest: undefined[],
28 | shortcode: string,
29 | aliasName: string
30 | ): Promise> {
31 | const newList = await draupnir.policyRoomManager.createPolicyRoom(
32 | shortcode,
33 | // avoids inviting ourself and setting 50 as our own powerlevel
34 | [info.commandSender].filter((sender) => sender !== draupnir.clientUserID),
35 | {
36 | room_alias_name: aliasName,
37 | }
38 | );
39 | if (isError(newList)) {
40 | return newList;
41 | }
42 | const watchResult =
43 | await draupnir.protectedRoomsSet.watchedPolicyRooms.watchPolicyRoomDirectly(
44 | newList.ok
45 | );
46 | if (isError(watchResult)) {
47 | return watchResult;
48 | }
49 | const protectResult =
50 | await draupnir.protectedRoomsSet.protectedRoomsManager.addRoom(newList.ok);
51 | if (isError(protectResult)) {
52 | return protectResult;
53 | }
54 | return newList;
55 | }
56 |
57 | export const DraupnirListCreateCommand = describeCommand({
58 | summary:
59 | "Create a new Policy Room which can be used to ban users, rooms and servers from your protected rooms",
60 | parameters: tuple(
61 | {
62 | name: "shortcode",
63 | acceptor: StringPresentationType,
64 | },
65 | {
66 | name: "alias name",
67 | acceptor: StringPresentationType,
68 | }
69 | ),
70 | executor: createList,
71 | });
72 |
73 | DraupnirInterfaceAdaptor.describeRenderer(DraupnirListCreateCommand, {
74 | isAlwaysSupposedToUseDefaultRenderer: true,
75 | });
76 |
--------------------------------------------------------------------------------
/src/commands/server-admin/HijackRoomCommand.ts:
--------------------------------------------------------------------------------
1 | // Copyright 2022 - 2024 Gnuxie
2 | // Copyright 2021, 2022 Marco Cirillo
3 | //
4 | // SPDX-License-Identifier: AFL-3.0 AND Apache-2.0
5 | //
6 | // SPDX-FileAttributionText:
7 | // This modified file incorporates work from mjolnir
8 | // https://github.com/matrix-org/mjolnir
9 | //
10 |
11 | import {
12 | ActionError,
13 | ActionResult,
14 | Ok,
15 | isError,
16 | } from "matrix-protection-suite";
17 | import { resolveRoomReferenceSafe } from "matrix-protection-suite-for-matrix-bot-sdk";
18 | import {
19 | MatrixRoomReferencePresentationSchema,
20 | MatrixUserIDPresentationType,
21 | describeCommand,
22 | tuple,
23 | } from "@the-draupnir-project/interface-manager";
24 | import { Draupnir } from "../../Draupnir";
25 | import { DraupnirInterfaceAdaptor } from "../DraupnirCommandPrerequisites";
26 |
27 | export const SynapseAdminHijackRoomCommand = describeCommand({
28 | summary:
29 | "Make the specified user the admin of a room via the synapse admin API",
30 | parameters: tuple(
31 | {
32 | name: "room",
33 | acceptor: MatrixRoomReferencePresentationSchema,
34 | },
35 | {
36 | name: "user",
37 | acceptor: MatrixUserIDPresentationType,
38 | }
39 | ),
40 | async executor(
41 | draupnir: Draupnir,
42 | _info,
43 | _keywords,
44 | _rest,
45 | room,
46 | user
47 | ): Promise> {
48 | const isAdmin = await draupnir.synapseAdminClient?.isSynapseAdmin();
49 | if (
50 | !draupnir.config.admin?.enableMakeRoomAdminCommand ||
51 | isAdmin === undefined ||
52 | isError(isAdmin) ||
53 | !isAdmin.ok
54 | ) {
55 | return ActionError.Result(
56 | "Either the command is disabled or Draupnir is not running as homeserver administrator."
57 | );
58 | }
59 | if (draupnir.synapseAdminClient === undefined) {
60 | throw new TypeError("Should be impossible at this point");
61 | }
62 | const resolvedRoom = await resolveRoomReferenceSafe(draupnir.client, room);
63 | if (isError(resolvedRoom)) {
64 | return resolvedRoom;
65 | }
66 | await draupnir.synapseAdminClient.makeUserRoomAdmin(
67 | resolvedRoom.ok.toRoomIDOrAlias(),
68 | user.toString()
69 | );
70 | return Ok(undefined);
71 | },
72 | });
73 |
74 | DraupnirInterfaceAdaptor.describeRenderer(SynapseAdminHijackRoomCommand, {
75 | isAlwaysSupposedToUseDefaultRenderer: true,
76 | });
77 |
--------------------------------------------------------------------------------
/test/appservice/utils/webAPIClient.ts:
--------------------------------------------------------------------------------
1 | // Copyright 2022 Gnuxie
2 | // Copyright 2022 The Matrix.org Foundation C.I.C.
3 | //
4 | // SPDX-License-Identifier: AFL-3.0 AND Apache-2.0
5 | //
6 | // SPDX-FileAttributionText:
7 | // This modified file incorporates work from mjolnir
8 | // https://github.com/matrix-org/mjolnir
9 | //
10 |
11 | import * as request from "request";
12 | import { MatrixClient } from "matrix-bot-sdk";
13 |
14 | interface OpenIDTokenInfo {
15 | access_token: string;
16 | expires_in: number;
17 | matrix_server_name: string;
18 | token_type: string;
19 | }
20 |
21 | async function getOpenIDToken(client: MatrixClient): Promise {
22 | const tokenInfo: OpenIDTokenInfo = await client.doRequest(
23 | "POST",
24 | `/_matrix/client/v3/user/${await client.getUserId()}/openid/request_token`,
25 | undefined,
26 | {}
27 | );
28 | return tokenInfo.access_token;
29 | }
30 |
31 | export interface CreateMjolnirResponse {
32 | mjolnirUserId: string;
33 | managementRoomId: string;
34 | }
35 |
36 | export class MjolnirWebAPIClient {
37 | private constructor(
38 | private readonly openIDToken: string,
39 | private readonly baseURL: string
40 | ) {}
41 |
42 | public static async makeClient(
43 | client: MatrixClient,
44 | baseUrl: string
45 | ): Promise {
46 | const token = await getOpenIDToken(client);
47 | return new MjolnirWebAPIClient(token, baseUrl);
48 | }
49 |
50 | public async createMjolnir(
51 | roomToProtectId: string
52 | ): Promise {
53 | const body: { mxid: string; roomId: string } = await new Promise(
54 | (resolve, reject) => {
55 | request.post(
56 | `${this.baseURL}/create`,
57 | {
58 | json: {
59 | openId: this.openIDToken,
60 | roomId: roomToProtectId,
61 | },
62 | },
63 | (error, response) => {
64 | if (error === null || error === undefined) {
65 | resolve(response.body);
66 | } else if (error instanceof Error) {
67 | reject(error);
68 | } else {
69 | reject(
70 | new TypeError(`Someone is throwing things that aren't errors`)
71 | );
72 | }
73 | }
74 | );
75 | }
76 | );
77 | return {
78 | mjolnirUserId: body.mxid,
79 | managementRoomId: body.roomId,
80 | };
81 | }
82 | }
83 |
--------------------------------------------------------------------------------
/src/commands/SetPowerLevelCommand.ts:
--------------------------------------------------------------------------------
1 | // Copyright 2022 Gnuxie
2 | // Copyright 2020 - 2021 The Matrix.org Foundation C.I.C.
3 | //
4 | // SPDX-License-Identifier: AFL-3.0 AND Apache-2.0
5 | //
6 | // SPDX-FileAttributionText:
7 | // This modified file incorporates work from mjolnir
8 | // https://github.com/matrix-org/mjolnir
9 | //
10 |
11 | import { MatrixRoomID } from "@the-draupnir-project/matrix-basic-types";
12 | import {
13 | MatrixRoomReferencePresentationSchema,
14 | MatrixUserIDPresentationType,
15 | StringPresentationType,
16 | describeCommand,
17 | tuple,
18 | } from "@the-draupnir-project/interface-manager";
19 | import { Ok, Result, isError } from "@gnuxie/typescript-result";
20 | import { Draupnir } from "../Draupnir";
21 | import { DraupnirInterfaceAdaptor } from "./DraupnirCommandPrerequisites";
22 |
23 | export const DraupnirSetPowerLevelCommand = describeCommand({
24 | summary:
25 | "Set the power level of a user across the protected rooms set, or within the provided rooms",
26 | parameters: tuple(
27 | {
28 | name: "user",
29 | acceptor: MatrixUserIDPresentationType,
30 | },
31 | {
32 | name: "power level",
33 | acceptor: StringPresentationType,
34 | }
35 | ),
36 | rest: {
37 | name: "rooms",
38 | acceptor: MatrixRoomReferencePresentationSchema,
39 | },
40 | async executor(
41 | draupnir: Draupnir,
42 | _info,
43 | _keywords,
44 | roomRefs,
45 | user,
46 | rawPowerLevel
47 | ): Promise> {
48 | const powerLevel = Number.parseInt(rawPowerLevel, 10);
49 | const resolvedGivenRooms: MatrixRoomID[] = [];
50 | for (const room of roomRefs) {
51 | const resolvedResult = await draupnir.clientPlatform
52 | .toRoomResolver()
53 | .resolveRoom(room);
54 | if (isError(resolvedResult)) {
55 | return resolvedResult;
56 | } else {
57 | resolvedGivenRooms.push(resolvedResult.ok);
58 | }
59 | }
60 | const rooms =
61 | roomRefs.length === 0
62 | ? draupnir.protectedRoomsSet.allProtectedRooms
63 | : resolvedGivenRooms;
64 | for (const room of rooms) {
65 | await draupnir.client.setUserPowerLevel(
66 | user.toString(),
67 | room.toRoomIDOrAlias(),
68 | powerLevel
69 | );
70 | }
71 | return Ok(undefined);
72 | },
73 | });
74 |
75 | DraupnirInterfaceAdaptor.describeRenderer(DraupnirSetPowerLevelCommand, {
76 | isAlwaysSupposedToUseDefaultRenderer: true,
77 | });
78 |
--------------------------------------------------------------------------------
/test/integration/utilsTest.ts:
--------------------------------------------------------------------------------
1 | // Copyright 2022 - 2024 Gnuxie
2 | // Copyright 2021 - 2022 The Matrix.org Foundation C.I.C.
3 | //
4 | // SPDX-License-Identifier: AFL-3.0 AND Apache-2.0
5 | //
6 | // SPDX-FileAttributionText:
7 | // This modified file incorporates work from mjolnir
8 | // https://github.com/matrix-org/mjolnir
9 | //
10 |
11 | import { strict as assert } from "assert";
12 | import { LogLevel } from "matrix-bot-sdk";
13 | import { DraupnirTestContext, draupnirClient } from "./mjolnirSetupUtils";
14 | import {
15 | NoticeMessageContent,
16 | RoomEvent,
17 | Value,
18 | } from "matrix-protection-suite";
19 |
20 | describe("Test: utils", function () {
21 | it(
22 | "replaceRoomIdsWithPills correctly turns a room ID in to a pill",
23 | async function (this: DraupnirTestContext) {
24 | const managementRoomAlias = this.config.managementRoom;
25 | const draupnir = this.draupnir;
26 | const draupnirMatrixClient = draupnirClient();
27 | if (draupnir === undefined || draupnirMatrixClient === null) {
28 | throw new TypeError(`Setup code is broken`);
29 | }
30 | const managementRoomOutput = draupnir.managementRoomOutput;
31 | await draupnir.client.sendStateEvent(
32 | draupnir.managementRoomID,
33 | "m.room.canonical_alias",
34 | "",
35 | { alias: managementRoomAlias }
36 | );
37 |
38 | const message: RoomEvent = await new Promise((resolve) => {
39 | draupnirMatrixClient.on("room.message", (roomId, event) => {
40 | if (roomId === draupnir.managementRoomID) {
41 | if (event.content?.body?.startsWith("it's")) {
42 | resolve(event);
43 | }
44 | }
45 | });
46 | void managementRoomOutput.logMessage(
47 | LogLevel.INFO,
48 | "replaceRoomIdsWithPills test",
49 | `it's fun here in ${draupnir.managementRoomID}`,
50 | [draupnir.managementRoomID, "!myfaketestid:example.com"]
51 | );
52 | });
53 | if (!Value.Check(NoticeMessageContent, message.content)) {
54 | throw new TypeError(
55 | `This test is written with the expectation logMessage will send a notice`
56 | );
57 | }
58 | assert.equal(
59 | message.content.formatted_body,
60 | `it's fun here in ${managementRoomAlias}`
61 | );
62 | } as unknown as Mocha.AsyncFunc
63 | );
64 | });
65 |
--------------------------------------------------------------------------------
/src/safemode/ManagementRoom.ts:
--------------------------------------------------------------------------------
1 | // Copyright 2022 - 2024 Gnuxie
2 | // Copyright 2019 - 2021 The Matrix.org Foundation C.I.C.
3 | //
4 | // SPDX-License-Identifier: AFL-3.0 AND Apache-2.0
5 | //
6 | // SPDX-FileAttributionText:
7 | // This modified file incorporates work from mjolnir
8 | // https://github.com/matrix-org/mjolnir
9 | //
10 |
11 | import { MatrixInterfaceCommandDispatcher } from "@the-draupnir-project/interface-manager";
12 | import {
13 | MatrixRoomID,
14 | StringRoomID,
15 | } from "@the-draupnir-project/matrix-basic-types";
16 | import {
17 | Logger,
18 | Ok,
19 | RoomEvent,
20 | RoomMessage,
21 | Task,
22 | TextMessageContent,
23 | Value,
24 | } from "matrix-protection-suite";
25 | import { MatrixSendClient } from "matrix-protection-suite-for-matrix-bot-sdk";
26 | import { MatrixEventContext } from "@the-draupnir-project/mps-interface-adaptor";
27 |
28 | const log = new Logger("ManagementRoom");
29 |
30 | export function makeCommandDispatcherTimelineListener(
31 | managementRoom: MatrixRoomID,
32 | client: MatrixSendClient,
33 | dispatcher: MatrixInterfaceCommandDispatcher
34 | ): (roomID: StringRoomID, event: RoomEvent) => void {
35 | const managementRoomID = managementRoom.toRoomIDOrAlias();
36 | return function (roomID, event): void {
37 | if (roomID !== managementRoomID) {
38 | return;
39 | }
40 | if (
41 | Value.Check(RoomMessage, event) &&
42 | Value.Check(TextMessageContent, event.content)
43 | ) {
44 | if (
45 | event.content.body ===
46 | "** Unable to decrypt: The sender's device has not sent us the keys for this message. **"
47 | ) {
48 | log.info(
49 | `Unable to decrypt an event ${event.event_id} from ${event.sender} in the management room ${managementRoom.toPermalink()}.`
50 | );
51 | void Task(
52 | client.unstableApis
53 | .addReactionToEvent(roomID, event.event_id, "⚠")
54 | .then((_) => Ok(undefined))
55 | );
56 | void Task(
57 | client.unstableApis
58 | .addReactionToEvent(roomID, event.event_id, "UISI")
59 | .then((_) => Ok(undefined))
60 | );
61 | void Task(
62 | client.unstableApis
63 | .addReactionToEvent(roomID, event.event_id, "🚨")
64 | .then((_) => Ok(undefined))
65 | );
66 | return;
67 | }
68 | dispatcher.handleCommandMessageEvent(
69 | {
70 | event,
71 | roomID,
72 | },
73 | event.content.body
74 | );
75 | }
76 | };
77 | }
78 |
--------------------------------------------------------------------------------
/src/queues/UnlistedUserRedactionQueue.ts:
--------------------------------------------------------------------------------
1 | // Copyright 2022 Gnuxie
2 | // Copyright 2019 - 2021 The Matrix.org Foundation C.I.C.
3 | //
4 | // SPDX-License-Identifier: AFL-3.0 AND Apache-2.0
5 | //
6 | // SPDX-FileAttributionText:
7 | // This modified file incorporates work from mjolnir
8 | // https://github.com/matrix-org/mjolnir
9 | //
10 | import { LogLevel, LogService } from "matrix-bot-sdk";
11 | import { RoomEvent } from "matrix-protection-suite";
12 | import { Draupnir } from "../Draupnir";
13 | import {
14 | StringUserID,
15 | StringRoomID,
16 | Permalinks,
17 | } from "@the-draupnir-project/matrix-basic-types";
18 |
19 | /**
20 | * A queue of users who have been flagged for redaction typically by the flooding or image protection.
21 | * Specifically any new events sent by a queued user will be redacted.
22 | * This does not handle previously sent events, for that see the `EventRedactionQueue`.
23 | * These users are not listed as banned in any watch list and so may continue
24 | * to view a room until a moderator can investigate.
25 | */
26 | export class UnlistedUserRedactionQueue {
27 | private usersToRedact = new Set();
28 |
29 | public addUser(userID: StringUserID) {
30 | this.usersToRedact.add(userID);
31 | }
32 |
33 | public removeUser(userID: StringUserID) {
34 | this.usersToRedact.delete(userID);
35 | }
36 |
37 | public isUserQueued(userID: StringUserID): boolean {
38 | return this.usersToRedact.has(userID);
39 | }
40 |
41 | public async handleEvent(
42 | roomID: StringRoomID,
43 | event: RoomEvent,
44 | draupnir: Draupnir
45 | ) {
46 | if (this.isUserQueued(event["sender"])) {
47 | const permalink = Permalinks.forEvent(roomID, event["event_id"]);
48 | try {
49 | LogService.info(
50 | "AutomaticRedactionQueue",
51 | `Redacting event because the user is listed as bad: ${permalink}`
52 | );
53 | if (!draupnir.config.noop) {
54 | await draupnir.client.redactEvent(roomID, event["event_id"]);
55 | } else {
56 | await draupnir.managementRoomOutput.logMessage(
57 | LogLevel.WARN,
58 | "AutomaticRedactionQueue",
59 | `Tried to redact ${permalink} but Draupnir is running in no-op mode`
60 | );
61 | }
62 | } catch (e) {
63 | await draupnir.managementRoomOutput.logMessage(
64 | LogLevel.WARN,
65 | "AutomaticRedactionQueue",
66 | `Unable to redact message: ${permalink}`
67 | );
68 | LogService.warn("AutomaticRedactionQueue", e);
69 | }
70 | }
71 | }
72 | }
73 |
--------------------------------------------------------------------------------
/test/unit/stores/roomAuditLogTest.ts:
--------------------------------------------------------------------------------
1 | // SPDX-FileCopyrightText: 2025 Gnuxie
2 | //
3 | // SPDX-License-Identifier: AFL-3.0
4 |
5 | import Database from "better-sqlite3";
6 | import { BetterSqliteOptions } from "../../../src/backingstore/better-sqlite3/BetterSqliteStore";
7 | import { SqliteRoomAuditLog } from "../../../src/protections/RoomTakedown/SqliteRoomAuditLog";
8 | import {
9 | describePolicyRule,
10 | LiteralPolicyRule,
11 | parsePolicyRule,
12 | PolicyRuleType,
13 | randomRoomID,
14 | Recommendation,
15 | } from "matrix-protection-suite";
16 | import expect from "expect";
17 | import { StringUserID } from "@the-draupnir-project/matrix-basic-types";
18 |
19 | describe("RoomAuditLog test", function () {
20 | const options = { path: ":memory:" } satisfies BetterSqliteOptions;
21 | const db = new Database(options.path);
22 | db.pragma("FOREIGN_KEYS = ON");
23 | const store = new SqliteRoomAuditLog(options, db);
24 | it("Test that it doesn't return null or something", async function () {
25 | expect(store.isRoomTakendown(randomRoomID([]).toRoomIDOrAlias())).toBe(
26 | false
27 | );
28 | });
29 | it("Can logged takendown rooms", async function () {
30 | const bannedRoom = randomRoomID([]);
31 | const policyRoom = randomRoomID([]);
32 | const ban = parsePolicyRule(
33 | describePolicyRule({
34 | room_id: policyRoom.toRoomIDOrAlias(),
35 | entity: bannedRoom.toRoomIDOrAlias(),
36 | reason: "spam",
37 | recommendation: Recommendation.Takedown,
38 | type: PolicyRuleType.Room,
39 | }) as never
40 | ).expect("Should bea ble to parse the policy rule.");
41 | (
42 | await store.takedownRoom(ban as LiteralPolicyRule, {
43 | room_id: bannedRoom.toRoomIDOrAlias(),
44 | name: "Spam name",
45 | topic: "Spam topic",
46 | joined_members: 30,
47 | creator: "@spam:example.com" as StringUserID,
48 | })
49 | ).expect("Should be able to takedown a room");
50 | expect(store.isRoomTakendown(bannedRoom.toRoomIDOrAlias())).toBe(true);
51 | const takedownDetails = (
52 | await store.getTakedownDetails(bannedRoom.toRoomIDOrAlias())
53 | ).expect("Should be able to get takedown details");
54 | expect(takedownDetails?.created_at).toBeDefined();
55 | expect(takedownDetails?.creator).toBe("@spam:example.com");
56 | expect(takedownDetails?.joined_members).toBe(30);
57 | expect(takedownDetails?.name).toBe("Spam name");
58 | expect(takedownDetails?.topic).toBe("Spam topic");
59 | expect(takedownDetails?.room_id).toBe(bannedRoom.toRoomIDOrAlias());
60 | expect(takedownDetails?.policy_id).toBeDefined();
61 | });
62 | });
63 |
--------------------------------------------------------------------------------
/.github/workflows/mjolnir.yml:
--------------------------------------------------------------------------------
1 | # SPDX-FileCopyrightText: 2024 Gnuxie
2 | #
3 | # SPDX-License-Identifier: CC0-1.0
4 |
5 | name: Tests
6 |
7 | on:
8 | push:
9 | branches: [main]
10 | pull_request:
11 | branches: [main]
12 | schedule:
13 | - cron: "20 20 * * *"
14 | env:
15 | CARGO_TERM_COLOR: always
16 |
17 | jobs:
18 | build:
19 | name: Build & Lint
20 | runs-on: ubuntu-latest
21 | steps:
22 | - uses: actions/checkout@v4
23 |
24 | - name: Specifically use node 20 like in the readme.
25 | uses: actions/setup-node@v4
26 | with:
27 | node-version: "20"
28 | - run: corepack enable
29 | - run: corepack yarn install
30 | - run: corepack yarn build
31 | - run: corepack yarn lint
32 | unit:
33 | name: Unit tests
34 | runs-on: ubuntu-latest
35 | steps:
36 | - uses: actions/checkout@v4
37 | - name: Specifically use node 20 like in the readme.
38 | uses: actions/setup-node@v4
39 | with:
40 | node-version: "20"
41 | - run: corepack yarn install
42 | - run: corepack yarn test:unit
43 | integration:
44 | name: Integration tests
45 | runs-on: ubuntu-latest
46 | timeout-minutes: 60
47 | steps:
48 | - uses: actions/checkout@v4
49 | - uses: actions/setup-node@v4
50 | with:
51 | node-version: "20"
52 | - name: Fetch and build mx-tester (cached across runs)
53 | uses: baptiste0928/cargo-install@v3
54 | with:
55 | crate: mx-tester
56 | version: "0.3.3"
57 | - name: Setup image
58 | run: RUST_LOG=debug,hyper=info,rusttls=info mx-tester build up
59 | - name: Setup dependencies
60 | run: corepack yarn install
61 | - name: Run tests
62 | run: RUST_LOG=debug,hyper=info,rusttls=info mx-tester run
63 | - name: Cleanup
64 | run: mx-tester down
65 | appservice-integration:
66 | name: Application Service Integration tests
67 | runs-on: ubuntu-latest
68 | timeout-minutes: 30
69 | steps:
70 | - uses: actions/checkout@v4
71 | - uses: actions/setup-node@v4
72 | with:
73 | node-version: "20"
74 | - name: Fetch and build mx-tester (cached across runs)
75 | uses: baptiste0928/cargo-install@v3
76 | with:
77 | crate: mx-tester
78 | version: "0.3.3"
79 | - name: Setup image
80 | run: RUST_LOG=debug,hyper=info,rusttls=info mx-tester build up
81 | - name: Setup dependencies
82 | run: corepack yarn install
83 | - name: Run tests
84 | run: corepack yarn test:appservice:integration
85 | - name: Cleanup
86 | run: mx-tester down
87 |
--------------------------------------------------------------------------------
/.github/workflows/sign-off.yml:
--------------------------------------------------------------------------------
1 | # Copyright 2024 Catalan Lover
2 | # Copyright 2022 - 2024 The Matrix.org Foundation C.I.C.
3 | #
4 | # SPDX-License-Identifier: Apache-2.0
5 | #
6 | # SPDX-FileAttributionText:
7 | # This modified file incorporates work from matrix-org/backend-meta
8 | # https://github.com/matrix-org/backend-meta
9 | #
10 |
11 | name: Contribution requirements
12 |
13 | on:
14 | pull_request:
15 | types: [opened, edited, synchronize]
16 | workflow_call:
17 |
18 | jobs:
19 | signoff:
20 | runs-on: ubuntu-latest
21 | steps:
22 | - name: Check PR for sign-off text
23 | uses: actions/github-script@v7
24 | with:
25 | script: |
26 | // We don't require owners or members of the org to sign off.
27 | const authorAssociation = context.payload.pull_request.author_association;
28 | if (['OWNER', 'MEMBER'].includes(authorAssociation) ||
29 | // GitHub sometimes mislables users as 'CONTRIBUTOR'/'COLLABORATOR',
30 | // so check that the user created the PR on the base project to check they have write access.
31 | context.payload.pull_request.head.user.login === context.repo.owner) {
32 | core.notice('Pull request does not require sign-off.');
33 | return;
34 | }
35 |
36 | // This regex is intentionally left lenient.
37 | const signOffRegex = /signed[_\- ]off[_\- ]by: [\S ]+ .+(@|at).+>?/i;
38 |
39 | if (signOffRegex.test(context.payload.pull_request.body ?? "")) {
40 | core.notice('Pull request body contains a sign-off notice');
41 | return;
42 | }
43 |
44 | const commits = await github.rest.pulls.listCommits({
45 | pull_number: context.payload.pull_request.number,
46 | owner: context.repo.owner,
47 | repo: context.repo.repo,
48 | // It's *possible* the author has buried the sign-off 101 commits down, but
49 | // we don't want to max out the API searching for it.
50 | per_page: 100,
51 | });
52 |
53 | const commit = commits.data.find(c => signOffRegex.test(c.commit.message));
54 | if (commit) {
55 | core.notice(`Commit '${commit.id}' contains a sign-off notice`);
56 | return;
57 | }
58 |
59 | core.setFailed('No sign off found. Please ensure you have signed off following the advice in https://the-draupnir-project.github.io/draupnir-documentation/contributing#sign-off .')
60 | core.notice('Ensure you have matched the format `Signed-off-by: Your Name `')
61 |
--------------------------------------------------------------------------------
/src/webapis/SynapseHTTPAntispam/CheckEventForSpamEndpoint.ts:
--------------------------------------------------------------------------------
1 | // SPDX-FileCopyrightText: 2025 Gnuxie
2 | //
3 | // SPDX-License-Identifier: AFL-3.0
4 |
5 | import { Type } from "@sinclair/typebox";
6 | import {
7 | EDStatic,
8 | isError,
9 | Logger,
10 | RoomEvent,
11 | Task,
12 | Value,
13 | } from "matrix-protection-suite";
14 | import { SpamCheckEndpointPluginManager } from "./SpamCheckEndpointPluginManager";
15 | import { Request, Response } from "express";
16 |
17 | const log = new Logger("CheckEventForSpamEndpoint");
18 |
19 | export type CheckEventForSpamListenerArguments = Parameters<
20 | (details: CheckEventForSpamRequestBody) => void
21 | >;
22 |
23 | export type CheckEventForSpamRequestBody = EDStatic<
24 | typeof CheckEventForSpamRequestBody
25 | >;
26 | export const CheckEventForSpamRequestBody = Type.Object({
27 | event: RoomEvent(Type.Unknown()),
28 | });
29 |
30 | export class CheckEventForSpamEndpoint {
31 | public constructor(
32 | private readonly pluginManager: SpamCheckEndpointPluginManager
33 | ) {
34 | // nothing to do.
35 | }
36 |
37 | private async handleCheckEventForSpamAsync(
38 | request: Request,
39 | response: Response,
40 | isResponded: boolean
41 | ): Promise {
42 | const decodedBody = Value.Decode(
43 | CheckEventForSpamRequestBody,
44 | request.body
45 | );
46 | if (isError(decodedBody)) {
47 | log.error("Error decoding request body:", decodedBody.error);
48 | if (!isResponded && this.pluginManager.isBlocking()) {
49 | response
50 | .status(400)
51 | .send({ errcode: "M_INVALID_PARAM", error: "Error handling event" });
52 | }
53 | return;
54 | }
55 | if (!isResponded && this.pluginManager.isBlocking()) {
56 | const blockingResult = await this.pluginManager.callBlockingHandles(
57 | decodedBody.ok
58 | );
59 | if (blockingResult === "NOT_SPAM") {
60 | response.status(200);
61 | response.send({});
62 | } else {
63 | response.status(400);
64 | response.send(blockingResult);
65 | }
66 | } else if (!isResponded) {
67 | response.status(200);
68 | response.send({});
69 | }
70 | this.pluginManager.callNonBlockingHandlesInTask(decodedBody.ok);
71 | }
72 |
73 | public handleCheckEventForSpam(request: Request, response: Response): void {
74 | if (!this.pluginManager.isBlocking()) {
75 | response.status(200);
76 | response.send({});
77 | }
78 | void Task(
79 | this.handleCheckEventForSpamAsync(
80 | request,
81 | response,
82 | !this.pluginManager.isBlocking()
83 | )
84 | );
85 | }
86 | }
87 |
--------------------------------------------------------------------------------
/test/unit/protections/RedactionSynchronisationTest.ts:
--------------------------------------------------------------------------------
1 | // SPDX-FileCopyrightText: 2025 Gnuxie
2 | //
3 | // SPDX-License-Identifier: AFL-3.0
4 |
5 | import {
6 | describeProtectedRoomsSet,
7 | Membership,
8 | Ok,
9 | PolicyRuleType,
10 | randomRoomID,
11 | randomUserID,
12 | Recommendation,
13 | } from "matrix-protection-suite";
14 | import {
15 | RedactionSynchronisationConsequences,
16 | StandardRedactionSynchronisation,
17 | } from "../../../src/protections/RedactionSynchronisation";
18 | import { MatrixGlob } from "@the-draupnir-project/matrix-basic-types";
19 | import { createMock } from "ts-auto-mock";
20 | import expect from "expect";
21 |
22 | describe("RedactionSynchronisation", function () {
23 | it("Attempts to retract invitations on permission requirements met", async function () {
24 | const room = randomRoomID([]);
25 | const targetUser = randomUserID();
26 | const { protectedRoomsSet } = await describeProtectedRoomsSet({
27 | rooms: [
28 | {
29 | room,
30 | membershipDescriptions: [
31 | {
32 | sender: targetUser,
33 | membership: Membership.Leave,
34 | },
35 | {
36 | sender: targetUser,
37 | membership: Membership.Invite,
38 | target: randomUserID(),
39 | },
40 | ],
41 | },
42 | ],
43 | lists: [
44 | {
45 | policyDescriptions: [
46 | {
47 | recommendation: Recommendation.Ban,
48 | entity: targetUser,
49 | reason: "spam",
50 | type: PolicyRuleType.User,
51 | },
52 | ],
53 | },
54 | ],
55 | });
56 | let mockMethodCalls = 0;
57 | const mockConsequences = createMock({
58 | async redactMessagesIn(userIDOrGlob, _reason, _roomIDs) {
59 | expect(userIDOrGlob).toBe(targetUser);
60 | mockMethodCalls += 1;
61 | return Ok(undefined);
62 | },
63 | async rejectInvite(roomID, sender, _reciever, _reason) {
64 | expect(roomID).toBe(room.toRoomIDOrAlias());
65 | expect(sender).toBe(targetUser);
66 | mockMethodCalls += 1;
67 | return Ok(undefined);
68 | },
69 | });
70 | const redactionSynronisationService = new StandardRedactionSynchronisation(
71 | [new MatrixGlob("spam")],
72 | mockConsequences,
73 | protectedRoomsSet.watchedPolicyRooms,
74 | protectedRoomsSet.setRoomMembership,
75 | protectedRoomsSet.setPoliciesMatchingMembership
76 | );
77 | redactionSynronisationService.handlePermissionRequirementsMet(
78 | room.toRoomIDOrAlias()
79 | );
80 | expect(mockMethodCalls).toBe(1);
81 | });
82 | });
83 |
--------------------------------------------------------------------------------
/src/webapis/SynapseHTTPAntispam/UserMayJoinRoomEndpoint.ts:
--------------------------------------------------------------------------------
1 | // SPDX-FileCopyrightText: 2025 Gnuxie
2 | //
3 | // SPDX-License-Identifier: AFL-3.0
4 |
5 | import { Type } from "@sinclair/typebox";
6 | import {
7 | EDStatic,
8 | isError,
9 | Logger,
10 | StringRoomIDSchema,
11 | StringUserIDSchema,
12 | Task,
13 | Value,
14 | } from "matrix-protection-suite";
15 | import { SpamCheckEndpointPluginManager } from "./SpamCheckEndpointPluginManager";
16 | import { Request, Response } from "express";
17 |
18 | const log = new Logger("UserMayJoinRoomEndpoint");
19 |
20 | export type UserMayJoinRoomListenerArguments = Parameters<
21 | (details: UserMayJoinRoomRequestBody) => void
22 | >;
23 |
24 | export type UserMayJoinRoomRequestBody = EDStatic<
25 | typeof UserMayJoinRoomRequestBody
26 | >;
27 | export const UserMayJoinRoomRequestBody = Type.Object({
28 | user: StringUserIDSchema,
29 | room: StringRoomIDSchema,
30 | is_invited: Type.Boolean(),
31 | });
32 |
33 | export class UserMayJoinRoomEndpoint {
34 | public constructor(
35 | private readonly pluginManager: SpamCheckEndpointPluginManager
36 | ) {
37 | // nothing to do.
38 | }
39 |
40 | private async handleUserMayJoinRoomAsync(
41 | request: Request,
42 | response: Response,
43 | isResponded: boolean
44 | ): Promise {
45 | const decodedBody = Value.Decode(UserMayJoinRoomRequestBody, request.body);
46 | if (isError(decodedBody)) {
47 | log.error("Error decoding request body:", decodedBody.error);
48 | if (!isResponded && this.pluginManager.isBlocking()) {
49 | response.status(400).send({
50 | errcode: "M_INVALID_PARAM",
51 | error: "Error handling user, room, and is_invited",
52 | });
53 | }
54 | return;
55 | }
56 | if (!isResponded && this.pluginManager.isBlocking()) {
57 | const blockingResult = await this.pluginManager.callBlockingHandles(
58 | decodedBody.ok
59 | );
60 | if (blockingResult === "NOT_SPAM") {
61 | response.status(200);
62 | response.send({});
63 | } else {
64 | response.status(400);
65 | response.send(blockingResult);
66 | }
67 | } else if (!isResponded) {
68 | response.status(200);
69 | response.send({});
70 | }
71 | this.pluginManager.callNonBlockingHandlesInTask(decodedBody.ok);
72 | }
73 |
74 | public handleUserMayJoinRoom(request: Request, response: Response): void {
75 | if (!this.pluginManager.isBlocking()) {
76 | response.status(200);
77 | response.send({});
78 | }
79 | void Task(
80 | this.handleUserMayJoinRoomAsync(
81 | request,
82 | response,
83 | !this.pluginManager.isBlocking()
84 | )
85 | );
86 | }
87 | }
88 |
--------------------------------------------------------------------------------
/src/commands/unban/UnbanEntity.tsx:
--------------------------------------------------------------------------------
1 | // SPDX-FileCopyrightText: 2025 Gnuxie
2 | //
3 | // SPDX-License-Identifier: AFL-3.0
4 |
5 | // So the purpose of this is just to remove all policies related to an entity.
6 | // Prompt which policies will be removed, and then remove them if it's accepted.
7 | // For finer control, they will need to use policy remove command.
8 |
9 | import {
10 | isStringRoomID,
11 | MatrixRoomID,
12 | StringRoomID,
13 | } from "@the-draupnir-project/matrix-basic-types";
14 | import {
15 | PolicyRoomManager,
16 | PolicyRuleType,
17 | Recommendation,
18 | RoomSetResultBuilder,
19 | WatchedPolicyRooms,
20 | } from "matrix-protection-suite";
21 | import { ListMatches } from "../Rules";
22 | import { isError, Ok, Result } from "@gnuxie/typescript-result";
23 | import { UnbanEntityPreview, UnbanEntityResult } from "./Unban";
24 |
25 | export function findPoliciesToRemove(
26 | entity: MatrixRoomID | string,
27 | watchedPolicyRooms: WatchedPolicyRooms
28 | ): UnbanEntityPreview {
29 | const entityType =
30 | entity instanceof MatrixRoomID
31 | ? PolicyRuleType.Room
32 | : PolicyRuleType.Server;
33 | const matches: ListMatches[] = [];
34 | for (const profile of watchedPolicyRooms.allRooms) {
35 | matches.push({
36 | room: profile.room,
37 | roomID: profile.room.toRoomIDOrAlias(),
38 | profile,
39 | matches: [Recommendation.Ban, Recommendation.Takedown].flatMap(
40 | (recommendation) =>
41 | profile.revision.allRulesMatchingEntity(entity.toString(), {
42 | type: entityType,
43 | searchHashedRules: true,
44 | recommendation,
45 | })
46 | ),
47 | });
48 | }
49 | return {
50 | entity,
51 | policyMatchesToRemove: matches,
52 | };
53 | }
54 |
55 | export async function unbanEntity(
56 | entity: StringRoomID | string,
57 | policyRoomManager: PolicyRoomManager,
58 | policyMatches: UnbanEntityPreview
59 | ): Promise> {
60 | const entityType = isStringRoomID(entity)
61 | ? PolicyRuleType.Room
62 | : PolicyRuleType.Server;
63 | const policiesRemoved = new RoomSetResultBuilder();
64 | for (const matches of policyMatches.policyMatchesToRemove) {
65 | const editor = await policyRoomManager.getPolicyRoomEditor(matches.room);
66 | if (isError(editor)) {
67 | policiesRemoved.addResult(
68 | matches.roomID,
69 | editor.elaborate("Unable to obtain the policy room editor")
70 | );
71 | } else {
72 | policiesRemoved.addResult(
73 | matches.roomID,
74 | (await editor.ok.unbanEntity(entityType, entity)) as Result
75 | );
76 | }
77 | }
78 | return Ok({
79 | ...policyMatches,
80 | policyRemovalResult: policiesRemoved.getResult(),
81 | });
82 | }
83 |
--------------------------------------------------------------------------------
/src/protections/ConfigMigration/CapabilitySetProviderMigration.ts:
--------------------------------------------------------------------------------
1 | // SPDX-FileCopyrightText: 2025 Gnuxie
2 | //
3 | // SPDX-License-Identifier: Apache-2.0
4 |
5 | import { Ok, ResultError } from "@gnuxie/typescript-result";
6 | import {
7 | CapabilityProviderConfig,
8 | DRAUPNIR_SCHEMA_VERSION_KEY,
9 | Logger,
10 | SchemedDataManager,
11 | ServerACLSynchronisationCapability,
12 | SimulatedServerBanSynchronisationCapability,
13 | Value,
14 | } from "matrix-protection-suite";
15 |
16 | const log = new Logger("CapabilitySetProviderMigration");
17 |
18 | export async function serverBanSynchronisationCapabilityRename(
19 | input: CapabilityProviderConfig,
20 | toVersion: number
21 | ) {
22 | // Annoyingly, having a record type on the top level mixed with known
23 | // properties is really terrible for typescript to deal with.
24 | if (!Value.Check(CapabilityProviderConfig, input)) {
25 | return ResultError.Result(
26 | `The data for the capability provider config is corrupted.`
27 | );
28 | }
29 | const protectionCapabilityName = "serverConsequences";
30 | const oldSimulatedServerConsequencesName = "SimulatedServerConsequences";
31 | const oldServerACLConsequencesName = "ServerACLConsequences";
32 | const oldServerConsequencesSet = input[protectionCapabilityName];
33 | if (oldServerConsequencesSet === undefined) {
34 | return Ok({
35 | ...input,
36 | [DRAUPNIR_SCHEMA_VERSION_KEY]: toVersion,
37 | } as unknown as CapabilityProviderConfig);
38 | }
39 | log.debug(
40 | `Migrating capability provider from ${protectionCapabilityName} to ServerBanSynchronisationCapability`
41 | );
42 | const makeProviderSet = (
43 | capabilityName: string
44 | ): CapabilityProviderConfig => {
45 | return {
46 | [protectionCapabilityName]: {
47 | capability_provider_name: capabilityName,
48 | },
49 | [DRAUPNIR_SCHEMA_VERSION_KEY]: toVersion,
50 | } as unknown as CapabilityProviderConfig;
51 | };
52 | switch (oldServerConsequencesSet.capability_provider_name) {
53 | case oldSimulatedServerConsequencesName:
54 | return Ok(
55 | makeProviderSet(SimulatedServerBanSynchronisationCapability.name)
56 | );
57 | case oldServerACLConsequencesName:
58 | return Ok(makeProviderSet(ServerACLSynchronisationCapability.name));
59 | default:
60 | // if someone has written their own custom thing, they probably need to know that we've
61 | // change the interface name.
62 | throw new TypeError(
63 | `Unknown capability provider name: ${oldServerConsequencesSet.capability_provider_name}`
64 | );
65 | }
66 | }
67 |
68 | export const CapabilitySetProviderMigration =
69 | new SchemedDataManager([
70 | serverBanSynchronisationCapabilityRename,
71 | ]);
72 |
--------------------------------------------------------------------------------
/src/appservice/bot/AppserviceBotCommandDispatcher.ts:
--------------------------------------------------------------------------------
1 | // SPDX-FileCopyrightText: 2024 Gnuxie
2 | //
3 | // SPDX-License-Identifier: Apache-2.0
4 | //
5 | // SPDX-FileAttributionText:
6 | // This modified file incorporates work from Draupnir
7 | // https://github.com/the-draupnir-project/Draupnir
8 | //
9 |
10 | import {
11 | BasicInvocationInformation,
12 | CommandNormaliser,
13 | JSInterfaceCommandDispatcher,
14 | makeCommandNormaliser,
15 | MatrixInterfaceCommandDispatcher,
16 | StandardJSInterfaceCommandDispatcher,
17 | StandardMatrixInterfaceCommandDispatcher,
18 | } from "@the-draupnir-project/interface-manager";
19 | import { AppserviceAdaptorContext } from "./AppserviceBotPrerequisite";
20 | import { AppserviceBotCommands } from "./AppserviceBotCommandTable";
21 | import { AppserviceBotHelpCommand } from "./AppserviceBotHelp";
22 | import {
23 | AppserviceBotInterfaceAdaptor,
24 | AppserviceAdaptorContextToCommandContextTranslator,
25 | } from "./AppserviceBotInterfaceAdaptor";
26 | import {
27 | invocationInformationFromMatrixEventcontext,
28 | MatrixEventContext,
29 | MPSCommandDispatcherCallbacks,
30 | } from "@the-draupnir-project/mps-interface-adaptor";
31 |
32 | function makeAppserviceCommandNormaliser(
33 | appserviceContext: AppserviceAdaptorContext
34 | ): CommandNormaliser {
35 | return makeCommandNormaliser(appserviceContext.clientUserID, {
36 | symbolPrefixes: ["!"],
37 | isAllowedOnlySymbolPrefixes: false,
38 | additionalPrefixes: ["admin"],
39 | getDisplayName: function (): string {
40 | // TODO: I don't nkow how we're going to do this yet but we'll
41 | // figure it out one day.
42 | return "admin";
43 | },
44 | normalisedPrefix: "admin",
45 | });
46 | }
47 |
48 | export function makeAppserviceBotCommandDispatcher(
49 | appserviceContext: AppserviceAdaptorContext
50 | ): MatrixInterfaceCommandDispatcher {
51 | return new StandardMatrixInterfaceCommandDispatcher(
52 | AppserviceBotInterfaceAdaptor,
53 | appserviceContext,
54 | AppserviceBotCommands,
55 | AppserviceBotHelpCommand,
56 | invocationInformationFromMatrixEventcontext,
57 | {
58 | ...MPSCommandDispatcherCallbacks,
59 | commandNormaliser: makeAppserviceCommandNormaliser(appserviceContext),
60 | }
61 | );
62 | }
63 |
64 | export function makeAppserviceJSCommandDispatcher(
65 | appserviceContext: AppserviceAdaptorContext
66 | ): JSInterfaceCommandDispatcher {
67 | return new StandardJSInterfaceCommandDispatcher(
68 | AppserviceBotCommands,
69 | AppserviceBotHelpCommand,
70 | appserviceContext,
71 | {
72 | ...MPSCommandDispatcherCallbacks,
73 | commandNormaliser: makeAppserviceCommandNormaliser(appserviceContext),
74 | },
75 | AppserviceAdaptorContextToCommandContextTranslator
76 | );
77 | }
78 |
--------------------------------------------------------------------------------
/src/appservice/bot/ListCommand.tsx:
--------------------------------------------------------------------------------
1 | // Copyright 2022 Gnuxie
2 | //
3 | // SPDX-License-Identifier: AFL-3.0
4 |
5 | import {
6 | ActionError,
7 | ActionResult,
8 | isError,
9 | Ok,
10 | } from "matrix-protection-suite";
11 | import { UnstartedDraupnir } from "../../draupnirfactory/StandardDraupnirManager";
12 | import { AppserviceAdaptorContext } from "./AppserviceBotPrerequisite";
13 | import {
14 | DeadDocumentJSX,
15 | describeCommand,
16 | MatrixUserIDPresentationType,
17 | tuple,
18 | } from "@the-draupnir-project/interface-manager";
19 | import { AppserviceBotInterfaceAdaptor } from "./AppserviceBotInterfaceAdaptor";
20 |
21 | export const AppserviceListUnstartedCommand = describeCommand({
22 | summary: "List any Draupnir that failed to start.",
23 | async executor(
24 | context: AppserviceAdaptorContext
25 | ): Promise> {
26 | return Ok(context.appservice.draupnirManager.getUnstartedDraupnirs());
27 | },
28 | parameters: [],
29 | });
30 |
31 | AppserviceBotInterfaceAdaptor.describeRenderer(AppserviceListUnstartedCommand, {
32 | JSXRenderer: function (result) {
33 | if (isError(result)) {
34 | return Ok(undefined);
35 | }
36 | const draupnirs = result.ok;
37 | return Ok(
38 |
39 | Unstarted Draupnir: {draupnirs.length}
40 |
41 | {draupnirs.map((draupnir) => {
42 | return (
43 | -
44 |
{draupnir.clientUserID}
45 | {draupnir.failType}:
46 |
47 | {String(draupnir.cause)}
48 |
49 | );
50 | })}
51 |
52 |
53 | );
54 | },
55 | });
56 |
57 | export const AppserviceRestartDraupnirCommand = describeCommand({
58 | summary: "Restart a Draupnir.",
59 | parameters: tuple({
60 | name: "draupnir",
61 | acceptor: MatrixUserIDPresentationType,
62 | description: "The userid of the draupnir to restart",
63 | }),
64 | async executor(
65 | context: AppserviceAdaptorContext,
66 | _info,
67 | _keywords,
68 | _rest,
69 | draupnirUser
70 | ): Promise> {
71 | const draupnirManager = context.appservice.draupnirManager;
72 | const draupnir = draupnirManager.findUnstartedDraupnir(
73 | draupnirUser.toString()
74 | );
75 | if (draupnir !== undefined) {
76 | return ActionError.Result(
77 | `We can't find the unstarted draupnir ${draupnirUser.toString()}, is it already running?`
78 | );
79 | }
80 | return await draupnirManager.startDraupnirFromMXID(draupnirUser.toString());
81 | },
82 | });
83 |
84 | AppserviceBotInterfaceAdaptor.describeRenderer(
85 | AppserviceRestartDraupnirCommand,
86 | {
87 | isAlwaysSupposedToUseDefaultRenderer: true,
88 | }
89 | );
90 |
--------------------------------------------------------------------------------
/test/integration/httpAntispamTest.ts:
--------------------------------------------------------------------------------
1 | // SPDX-FileCopyrightText: 2025 Gnuxie
2 | //
3 | // SPDX-License-Identifier: AFL-3.0
4 |
5 | import expect from "expect";
6 | import { DraupnirTestContext } from "./mjolnirSetupUtils";
7 | import {
8 | ActionException,
9 | isOk,
10 | MatrixException,
11 | } from "matrix-protection-suite";
12 | import { MatrixError } from "matrix-bot-sdk";
13 |
14 | describe("Test for http antispam callbacks", function () {
15 | it("We can process check_event_for_spam", async function (
16 | this: DraupnirTestContext
17 | ) {
18 | const draupnir = this.draupnir;
19 | if (draupnir === undefined) {
20 | throw new TypeError(`setup code is wrong`);
21 | }
22 | const synapseHTTPAntispam = draupnir.synapseHTTPAntispam;
23 | if (synapseHTTPAntispam === undefined) {
24 | throw new TypeError("Setup code is wrong");
25 | }
26 | const promise = new Promise((resolve) => {
27 | synapseHTTPAntispam.checkEventForSpamHandles.registerNonBlockingHandle(
28 | (details) => {
29 | if (details.event.sender === draupnir.clientUserID) {
30 | resolve(undefined);
31 | }
32 | }
33 | );
34 | });
35 | (
36 | await draupnir.clientPlatform
37 | .toRoomMessageSender()
38 | .sendMessage(draupnir.managementRoomID, {
39 | body: "hello",
40 | msgtype: "m.text",
41 | })
42 | ).expect("should be able to send the message just fine");
43 | await promise;
44 | // now try blocking
45 | synapseHTTPAntispam.checkEventForSpamHandles.registerBlockingHandle(() => {
46 | return Promise.resolve({ errcode: "M_FORBIDDEN", error: "no." });
47 | });
48 | const sendResult = await draupnir.clientPlatform
49 | .toRoomMessageSender()
50 | .sendMessage(draupnir.managementRoomID, {
51 | body: "hello",
52 | msgtype: "m.text",
53 | });
54 | if (isOk(sendResult)) {
55 | throw new TypeError("We expect the result to be blocked");
56 | }
57 | if (!(sendResult.error instanceof ActionException)) {
58 | throw new TypeError(
59 | "We're trying to destructure this to get the MatrixError"
60 | );
61 | }
62 | // I'm pretty sure there are different versions of this being used in the code base
63 | // so instanceof fails :/ sucks balls mare
64 | // https://github.com/the-draupnir-project/Draupnir/issues/760
65 | // https://github.com/the-draupnir-project/Draupnir/issues/759
66 | if (sendResult.error instanceof MatrixException) {
67 | expect(sendResult.error.matrixErrorMessage).toBe("no.");
68 | expect(sendResult.error.matrixErrorCode).toBe("M_FORBIDDEN");
69 | } else {
70 | const matrixError = sendResult.error.exception as MatrixError;
71 | expect(matrixError.error).toBe("no.");
72 | expect(matrixError.errcode).toBe("M_FORBIDDEN");
73 | }
74 | } as unknown as Mocha.AsyncFunc);
75 | });
76 |
--------------------------------------------------------------------------------
/src/webapis/SynapseHTTPAntispam/SpamCheckEndpointPluginManager.ts:
--------------------------------------------------------------------------------
1 | // SPDX-FileCopyrightText: 2025 Gnuxie
2 | //
3 | // SPDX-License-Identifier: AFL-3.0
4 |
5 | import { Logger, Task } from "matrix-protection-suite";
6 |
7 | type BlockingResponse =
8 | | "NOT_SPAM"
9 | | {
10 | errcode: string;
11 | error: string;
12 | };
13 |
14 | const log = new Logger("SpamCheckEndpointPluginManager");
15 |
16 | export type BlockingCallback = (
17 | ...args: CBArguments
18 | ) => Promise;
19 | export type NonBlockingCallback = (
20 | ...args: CBArguments
21 | ) => void;
22 |
23 | export class SpamCheckEndpointPluginManager {
24 | private readonly blockingHandles = new Set>();
25 | private readonly nonBlockingHandles = new Set<
26 | NonBlockingCallback
27 | >();
28 |
29 | public registerBlockingHandle(handle: BlockingCallback): void {
30 | this.blockingHandles.add(handle);
31 | }
32 |
33 | public registerNonBlockingHandle(
34 | handle: NonBlockingCallback
35 | ): void {
36 | this.nonBlockingHandles.add(handle);
37 | }
38 |
39 | public unregisterHandle(
40 | handle: BlockingCallback | NonBlockingCallback
41 | ): void {
42 | this.blockingHandles.delete(handle as BlockingCallback);
43 | this.nonBlockingHandles.delete(handle as NonBlockingCallback);
44 | }
45 |
46 | public unregisterListeners(): void {
47 | this.blockingHandles.clear();
48 | this.nonBlockingHandles.clear();
49 | }
50 |
51 | public isBlocking(): boolean {
52 | return this.blockingHandles.size > 0;
53 | }
54 |
55 | public async callBlockingHandles(
56 | ...args: CBArguments
57 | ): ReturnType> {
58 | const results = await Promise.allSettled(
59 | [...this.blockingHandles.values()].map((handle) => handle(...args))
60 | );
61 | for (const result of results) {
62 | if (result.status === "rejected") {
63 | log.error(
64 | "Error processing a blocking spam check callback:",
65 | result.reason
66 | );
67 | } else {
68 | if (result.value !== "NOT_SPAM") {
69 | return result.value;
70 | }
71 | }
72 | }
73 | return "NOT_SPAM";
74 | }
75 |
76 | public callNonBlockingHandles(...args: CBArguments): void {
77 | for (const handle of this.nonBlockingHandles) {
78 | try {
79 | handle(...args);
80 | } catch (e) {
81 | log.error("Error processing a non blocking spam check callback:", e);
82 | }
83 | }
84 | }
85 |
86 | public callNonBlockingHandlesInTask(...args: CBArguments): void {
87 | void Task(
88 | (async () => {
89 | this.callNonBlockingHandles(...args);
90 | })()
91 | );
92 | }
93 | }
94 |
--------------------------------------------------------------------------------
/src/commands/server-admin/ShutdownRoomCommand.ts:
--------------------------------------------------------------------------------
1 | // Copyright 2022 Gnuxie
2 | // Copyright 2020 The Matrix.org Foundation C.I.C.
3 | //
4 | // SPDX-License-Identifier: AFL-3.0 AND Apache-2.0
5 | //
6 | // SPDX-FileAttributionText:
7 | // This modified file incorporates work from mjolnir
8 | // https://github.com/matrix-org/mjolnir
9 | //
10 |
11 | import { Result, isError } from "@gnuxie/typescript-result";
12 | import {
13 | MatrixRoomReferencePresentationSchema,
14 | StringPresentationType,
15 | describeCommand,
16 | tuple,
17 | } from "@the-draupnir-project/interface-manager";
18 | import { Draupnir } from "../../Draupnir";
19 | import { ActionError } from "matrix-protection-suite";
20 | import { DraupnirInterfaceAdaptor } from "../DraupnirCommandPrerequisites";
21 |
22 | export const SynapseAdminShutdownRoomCommand = describeCommand({
23 | summary:
24 | "Prevents access to the the room on this server and sends a message to all users that they have violated the terms of service.",
25 | parameters: tuple({
26 | name: "room",
27 | acceptor: MatrixRoomReferencePresentationSchema,
28 | }),
29 | rest: {
30 | name: "reason",
31 | acceptor: StringPresentationType,
32 | },
33 | keywords: {
34 | keywordDescriptions: {
35 | notify: {
36 | isFlag: true,
37 | description:
38 | "Whether to send the content violation notification message. This is an invitation that will be sent to the room's joined members which explains the reason for the shutdown.",
39 | },
40 | },
41 | },
42 | async executor(
43 | draupnir: Draupnir,
44 | _info,
45 | keywords,
46 | reasonParts,
47 | targetRoom
48 | ): Promise> {
49 | const notify = keywords.getKeywordValue("notify", false);
50 | const isAdmin = await draupnir.synapseAdminClient?.isSynapseAdmin();
51 | if (isAdmin === undefined || isError(isAdmin) || !isAdmin.ok) {
52 | return ActionError.Result(
53 | "I am not a Synapse administrator, or the endpoint to shutdown a room is blocked"
54 | );
55 | }
56 | if (draupnir.synapseAdminClient === undefined) {
57 | throw new TypeError(`Should be impossible at this point.`);
58 | }
59 | const resolvedRoom = await draupnir.clientPlatform
60 | .toRoomResolver()
61 | .resolveRoom(targetRoom);
62 | if (isError(resolvedRoom)) {
63 | return resolvedRoom;
64 | }
65 | const reason = reasonParts.join(" ");
66 | return await draupnir.synapseAdminClient.shutdownRoomV2(
67 | resolvedRoom.ok.toRoomIDOrAlias(),
68 | {
69 | ...(notify
70 | ? { message: reason, new_room_user_id: draupnir.clientUserID }
71 | : {}),
72 | block: true,
73 | purge: true,
74 | }
75 | );
76 | },
77 | });
78 |
79 | DraupnirInterfaceAdaptor.describeRenderer(SynapseAdminShutdownRoomCommand, {
80 | isAlwaysSupposedToUseDefaultRenderer: true,
81 | });
82 |
--------------------------------------------------------------------------------
/src/webapis/SynapseHTTPAntispam/UserMayInviteEndpoint.ts:
--------------------------------------------------------------------------------
1 | // SPDX-FileCopyrightText: 2025 Gnuxie
2 | //
3 | // SPDX-License-Identifier: AFL-3.0
4 |
5 | import { SpamCheckEndpointPluginManager } from "./SpamCheckEndpointPluginManager";
6 | import { Request, Response } from "express";
7 | import {
8 | EDStatic,
9 | isError,
10 | Logger,
11 | StringRoomIDSchema,
12 | StringUserIDSchema,
13 | Task,
14 | Value,
15 | } from "matrix-protection-suite";
16 | import { Type } from "@sinclair/typebox";
17 |
18 | // for check_event_for_spam we will leave the event as unparsed
19 |
20 | const log = new Logger("UserMayInviteEndpoint");
21 |
22 | export type UserMayInviteListenerArguments = Parameters<
23 | (details: UserMayInviteRequestBody) => void
24 | >;
25 |
26 | export type UserMayInviteRequestBody = EDStatic<
27 | typeof UserMayInviteRequestBody
28 | >;
29 | export const UserMayInviteRequestBody = Type.Object({
30 | inviter: StringUserIDSchema,
31 | invitee: StringUserIDSchema,
32 | room_id: StringRoomIDSchema,
33 | });
34 |
35 | export type UserMayInvitePluginManager =
36 | SpamCheckEndpointPluginManager;
37 | export class UserMayInviteEndpoint {
38 | public constructor(
39 | private readonly pluginManager: SpamCheckEndpointPluginManager
40 | ) {
41 | // nothing to do.
42 | }
43 |
44 | private async handleUserMayInviteAsync(
45 | request: Request,
46 | response: Response,
47 | isResponded: boolean
48 | ): Promise {
49 | const decodedBody = Value.Decode(UserMayInviteRequestBody, request.body);
50 | if (isError(decodedBody)) {
51 | log.error("Error decoding request body:", decodedBody.error);
52 | if (!isResponded && this.pluginManager.isBlocking()) {
53 | response.status(400).send({
54 | errcode: "M_INVALID_PARAM",
55 | error: "Error handling inviter, invitee, and room_id",
56 | });
57 | }
58 | return;
59 | }
60 | if (!isResponded && this.pluginManager.isBlocking()) {
61 | const blockingResult = await this.pluginManager.callBlockingHandles(
62 | decodedBody.ok
63 | );
64 | if (blockingResult === "NOT_SPAM") {
65 | response.status(200);
66 | response.send({});
67 | } else {
68 | response.status(400);
69 | response.send(blockingResult);
70 | }
71 | } else if (!isResponded) {
72 | response.status(200);
73 | response.send({});
74 | }
75 | this.pluginManager.callNonBlockingHandlesInTask(decodedBody.ok);
76 | }
77 |
78 | public handleUserMayInvite(request: Request, response: Response): void {
79 | if (!this.pluginManager.isBlocking()) {
80 | response.status(200);
81 | response.send({});
82 | }
83 | void Task(
84 | this.handleUserMayInviteAsync(
85 | request,
86 | response,
87 | !this.pluginManager.isBlocking()
88 | )
89 | );
90 | }
91 | }
92 |
--------------------------------------------------------------------------------
/test/integration/commands/hijackRoomCommandTest.ts:
--------------------------------------------------------------------------------
1 | // Copyright 2022 - 2024 Gnuxie
2 | // Copyright 2021, 2022 Marco Cirillo
3 | //
4 | // SPDX-License-Identifier: AFL-3.0 AND Apache-2.0
5 | //
6 | // SPDX-FileAttributionText:
7 | // This modified file incorporates work from mjolnir
8 | // https://github.com/matrix-org/mjolnir
9 | //
10 |
11 | import { strict as assert } from "assert";
12 | import { newTestUser } from "../clientHelper";
13 | import { getFirstReaction } from "./commandUtils";
14 | import { DraupnirTestContext, draupnirSafeEmitter } from "../mjolnirSetupUtils";
15 |
16 | // Breaks with this test.
17 | describe("Test: The make admin command", function () {
18 | it("Draupnir make the bot self room administrator", async function (
19 | this: DraupnirTestContext
20 | ) {
21 | this.timeout(90000);
22 | if (!this.config.admin?.enableMakeRoomAdminCommand) {
23 | this.done();
24 | }
25 | const draupnir = this.draupnir;
26 | if (draupnir === undefined) {
27 | throw new TypeError(`Test didn't setup correctly`);
28 | }
29 | const moderator = await newTestUser(this.config.homeserverUrl, {
30 | name: { contains: "moderator" },
31 | });
32 | const userA = await newTestUser(this.config.homeserverUrl, {
33 | name: { contains: "a" },
34 | });
35 | const userAId = await userA.getUserId();
36 |
37 | await moderator.joinRoom(draupnir.managementRoomID);
38 | const targetRoom = await moderator.createRoom({
39 | invite: [draupnir.clientUserID],
40 | preset: "public_chat",
41 | });
42 | await moderator.sendMessage(draupnir.managementRoomID, {
43 | msgtype: "m.text.",
44 | body: `!draupnir rooms add ${targetRoom}`,
45 | });
46 | await userA.joinRoom(targetRoom);
47 | const powerLevelsBefore = await moderator.getRoomStateEvent(
48 | targetRoom,
49 | "m.room.power_levels",
50 | ""
51 | );
52 | assert.notEqual(
53 | powerLevelsBefore["users"][draupnir.clientUserID],
54 | 100,
55 | `Bot should not yet be an admin of ${targetRoom}`
56 | );
57 | await getFirstReaction(
58 | draupnirSafeEmitter(),
59 | draupnir.managementRoomID,
60 | "✅",
61 | async () => {
62 | return await moderator.sendMessage(draupnir.managementRoomID, {
63 | msgtype: "m.text",
64 | body: `!draupnir hijack room ${targetRoom} ${draupnir.clientUserID}`,
65 | });
66 | }
67 | );
68 |
69 | const powerLevelsAfter = await moderator.getRoomStateEvent(
70 | targetRoom,
71 | "m.room.power_levels",
72 | ""
73 | );
74 | assert.equal(
75 | powerLevelsAfter["users"][draupnir.clientUserID],
76 | 100,
77 | "Bot should be a room admin."
78 | );
79 | assert.equal(
80 | powerLevelsAfter["users"][userAId],
81 | undefined,
82 | "User A is not supposed to be a room admin."
83 | );
84 | } as unknown as Mocha.AsyncFunc);
85 | });
86 |
--------------------------------------------------------------------------------
/src/commands/DraupnirCommandDispatcher.ts:
--------------------------------------------------------------------------------
1 | // SPDX-FileCopyrightText: 2024 Gnuxie
2 | //
3 | // SPDX-License-Identifier: Apache-2.0
4 | //
5 | // SPDX-FileAttributionText:
6 | // This modified file incorporates work from Draupnir
7 | // https://github.com/the-draupnir-project/Draupnir
8 | //
9 |
10 | import {
11 | MatrixInterfaceCommandDispatcher,
12 | StandardMatrixInterfaceCommandDispatcher,
13 | JSInterfaceCommandDispatcher,
14 | BasicInvocationInformation,
15 | StandardJSInterfaceCommandDispatcher,
16 | CommandNormaliser,
17 | makeCommandNormaliser,
18 | } from "@the-draupnir-project/interface-manager";
19 | import { Draupnir } from "../Draupnir";
20 | import { DraupnirHelpCommand } from "./Help";
21 | import { StringUserID } from "@the-draupnir-project/matrix-basic-types";
22 | import { DraupnirTopLevelCommands } from "./DraupnirCommandTable";
23 | import {
24 | DraupnirContextToCommandContextTranslator,
25 | DraupnirInterfaceAdaptor,
26 | } from "./DraupnirCommandPrerequisites";
27 | import "./DraupnirCommands";
28 | import { IConfig } from "../config";
29 | import {
30 | MatrixEventContext,
31 | invocationInformationFromMatrixEventcontext,
32 | MPSCommandDispatcherCallbacks,
33 | } from "@the-draupnir-project/mps-interface-adaptor";
34 |
35 | export function makeDraupnirCommandNormaliser(
36 | clientUserID: StringUserID,
37 | displayNameIssuer: { clientDisplayName: string },
38 | config: IConfig
39 | ): CommandNormaliser {
40 | return makeCommandNormaliser(clientUserID, {
41 | symbolPrefixes: config.commands.symbolPrefixes,
42 | isAllowedOnlySymbolPrefixes: config.commands.allowNoPrefix,
43 | additionalPrefixes: ["draupnir", ...config.commands.additionalPrefixes],
44 | getDisplayName: function (): string {
45 | return displayNameIssuer.clientDisplayName;
46 | },
47 | normalisedPrefix: "draupnir",
48 | });
49 | }
50 |
51 | export function makeDraupnirCommandDispatcher(
52 | draupnir: Draupnir
53 | ): MatrixInterfaceCommandDispatcher {
54 | return new StandardMatrixInterfaceCommandDispatcher(
55 | DraupnirInterfaceAdaptor,
56 | draupnir,
57 | DraupnirTopLevelCommands,
58 | DraupnirHelpCommand,
59 | invocationInformationFromMatrixEventcontext,
60 | {
61 | ...MPSCommandDispatcherCallbacks,
62 | commandNormaliser: makeDraupnirCommandNormaliser(
63 | draupnir.clientUserID,
64 | draupnir,
65 | draupnir.config
66 | ),
67 | }
68 | );
69 | }
70 |
71 | export function makeDraupnirJSCommandDispatcher(
72 | draupnir: Draupnir
73 | ): JSInterfaceCommandDispatcher {
74 | return new StandardJSInterfaceCommandDispatcher(
75 | DraupnirTopLevelCommands,
76 | DraupnirHelpCommand,
77 | draupnir,
78 | {
79 | ...MPSCommandDispatcherCallbacks,
80 | commandNormaliser: makeDraupnirCommandNormaliser(
81 | draupnir.clientUserID,
82 | draupnir,
83 | draupnir.config
84 | ),
85 | },
86 | DraupnirContextToCommandContextTranslator
87 | );
88 | }
89 |
--------------------------------------------------------------------------------
/test/unit/stores/userRestrictionAuditLogTest.ts:
--------------------------------------------------------------------------------
1 | // SPDX-FileCopyrightText: 2025 Gnuxie
2 | //
3 | // SPDX-License-Identifier: AFL-3.0
4 |
5 | import Database from "better-sqlite3";
6 | import { BetterSqliteOptions } from "../../../src/backingstore/better-sqlite3/BetterSqliteStore";
7 | import {
8 | describePolicyRule,
9 | LiteralPolicyRule,
10 | parsePolicyRule,
11 | PolicyRuleType,
12 | randomRoomID,
13 | randomUserID,
14 | Recommendation,
15 | } from "matrix-protection-suite";
16 | import expect from "expect";
17 | import { AccountRestriction } from "matrix-protection-suite-for-matrix-bot-sdk";
18 | import { SqliteUserRestrictionAuditLog } from "../../../src/protections/HomeserverUserPolicyApplication/SqliteUserRestrictionAuditLog";
19 |
20 | describe("UserAuditLog test", function () {
21 | const options = { path: ":memory:" } satisfies BetterSqliteOptions;
22 | const db = new Database(options.path);
23 | db.pragma("FOREIGN_KEYS = ON");
24 | const store = new SqliteUserRestrictionAuditLog(db);
25 | it("Can logged suspended users", async function () {
26 | const bannedUser = randomUserID();
27 | const policyRoom = randomRoomID([]);
28 | const moderator = randomUserID();
29 | expect(
30 | (await store.isUserRestricted(bannedUser)).expect(
31 | "Should be able to query if user is suspended"
32 | )
33 | ).toBe(false);
34 | const ban = parsePolicyRule(
35 | describePolicyRule({
36 | room_id: policyRoom.toRoomIDOrAlias(),
37 | entity: bannedUser,
38 | reason: "spam",
39 | recommendation: Recommendation.Ban,
40 | type: PolicyRuleType.User,
41 | }) as never
42 | ).expect("Should be able to parse the policy rule.");
43 | (
44 | await store.recordUserRestriction(
45 | bannedUser,
46 | AccountRestriction.Suspended,
47 | {
48 | sender: moderator,
49 | rule: ban as LiteralPolicyRule,
50 | }
51 | )
52 | ).expect("Should be able to takedown a room");
53 | expect(
54 | (await store.isUserRestricted(bannedUser)).expect(
55 | "Should be able to query if user is suspended"
56 | )
57 | ).toBe(true);
58 | // now unsuspend them
59 | (await store.unrestrictUser(bannedUser, moderator)).expect(
60 | "Should be able to unsuspend a user"
61 | );
62 | expect(
63 | (await store.isUserRestricted(bannedUser)).expect(
64 | "Should be able to query if user is suspended"
65 | )
66 | ).toBe(false);
67 | });
68 | it("Can log suspended users even without a policy (when a command is used)", async function () {
69 | const bannedUser = randomUserID();
70 | const moderator = randomUserID();
71 | (
72 | await store.recordUserRestriction(
73 | bannedUser,
74 | AccountRestriction.Suspended,
75 | {
76 | sender: moderator,
77 | rule: null,
78 | }
79 | )
80 | ).expect("To be able to do this");
81 | expect(
82 | (await store.isUserRestricted(bannedUser)).expect(
83 | "Should be able to query if user is suspended"
84 | )
85 | ).toBe(true);
86 | });
87 | });
88 |
--------------------------------------------------------------------------------
/src/backingstore/DraupnirStores.ts:
--------------------------------------------------------------------------------
1 | // SPDX-FileCopyrightText: 2025 Gnuxie
2 | //
3 | // SPDX-License-Identifier: Apache-2.0
4 |
5 | import { EventDecoder, SHA256HashStore } from "matrix-protection-suite";
6 | import { RoomAuditLog } from "../protections/RoomTakedown/RoomAuditLog";
7 | import { SqliteRoomStateBackingStore } from "./better-sqlite3/SqliteRoomStateBackingStore";
8 | import { SqliteHashReversalStore } from "./better-sqlite3/HashStore";
9 | import { SqliteRoomAuditLog } from "../protections/RoomTakedown/SqliteRoomAuditLog";
10 | import { UserRestrictionAuditLog } from "../protections/HomeserverUserPolicyApplication/UserRestrictionAuditLog";
11 | import { SqliteUserRestrictionAuditLog } from "../protections/HomeserverUserPolicyApplication/SqliteUserRestrictionAuditLog";
12 |
13 | export type TopLevelStores = {
14 | hashStore?: SHA256HashStore;
15 | roomAuditLog?: RoomAuditLog | undefined;
16 | roomStateBackingStore?: SqliteRoomStateBackingStore | undefined;
17 | userRestrictionAuditLog?: UserRestrictionAuditLog | undefined;
18 | dispose(): void;
19 | };
20 |
21 | /**
22 | * These stores will usually be created at the entrypoint of the draupnir
23 | * application or attenuated for each draupnir.
24 | *
25 | * No i don't like it. Some of these stores ARE specific to the draupnir
26 | * such as the hasStore's event emitter... that just can't be mixed.
27 | *
28 | * We could create a wrapper that disposes of only the stores that
29 | * have been attenuated... but i don't know about it.
30 | */
31 | export type DraupnirStores = {
32 | hashStore?: SHA256HashStore | undefined;
33 | roomAuditLog?: RoomAuditLog | undefined;
34 | userRestrictionAuditLog?: UserRestrictionAuditLog | undefined;
35 | /**
36 | * Dispose of stores relevant to a specific draupnir instance.
37 | * For example, the hash store is usually specific to a single draupnir.
38 | */
39 | dispose(): void;
40 | };
41 |
42 | export function createDraupnirStores(
43 | topLevelStores: TopLevelStores
44 | ): DraupnirStores {
45 | return Object.freeze({
46 | roomAuditLog: topLevelStores.roomAuditLog,
47 | hashStore: topLevelStores.hashStore,
48 | userRestrictionAuditLog: topLevelStores.userRestrictionAuditLog,
49 | dispose() {},
50 | } satisfies DraupnirStores);
51 | }
52 |
53 | export function makeTopLevelStores(
54 | storagePath: string,
55 | eventDecoder: EventDecoder,
56 | {
57 | isRoomStateBackingStoreEnabled,
58 | }: { isRoomStateBackingStoreEnabled: boolean }
59 | ): TopLevelStores {
60 | return Object.freeze({
61 | roomStateBackingStore: isRoomStateBackingStoreEnabled
62 | ? SqliteRoomStateBackingStore.create(storagePath, eventDecoder)
63 | : undefined,
64 | hashStore: SqliteHashReversalStore.createToplevel(storagePath),
65 | roomAuditLog: SqliteRoomAuditLog.createToplevel(storagePath),
66 | userRestrictionAuditLog:
67 | SqliteUserRestrictionAuditLog.createToplevel(storagePath),
68 | dispose() {
69 | this.roomStateBackingStore?.destroy();
70 | this.hashStore?.destroy();
71 | this.roomAuditLog?.destroy();
72 | this.userRestrictionAuditLog?.destroy();
73 | },
74 | } satisfies TopLevelStores);
75 | }
76 |
--------------------------------------------------------------------------------
/src/queues/ProtectedRoomActivityTracker.ts:
--------------------------------------------------------------------------------
1 | // Copyright 2022 Gnuxie
2 | // Copyright 2022 The Matrix.org Foundation C.I.C.
3 | //
4 | // SPDX-License-Identifier: AFL-3.0 AND Apache-2.0
5 | //
6 | // SPDX-FileAttributionText:
7 | // This modified file incorporates work from mjolnir
8 | // https://github.com/matrix-org/mjolnir
9 | //
10 |
11 | import { StringRoomID } from "@the-draupnir-project/matrix-basic-types";
12 | import { RoomEvent } from "matrix-protection-suite";
13 |
14 | /**
15 | * Used to keep track of protected rooms so they are always ordered for activity.
16 | *
17 | * We use the same method as Element web for this, the major disadvantage being that we sort on each access to the room list (sort by most recently active first).
18 | * We have tried to mitigate this by caching the sorted list until the activity in rooms changes again.
19 | * See https://github.com/matrix-org/matrix-react-sdk/blob/8a0398b632dff1a5f6cfd4bf95d78854aeadc60e/src/stores/room-list/algorithms/tag-sorting/RecentAlgorithm.ts
20 | *
21 | */
22 | export class ProtectedRoomActivityTracker {
23 | private protectedRoomActivities = new Map<
24 | string /*room id*/,
25 | number /*last event timestamp*/
26 | >();
27 | /**
28 | * A slot to cache the rooms for `protectedRoomsByActivity` ordered so the most recently active room is first.
29 | */
30 | private activeRoomsCache: null | string[] = null;
31 |
32 | /**
33 | * Inform the tracker that a new room is being protected by Draupnir.
34 | * @param roomId The room Draupnir is now protecting.
35 | */
36 | public addProtectedRoom(roomId: string): void {
37 | this.protectedRoomActivities.set(roomId, /* epoch */ 0);
38 | this.activeRoomsCache = null;
39 | }
40 |
41 | /**
42 | * Inform the trakcer that a room is no longer being protected by Draupnir.
43 | * @param roomId The roomId that is no longer being protected by Draupnir.
44 | */
45 | public removeProtectedRoom(roomId: string): void {
46 | this.protectedRoomActivities.delete(roomId);
47 | this.activeRoomsCache = null;
48 | }
49 |
50 | /**
51 | * Inform the tracker of a new event in a room, so that the internal ranking of rooms can be updated
52 | * @param roomId The room the new event is in.
53 | * @param event The new event.
54 | *
55 | */
56 | public handleEvent(roomID: StringRoomID, event: RoomEvent): void {
57 | const last_origin_server_ts = this.protectedRoomActivities.get(roomID);
58 | if (
59 | last_origin_server_ts !== undefined &&
60 | Number.isInteger(event.origin_server_ts)
61 | ) {
62 | if (event.origin_server_ts > last_origin_server_ts) {
63 | this.activeRoomsCache = null;
64 | this.protectedRoomActivities.set(roomID, event.origin_server_ts);
65 | }
66 | }
67 | }
68 |
69 | /**
70 | * @returns A list of protected rooms ids ordered by activity.
71 | */
72 | public protectedRoomsByActivity(): string[] {
73 | if (!this.activeRoomsCache) {
74 | this.activeRoomsCache = [...this.protectedRoomActivities]
75 | .sort((a, b) => b[1] - a[1])
76 | .map((pair) => pair[0]);
77 | }
78 | return this.activeRoomsCache;
79 | }
80 | }
81 |
--------------------------------------------------------------------------------
/test/unit/commands/WatchUnwatchCommandTest.ts:
--------------------------------------------------------------------------------
1 | // SPDX-FileCopyrightText: 2024 Gnuxie
2 | //
3 | // SPDX-License-Identifier: AFL-3.0
4 |
5 | import {
6 | MatrixRoomID,
7 | MatrixRoomReference,
8 | StringRoomID,
9 | } from "@the-draupnir-project/matrix-basic-types";
10 | import expect from "expect";
11 | import {
12 | Ok,
13 | PropagationType,
14 | RoomJoiner,
15 | WatchedPolicyRooms,
16 | isError,
17 | isOk,
18 | } from "matrix-protection-suite";
19 | import { createMock } from "ts-auto-mock";
20 | import {
21 | DraupnirUnwatchPolicyRoomCommand,
22 | DraupnirWatchPolicyRoomCommand,
23 | DraupnirWatchUnwatchCommandContext,
24 | } from "../../../src/commands/WatchUnwatchCommand";
25 | import { CommandExecutorHelper } from "@the-draupnir-project/interface-manager";
26 |
27 | describe("Test the WatchUnwatchCommmands", function () {
28 | const policyRoom = MatrixRoomReference.fromRoomID(
29 | "!room:example.com" as StringRoomID
30 | );
31 | const watchedPolicyRooms = createMock({
32 | async watchPolicyRoomDirectly(room) {
33 | expect(room).toBe(policyRoom);
34 | return Ok(undefined);
35 | },
36 | });
37 | const roomJoiner = createMock({
38 | async joinRoom(roomReference) {
39 | if (roomReference instanceof MatrixRoomID) {
40 | return Ok(roomReference);
41 | }
42 | throw new TypeError(`We don't really expect to resolve anything`);
43 | },
44 | async resolveRoom(roomReference) {
45 | if (roomReference instanceof MatrixRoomID) {
46 | return Ok(roomReference);
47 | }
48 | throw new TypeError(`We don't really expect to resolve anything`);
49 | },
50 | });
51 | it("DraupnirWatchCommand", async function () {
52 | const result = await CommandExecutorHelper.execute(
53 | DraupnirWatchPolicyRoomCommand,
54 | createMock({
55 | watchedPolicyRooms,
56 | roomJoiner,
57 | }),
58 | { keywords: { "no-confirm": true } },
59 | policyRoom
60 | );
61 | expect(isOk(result)).toBe(true);
62 | });
63 | it("Draupnir watch command should return an error if the room is already being watched", async function () {
64 | const issuerManagerWithWatchedList = createMock({
65 | allRooms: [{ room: policyRoom, propagation: PropagationType.Direct }],
66 | });
67 | const result = await CommandExecutorHelper.execute(
68 | DraupnirWatchPolicyRoomCommand,
69 | createMock({
70 | watchedPolicyRooms: issuerManagerWithWatchedList,
71 | roomJoiner,
72 | }),
73 | { keywords: { "no-confirm": true } },
74 | policyRoom
75 | );
76 | expect(isError(result)).toBe(true);
77 | });
78 | it("DraupnirUnwatchCommand", async function () {
79 | const result = await CommandExecutorHelper.execute(
80 | DraupnirUnwatchPolicyRoomCommand,
81 | createMock({
82 | watchedPolicyRooms,
83 | roomJoiner,
84 | }),
85 | { keywords: { "no-confirm": true } },
86 | policyRoom
87 | );
88 | expect(isOk(result)).toBe(true);
89 | });
90 | });
91 |
--------------------------------------------------------------------------------
/test/appservice/integration/provisionTest.ts:
--------------------------------------------------------------------------------
1 | // Copyright 2022 Gnuxie
2 | // Copyright 2022 The Matrix.org Foundation C.I.C.
3 | //
4 | // SPDX-License-Identifier: AFL-3.0 AND Apache-2.0
5 | //
6 | // SPDX-FileAttributionText:
7 | // This modified file incorporates work from mjolnir
8 | // https://github.com/matrix-org/mjolnir
9 | //
10 |
11 | import { isPolicyRoom, readTestConfig, setupHarness } from "../utils/harness";
12 | import { newTestUser } from "../../integration/clientHelper";
13 | import { getFirstReply } from "../../integration/commands/commandUtils";
14 | import { MatrixClient } from "matrix-bot-sdk";
15 | import { MjolnirAppService } from "../../../src/appservice/AppService";
16 |
17 | interface Context extends Mocha.Context {
18 | moderator?: MatrixClient;
19 | appservice?: MjolnirAppService | undefined;
20 | }
21 |
22 | describe("Test that the app service can provision a draupnir on invite of the appservice bot", function () {
23 | afterEach(function (this: Context) {
24 | this.moderator?.stop();
25 | if (this.appservice) {
26 | return this.appservice.close();
27 | } else {
28 | console.warn("Missing Appservice in this context, so cannot stop it.");
29 | return Promise.resolve(); // TS7030: Not all code paths return a value.
30 | }
31 | });
32 | it("A moderator that requests a draupnir via a matrix invitation will be invited to a new policy and management room", async function (this: Context) {
33 | const config = readTestConfig();
34 | this.appservice = await setupHarness();
35 | const appservice = this.appservice;
36 | // create a user to act as the moderator
37 | const moderator = await newTestUser(config.homeserver.url, {
38 | name: { contains: "test" },
39 | });
40 | const roomWeWantProtecting = await moderator.createRoom();
41 | // have the moderator invite the appservice bot in order to request a new draupnir
42 | this.moderator = moderator;
43 | const roomsInvitedTo: string[] = [];
44 | await new Promise((resolve) => {
45 | void (async () => {
46 | moderator.on("room.invite", (roomId: string) => {
47 | roomsInvitedTo.push(roomId);
48 | // the appservice should invite the moderator to a policy room and a management room.
49 | if (roomsInvitedTo.length === 2) {
50 | resolve(null);
51 | }
52 | });
53 | await moderator.start();
54 | await moderator.inviteUser(
55 | appservice.bridge.getBot().getUserId(),
56 | roomWeWantProtecting
57 | );
58 | })();
59 | });
60 | await Promise.all(
61 | roomsInvitedTo.map((roomId) => moderator.joinRoom(roomId))
62 | );
63 | const managementRoomId = roomsInvitedTo.filter(
64 | async (roomId) => !(await isPolicyRoom(moderator, roomId))
65 | )[0];
66 | if (managementRoomId === undefined) {
67 | throw new TypeError(`Unable to find management room`);
68 | }
69 | // Check that the newly provisioned draupnir is actually responsive.
70 | await getFirstReply(moderator, managementRoomId, () => {
71 | return moderator.sendMessage(managementRoomId, {
72 | body: `!draupnir status`,
73 | msgtype: "m.text",
74 | });
75 | });
76 | });
77 | });
78 |
--------------------------------------------------------------------------------
/test/integration/reportPollingTest.ts:
--------------------------------------------------------------------------------
1 | // Copyright 2022 - 2024 Gnuxie
2 | // Copyright 2022 The Matrix.org Foundation C.I.C.
3 | //
4 | // SPDX-License-Identifier: AFL-3.0 AND Apache-2.0
5 | //
6 | // SPDX-FileAttributionText:
7 | // This modified file incorporates work from mjolnir
8 | // https://github.com/matrix-org/mjolnir
9 | //
10 |
11 | import { MatrixClient } from "matrix-bot-sdk";
12 | import { newTestUser } from "./clientHelper";
13 | import { DraupnirTestContext } from "./mjolnirSetupUtils";
14 | import {
15 | MatrixRoomReference,
16 | StringRoomID,
17 | } from "@the-draupnir-project/matrix-basic-types";
18 | import { randomUUID } from "crypto";
19 | import expect from "expect";
20 | import { createMock } from "ts-auto-mock";
21 | import { ReportManager } from "../../src/report/ReportManager";
22 | import { ReportPoller } from "../../src/report/ReportPoller";
23 |
24 | describe("Test: Report polling", function () {
25 | let client: MatrixClient;
26 | let reportPoller: ReportPoller | undefined;
27 | this.beforeEach(async function () {
28 | client = await newTestUser(this.config.homeserverUrl, {
29 | name: { contains: "protection-settings" },
30 | });
31 | });
32 | this.afterEach(function () {
33 | reportPoller?.stop();
34 | });
35 | it("Draupnir correctly retrieves a report from synapse", async function (
36 | this: DraupnirTestContext
37 | ) {
38 | const draupnir = this.draupnir;
39 | if (draupnir === undefined) {
40 | throw new TypeError(`Test didn't setup properly`);
41 | }
42 | const protectedRoomId = await draupnir.client.createRoom({
43 | invite: [await client.getUserId()],
44 | });
45 | await client.joinRoom(protectedRoomId);
46 | await draupnir.protectedRoomsSet.protectedRoomsManager.addRoom(
47 | MatrixRoomReference.fromRoomID(protectedRoomId as StringRoomID)
48 | );
49 | const testReportReason = randomUUID();
50 | const reportsFound = new Set();
51 | const duplicateReports = new Set();
52 | const reportManager = createMock({
53 | handleServerAbuseReport({ event, reason }) {
54 | if (reason === testReportReason) {
55 | if (reportsFound.has(event.event_id)) {
56 | duplicateReports.add(event.event_id);
57 | }
58 | reportsFound.add(event.event_id);
59 | }
60 | return Promise.resolve(undefined);
61 | },
62 | });
63 | reportPoller = new ReportPoller(draupnir, reportManager, {
64 | pollPeriod: 500,
65 | });
66 | const reportEvent = async () => {
67 | const eventId = await client.sendMessage(protectedRoomId, {
68 | msgtype: "m.text",
69 | body: "uwNd3q",
70 | });
71 | await client.doRequest(
72 | "POST",
73 | `/_matrix/client/r0/rooms/${encodeURIComponent(protectedRoomId)}/report/${encodeURIComponent(eventId)}`,
74 | "",
75 | {
76 | reason: testReportReason,
77 | }
78 | );
79 | };
80 | reportPoller.start({ from: 1 });
81 | for (let i = 0; i < 20; i++) {
82 | await reportEvent();
83 | }
84 | // wait for them to come down the poll.
85 | await new Promise((resolve) => setTimeout(resolve, 3000));
86 | expect(reportsFound.size).toBe(20);
87 | expect(duplicateReports.size).toBe(0);
88 | } as unknown as Mocha.AsyncFunc);
89 | });
90 |
--------------------------------------------------------------------------------
/src/protections/ProtectedRooms/UnprotectPartedRooms.tsx:
--------------------------------------------------------------------------------
1 | // SPDX-FileCopyrightText: 2025 Gnuxie
2 | //
3 | // SPDX-License-Identifier: AFL-3.0
4 |
5 | import {
6 | MatrixRoomReference,
7 | StringRoomID,
8 | StringUserID,
9 | } from "@the-draupnir-project/matrix-basic-types";
10 | import {
11 | isOk,
12 | MembershipChange,
13 | MembershipChangeType,
14 | ProtectedRoomsManager,
15 | RoomMessageSender,
16 | Task,
17 | } from "matrix-protection-suite";
18 | import {
19 | renderMentionPill,
20 | renderRoomPill,
21 | sendMatrixEventsFromDeadDocument,
22 | } from "@the-draupnir-project/mps-interface-adaptor";
23 | import { DeadDocumentJSX } from "@the-draupnir-project/interface-manager";
24 |
25 | export class UnprotectPartedRooms {
26 | constructor(
27 | private readonly clientUserID: StringUserID,
28 | private readonly managementRoomID: StringRoomID,
29 | private readonly protectedRoomsManager: ProtectedRoomsManager,
30 | private readonly messageSender: RoomMessageSender
31 | ) {
32 | // nothing to do.
33 | }
34 |
35 | public async handlePartedRoom(change: MembershipChange): Promise {
36 | const room = MatrixRoomReference.fromRoomID(change.roomID);
37 | const unprotectResult = await this.protectedRoomsManager.removeRoom(room);
38 | const removalDescription = (
39 |
40 | Draupnir has been removed from {renderRoomPill(room)} by{" "}
41 | {renderMentionPill(change.sender, change.sender)}
42 | {change.content.reason ? (
43 |
44 | {" "}
45 | for reason: {change.content.reason}
46 |
47 | ) : (
48 | ""
49 | )}
50 | .
51 |
52 | );
53 | if (isOk(unprotectResult)) {
54 | void Task(
55 | sendMatrixEventsFromDeadDocument(
56 | this.messageSender,
57 | this.managementRoomID,
58 | {removalDescription} The room is now unprotected.,
59 | {}
60 | ),
61 | {
62 | description:
63 | "Report recently unprotected rooms to the management room.",
64 | }
65 | );
66 | } else {
67 | void Task(
68 | sendMatrixEventsFromDeadDocument(
69 | this.messageSender,
70 | this.managementRoomID,
71 |
72 | {removalDescription} Draupnir could not unprotect the room. Please
73 | use !draupnir rooms remove {room.toRoomIDOrAlias()} if
74 | the room is still marked as protected.
75 | ,
76 | {}
77 | ),
78 | {
79 | description:
80 | "Report recently unprotected rooms to the management room.",
81 | }
82 | );
83 | }
84 | }
85 |
86 | public handleMembershipChange(change: MembershipChange): void {
87 | if (change.userID === this.clientUserID) {
88 | if (!this.protectedRoomsManager.isProtectedRoom(change.roomID)) {
89 | return;
90 | }
91 | switch (change.membershipChangeType) {
92 | case MembershipChangeType.Banned:
93 | case MembershipChangeType.Kicked:
94 | case MembershipChangeType.Left:
95 | void this.handlePartedRoom(change);
96 | }
97 | }
98 | }
99 | }
100 |
--------------------------------------------------------------------------------
/src/webapis/SynapseHTTPAntispam/SynapseHttpAntispam.ts:
--------------------------------------------------------------------------------
1 | // SPDX-FileCopyrightText: 2025 Gnuxie
2 | //
3 | // SPDX-License-Identifier: AFL-3.0
4 |
5 | import { Express, Request, Response } from "express";
6 | import { SpamCheckEndpointPluginManager } from "./SpamCheckEndpointPluginManager";
7 | import {
8 | UserMayInviteEndpoint,
9 | UserMayInviteListenerArguments,
10 | } from "./UserMayInviteEndpoint";
11 | import {
12 | UserMayJoinRoomEndpoint,
13 | UserMayJoinRoomListenerArguments,
14 | } from "./UserMayJoinRoomEndpoint";
15 | import {
16 | CheckEventForSpamEndpoint,
17 | CheckEventForSpamListenerArguments,
18 | } from "./CheckEventForSpamEndpoint";
19 | import { handleHttpAntispamPing } from "./PingEndpoint";
20 |
21 | const SPAM_CHECK_PREFIX = "/api/1/spam_check";
22 | const AUTHORIZATION = new RegExp("Bearer (.*)");
23 |
24 | function makeAuthenticatedEndpointHandler(
25 | secret: string,
26 | cb: (request: Request, response: Response) => void
27 | ): (request: Request, response: Response) => void {
28 | return function (request, response) {
29 | const authorization = request.get("Authorization");
30 | if (!authorization) {
31 | response.status(401).send("Missing access token");
32 | return;
33 | }
34 | const [, accessToken] = AUTHORIZATION.exec(authorization) ?? [];
35 | if (accessToken !== secret) {
36 | response.status(401).send("Missing access token");
37 | return;
38 | }
39 | cb(request, response);
40 | };
41 | }
42 |
43 | export class SynapseHttpAntispam {
44 | public readonly userMayInviteHandles =
45 | new SpamCheckEndpointPluginManager();
46 | private readonly userMayInviteEndpoint = new UserMayInviteEndpoint(
47 | this.userMayInviteHandles
48 | );
49 | public readonly userMayJoinRoomHandles =
50 | new SpamCheckEndpointPluginManager();
51 | private readonly userMayJoinRoomEndpoint = new UserMayJoinRoomEndpoint(
52 | this.userMayJoinRoomHandles
53 | );
54 | public readonly checkEventForSpamHandles =
55 | new SpamCheckEndpointPluginManager();
56 | private readonly checkEventForSpamEndpoint = new CheckEventForSpamEndpoint(
57 | this.checkEventForSpamHandles
58 | );
59 | public constructor(private readonly secret: string) {
60 | // nothing to do
61 | }
62 |
63 | public register(webController: Express): void {
64 | webController.post(
65 | `${SPAM_CHECK_PREFIX}/user_may_invite`,
66 | makeAuthenticatedEndpointHandler(this.secret, (request, response) => {
67 | this.userMayInviteEndpoint.handleUserMayInvite(request, response);
68 | })
69 | );
70 | webController.post(
71 | `${SPAM_CHECK_PREFIX}/user_may_join_room`,
72 | makeAuthenticatedEndpointHandler(this.secret, (request, response) => {
73 | this.userMayJoinRoomEndpoint.handleUserMayJoinRoom(request, response);
74 | })
75 | );
76 | webController.post(
77 | `${SPAM_CHECK_PREFIX}/check_event_for_spam`,
78 | makeAuthenticatedEndpointHandler(this.secret, (request, response) => {
79 | this.checkEventForSpamEndpoint.handleCheckEventForSpam(
80 | request,
81 | response
82 | );
83 | })
84 | );
85 | webController.post(
86 | `${SPAM_CHECK_PREFIX}/ping`,
87 | makeAuthenticatedEndpointHandler(this.secret, handleHttpAntispamPing)
88 | );
89 | }
90 | }
91 |
--------------------------------------------------------------------------------
/src/queues/LeakyBucket.ts:
--------------------------------------------------------------------------------
1 | // SPDX-FileCopyrightText: 2024 - 2025 Gnuxie
2 | //
3 | // SPDX-License-Identifier: Apache-2.0
4 |
5 | export interface LeakyBucket {
6 | /**
7 | * Return the token count.
8 | */
9 | addToken(key: Key): number;
10 | getTokenCount(key: Key): number;
11 | getAllTokens(): Map;
12 | stop(): void;
13 | }
14 |
15 | type BucketEntry = {
16 | tokens: number;
17 | lastLeak: Date;
18 | };
19 |
20 | /**
21 | * A lazy version of the bucket to be used when the throuhgput is really
22 | * low most of the time, so doesn't warrant constant filling/leaking.
23 | *
24 | * This won't be good to use in a high throughput situation because
25 | * of the way it will spam calling for the current time.
26 | */
27 | export class LazyLeakyBucket implements LeakyBucket {
28 | private readonly buckets: Map = new Map();
29 | private readonly leakDelta: number;
30 | private isDisposed = false;
31 | private leakCycleTimeout: NodeJS.Timeout | null = null;
32 |
33 | public constructor(
34 | private readonly capacity: number,
35 | private readonly timescale: number
36 | ) {
37 | this.leakDelta = this.timescale / this.capacity;
38 | this.startLeakCycle();
39 | }
40 | getAllTokens(): Map {
41 | const map = new Map();
42 | for (const key of this.buckets.keys()) {
43 | map.set(key, this.getTokenCount(key));
44 | }
45 | return map;
46 | }
47 |
48 | private leak(now: Date, key: Key, entry: BucketEntry): void {
49 | const elapsed = now.getTime() - entry.lastLeak.getTime();
50 | const tokensToRemove = Math.floor(elapsed / this.timescale);
51 | entry.tokens = Math.max(entry.tokens - tokensToRemove, 0);
52 | entry.lastLeak = new Date(
53 | entry.lastLeak.getTime() + tokensToRemove * this.leakDelta
54 | );
55 | if (entry.tokens < 1) {
56 | this.buckets.delete(key);
57 | }
58 | }
59 |
60 | public addToken(key: Key): number {
61 | const now = new Date();
62 | const entry = this.buckets.get(key);
63 | if (entry === undefined) {
64 | this.buckets.set(key, {
65 | tokens: 1,
66 | lastLeak: now,
67 | });
68 | return 1;
69 | }
70 | entry.tokens += 1;
71 | this.leak(now, key, entry);
72 | return entry.tokens;
73 | }
74 |
75 | public getTokenCount(key: Key): number {
76 | const now = new Date();
77 | const entry = this.buckets.get(key);
78 | if (entry === undefined) {
79 | return 0;
80 | }
81 | this.leak(now, key, entry);
82 | return entry.tokens;
83 | }
84 |
85 | private leakAll(): void {
86 | const now = new Date();
87 | for (const [key, entry] of this.buckets.entries()) {
88 | this.leak(now, key, entry);
89 | }
90 | }
91 |
92 | /**
93 | * Periodically leak all of the buckets to prevent memory leaks from leftover
94 | * keys.
95 | */
96 | private startLeakCycle(): void {
97 | if (this.isDisposed) {
98 | return;
99 | }
100 | this.leakCycleTimeout = setTimeout(() => {
101 | this.leakAll();
102 | this.startLeakCycle();
103 | }, this.timescale);
104 | }
105 |
106 | public stop(): void {
107 | this.isDisposed = true;
108 | if (this.leakCycleTimeout) {
109 | clearTimeout(this.leakCycleTimeout);
110 | }
111 | }
112 | }
113 |
--------------------------------------------------------------------------------
/src/capabilities/StandardEventConsequencesRenderer.tsx:
--------------------------------------------------------------------------------
1 | // Copyright 2022 - 2024 Gnuxie
2 | // Copyright 2019 - 2021 The Matrix.org Foundation C.I.C.
3 | //
4 | // SPDX-License-Identifier: AFL-3.0 AND Apache-2.0
5 | //
6 | // SPDX-FileAttributionText:
7 | // This modified file incorporates work from mjolnir
8 | // https://github.com/matrix-org/mjolnir
9 | //
10 |
11 | import {
12 | ActionResult,
13 | Capability,
14 | DescriptionMeta,
15 | EventConsequences,
16 | RoomEventRedacter,
17 | describeCapabilityContextGlue,
18 | describeCapabilityRenderer,
19 | isError,
20 | } from "matrix-protection-suite";
21 | import { RendererMessageCollector } from "./RendererMessageCollector";
22 | import { Draupnir } from "../Draupnir";
23 | import {
24 | Permalinks,
25 | StringEventID,
26 | StringRoomID,
27 | } from "@the-draupnir-project/matrix-basic-types";
28 | import { DeadDocumentJSX } from "@the-draupnir-project/interface-manager";
29 | import { renderFailedSingularConsequence } from "@the-draupnir-project/mps-interface-adaptor";
30 |
31 | class StandardEventConsequencesRenderer implements EventConsequences {
32 | constructor(
33 | private readonly description: DescriptionMeta,
34 | private readonly messageCollector: RendererMessageCollector,
35 | private readonly capability: EventConsequences
36 | ) {
37 | // nothing to do.
38 | }
39 | public readonly requiredEventPermissions =
40 | this.capability.requiredEventPermissions;
41 | public readonly requiredPermissions = this.capability.requiredPermissions;
42 | public readonly requiredStatePermissions =
43 | this.capability.requiredStatePermissions;
44 | public async consequenceForEvent(
45 | roomID: StringRoomID,
46 | eventID: StringEventID,
47 | reason: string
48 | ): Promise> {
49 | const capabilityResult = await this.capability.consequenceForEvent(
50 | roomID,
51 | eventID,
52 | reason
53 | );
54 | const title = (
55 | Redacting {Permalinks.forEvent(roomID, eventID)}.
56 | );
57 | if (isError(capabilityResult)) {
58 | this.messageCollector.addOneliner(
59 | this.description,
60 | this.capability,
61 | renderFailedSingularConsequence(
62 | this.description,
63 | title,
64 | capabilityResult.error
65 | )
66 | );
67 | return capabilityResult;
68 | }
69 | this.messageCollector.addOneliner(this.description, this.capability, title);
70 | return capabilityResult;
71 | }
72 | }
73 |
74 | describeCapabilityRenderer({
75 | name: "StandardEventConsequences",
76 | description: "Renders the standard event consequences capability",
77 | interface: "EventConsequences",
78 | factory(description, draupnir, capability) {
79 | return new StandardEventConsequencesRenderer(
80 | description,
81 | draupnir.capabilityMessageRenderer,
82 | capability
83 | );
84 | },
85 | isDefaultForInterface: true,
86 | });
87 |
88 | describeCapabilityContextGlue({
89 | name: "StandardEventConsequences",
90 | glueMethod: function (
91 | protectionDescription,
92 | draupnir,
93 | capabilityProvider
94 | ): Capability {
95 | return capabilityProvider.factory(protectionDescription, {
96 | eventRedacter: draupnir.clientPlatform.toRoomEventRedacter(),
97 | });
98 | },
99 | });
100 |
--------------------------------------------------------------------------------
/mx-tester.yml:
--------------------------------------------------------------------------------
1 | # SPDX-FileCopyrightText: 2022 The Matrix.org Foundation C.I.C.
2 | #
3 | # SPDX-License-Identifier: Apache-2.0
4 |
5 | name: mjolnir
6 |
7 | up:
8 | before:
9 | - docker run --rm --network $MX_TEST_NETWORK_NAME --name
10 | mjolnir-test-postgres --domainname mjolnir-test-postgres -e
11 | POSTGRES_PASSWORD=mjolnir-test -e POSTGRES_USER=mjolnir-tester -e
12 | POSTGRES_DB=mjolnir-test-db -d -p 127.0.0.1:8083:5432 postgres
13 | # Wait until postgresql is ready
14 | - until psql
15 | postgres://mjolnir-tester:mjolnir-test@127.0.0.1:8083/mjolnir-test-db -c
16 | ""; do echo "Waiting for psql..."; sleep 1s; done
17 | # Launch the reverse proxy, listening for connections *only* on the local host.
18 | - docker run --rm --network host --name mjolnir-test-reverse-proxy -p
19 | 127.0.0.1:8081:80 -v $MX_TEST_CWD/test/nginx.conf:/etc/nginx/nginx.conf:ro
20 | -v
21 | $MX_TEST_CWD/test/draupnir_news.json:/var/www/test/draupnir_news.json:ro
22 | -d nginx
23 | - corepack yarn install
24 | - corepack yarn ts-node src/appservice/cli.ts -r -u
25 | "http://host.docker.internal:9000"
26 | - cp draupnir-registration.yaml $MX_TEST_SYNAPSE_DIR/data/
27 | after:
28 | # Wait until Synapse is ready
29 | - until curl localhost:9999 --stderr /dev/null > /dev/null; do echo "Waiting
30 | for Synapse..."; sleep 1s; done
31 | - echo "Synapse is ready"
32 |
33 | run:
34 | - corepack yarn test:integration
35 |
36 | down:
37 | finally:
38 | - docker stop mjolnir-test-postgres || true
39 | - docker stop mjolnir-test-reverse-proxy || true
40 |
41 | modules:
42 | - name: HTTPAntispam
43 | build:
44 | - git clone https://github.com/maunium/synapse-http-antispam.git
45 | $MX_TEST_MODULE_DIR/
46 | config:
47 | module: synapse_http_antispam.HTTPAntispam
48 | config:
49 | base_url: http://host.docker.internal:8082/api/1/spam_check
50 | authorization: DEFAULT
51 | do_ping: true
52 | enabled_callbacks:
53 | - user_may_invite
54 | - user_may_join_room
55 | - check_event_for_spam
56 | fail_open:
57 | user_may_invite: true
58 | user_may_join_room: true
59 | check_event_for_spam: true
60 |
61 | homeserver:
62 | # Basic configuration.
63 | server_name: localhost:9999
64 | public_baseurl: http://localhost:9999
65 | registration_shared_secret: REGISTRATION_SHARED_SECRET
66 | # Make manual testing easier
67 | enable_registration: true
68 | enable_registration_without_verification: true
69 |
70 | app_service_config_files:
71 | - "/data/draupnir-registration.yaml"
72 |
73 | # We remove rc_message so we can test rate limiting,
74 | # but we keep the others because of https://github.com/matrix-org/synapse/issues/11785
75 | # and we don't want to slow integration tests down.
76 | rc_registration:
77 | per_second: 10000
78 | burst_count: 10000
79 |
80 | rc_login:
81 | address:
82 | per_second: 10000
83 | burst_count: 10000
84 | account:
85 | per_second: 10000
86 | burst_count: 10000
87 | failed_attempts:
88 | per_second: 10000
89 | burst_count: 10000
90 |
91 | rc_admin_redaction:
92 | per_second: 10000
93 | burst_count: 10000
94 |
95 | rc_joins:
96 | local:
97 | per_second: 10000
98 | burst_count: 10000
99 | remote:
100 | per_second: 10000
101 | burst_count: 10000
102 |
--------------------------------------------------------------------------------
/src/queues/TimelineRedactionQueue.ts:
--------------------------------------------------------------------------------
1 | // SPDX-FileCopyrightText: 2025 Gnuxie
2 | //
3 | // SPDX-License-Identifier: AFL-3.0
4 |
5 | import { isError, Ok, Result } from "@gnuxie/typescript-result";
6 | import {
7 | MatrixGlob,
8 | StringEventID,
9 | StringRoomID,
10 | StringUserID,
11 | } from "@the-draupnir-project/matrix-basic-types";
12 | import {
13 | KeyedBatchQueue,
14 | Logger,
15 | RoomEventRedacter,
16 | RoomMessages,
17 | } from "matrix-protection-suite";
18 |
19 | const log = new Logger("TimelineRedactionQueue");
20 |
21 | export class TimelineRedactionQueue {
22 | private readonly batchProcessor = this.processBatch.bind(this);
23 | private readonly queue = new KeyedBatchQueue(
24 | this.batchProcessor
25 | );
26 |
27 | public constructor(
28 | private readonly roomMessages: RoomMessages,
29 | private readonly roomEventRedacter: RoomEventRedacter,
30 | // FIXME: We really should have a way of adding a limit to the batch enqueue operation...
31 | // It doesn't really seem possible though. Unless the maximum limit anyone enqueues is chosen.
32 | // Yeah that sounds like how it would have to work. Meanwhile, it doesn't really matter since
33 | // the current behaviour is constrained to 1000.
34 | private readonly limit = 1000
35 | ) {
36 | // nothing to do.
37 | }
38 | private async processBatch(
39 | roomID: StringRoomID,
40 | userIDs: StringUserID[]
41 | ): Promise> {
42 | const globUserIDs = userIDs.filter(
43 | (userID) => userID.includes("*") || userID.includes("?")
44 | );
45 | const globsToTest = globUserIDs.map((userID) => new MatrixGlob(userID));
46 | const isGlobInUsers = globsToTest.length !== 0;
47 | const usersToTest = userIDs.filter(
48 | (userID) => !globUserIDs.includes(userID)
49 | );
50 | const paginator = this.roomMessages.toRoomMessagesIterator(roomID, {
51 | direction: "backwards",
52 | limit: this.limit,
53 | ...(isGlobInUsers ? {} : { filter: { senders: userIDs } }),
54 | });
55 | const eventsToRedact: StringEventID[] = [];
56 | const paginationResult = await paginator.forEachItem({
57 | forEachItemCB: (event) => {
58 | if (
59 | // Always add users when there are no globs, since events are filtered by sender.
60 | !isGlobInUsers ||
61 | usersToTest.includes(event.sender) ||
62 | globsToTest.some((glob) => glob.test(event.sender))
63 | ) {
64 | eventsToRedact.push(event.event_id);
65 | }
66 | },
67 | totalItemLimit: this.limit,
68 | });
69 | if (isError(paginationResult)) {
70 | return paginationResult.elaborate(
71 | `Failed to paginate /messages in ${roomID} to begin redaction`
72 | );
73 | }
74 | // TODO: It would be good if we had a way of throttling these requests
75 | // per draupnir and in general but y'know.
76 | for (const eventID of eventsToRedact) {
77 | const redactResult = await this.roomEventRedacter.redactEvent(
78 | roomID,
79 | eventID
80 | );
81 | if (isError(redactResult)) {
82 | log.error(
83 | `Error while trying to redact messages for in ${roomID}:`,
84 | eventID,
85 | redactResult.error
86 | );
87 | }
88 | }
89 | return Ok(undefined);
90 | }
91 |
92 | public async enqueueRedaction(
93 | userID: StringUserID,
94 | roomID: StringRoomID
95 | ): Promise> {
96 | return await this.queue.enqueue(roomID, userID);
97 | }
98 | }
99 |
--------------------------------------------------------------------------------