From df63b608e714d11786f2b8d98cfd50f032d88b29 Mon Sep 17 00:00:00 2001 From: David Teller Date: Fri, 14 Jan 2022 17:51:08 +0100 Subject: [PATCH] WIP --- config/default.yaml | 39 +++++++++++ src/Mjolnir.ts | 23 ++++++- src/commands/CommandHandler.ts | 9 ++- ...{RedactCommand.ts => HardRedactCommand.ts} | 12 +++- src/commands/SoftRedactCommand.ts | 66 +++++++++++++++++++ src/config.ts | 10 ++- 6 files changed, 151 insertions(+), 8 deletions(-) rename src/commands/{RedactCommand.ts => HardRedactCommand.ts} (85%) create mode 100644 src/commands/SoftRedactCommand.ts diff --git a/config/default.yaml b/config/default.yaml index 5e680b4..f618e5e 100644 --- a/config/default.yaml +++ b/config/default.yaml @@ -192,3 +192,42 @@ web: abuseReporting: # Whether to enable this feature. enabled: false + +# Uncomment to enable support for undoable actions. +# +# As of this writing, the only undoable action is `softredact`. +# +#trashcan: +# # The room in which we store undoable actions for review. +# # Mjölnir will not create it. This can be the same as the +# # management room but it doesn't have to. +# roomAliasOrId: "#trashcan:localhost" +# +# # Support for soft redactions, i.e. using Mjölnir to mark +# # messages as hidden pending moderation. +# # +# # As of this writing, this feature only works with develop +# # versions of Element Web, with feature +# # `feature_msc3531_hide_messages_pending_moderation` +# # enabled. +# # +# # Comment this out if you wish to disable soft redactions. +# softRedact: +# # After `goodAfterMs` milliseconds, any message hidden +# # pending moderation by Mjölnir will revert to visible +# # and vanish from the trashcan. +# # Ignored if `goodAfterMs` is >= `badAfterMs` or `retainMs`. +# goodAfterMs: .inf +# +# # After `badAfterMs` milliseconds, any message hidden +# # pending moderation by Mjölnir will be irreversibly +# # deleted from homeservers and up-to-date clients and +# # vanish from the trashcan. +# # Ignored if `badAfterMs` is > `goodAfterMs` or `retainMs`. +# badAfterMs: .inf +# +# # After `retainMs` milliseconds, any message hidden +# # pending moderation will vanish from the trashcan, without +# # further action. +# # Ignored if `retainMs` is > `goodAfterMs` or `badAfterMs`. +# retainMs: 604800000 # Approximately one week. diff --git a/src/Mjolnir.ts b/src/Mjolnir.ts index 6ceef83..744b2d4 100644 --- a/src/Mjolnir.ts +++ b/src/Mjolnir.ts @@ -42,6 +42,7 @@ import { EventRedactionQueue, RedactUserInRoom } from "./queues/EventRedactionQu import * as htmlEscape from "escape-html"; import { ReportManager } from "./report/ReportManager"; import { WebAPIs } from "./webapis/WebAPIs"; +import { TrashManager } from "./trashcan/TrashManager"; export const STATE_NOT_STARTED = "not_started"; export const STATE_CHECKING_PERMISSIONS = "checking_permissions"; @@ -73,6 +74,8 @@ export class Mjolnir { private explicitlyProtectedRoomIds: string[] = []; private knownUnprotectedRooms: string[] = []; private webapis: WebAPIs; + public trashcanManager?: TrashManager; + /** * Adds a listener to the client that will automatically accept invitations. * @param {MatrixClient} client @@ -146,15 +149,25 @@ export class Mjolnir { } else { config.managementRoom = managementRoomId; } - await logMessage(LogLevel.INFO, "index", "Mjolnir is starting up. Use !mjolnir to query status."); - return new Mjolnir(client, protectedRooms, banLists); + let trashcanRoomId; + if (config.trashcan) { + trashcanRoomId = await client.resolveRoom(config.trashcan.roomAliasOrId); + const joinedRooms = await client.getJoinedRooms(); + if (!joinedRooms.includes(trashcanRoomId)) { + await client.joinRoom(trashcanRoomId); + } + } + + await logMessage(LogLevel.INFO, "index", "Mjolnir is starting up. Use !mjolnir to query status."); + return new Mjolnir(client, protectedRooms, banLists, trashcanRoomId); } constructor( public readonly client: MatrixClient, public readonly protectedRooms: { [roomId: string]: string }, private banLists: BanList[], + trashcanRoomId?: string, ) { this.explicitlyProtectedRoomIds = Object.keys(this.protectedRooms); @@ -222,6 +235,12 @@ export class Mjolnir { // Setup Web APIs console.log("Creating Web APIs"); this.webapis = new WebAPIs(new ReportManager(this)); + + // Setup trashcan. + console.log("Creating Trashcan Manager"); + if (trashcanRoomId) { + this.trashcanManager = new TrashManager(this, trashcanRoomId); + } } public get lists(): BanList[] { diff --git a/src/commands/CommandHandler.ts b/src/commands/CommandHandler.ts index d26005f..cbcd538 100644 --- a/src/commands/CommandHandler.ts +++ b/src/commands/CommandHandler.ts @@ -1,5 +1,5 @@ /* -Copyright 2019-2021 The Matrix.org Foundation C.I.C. +Copyright 2019-2022 The Matrix.org Foundation C.I.C. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -24,7 +24,8 @@ import { execSyncCommand } from "./SyncCommand"; import { execPermissionCheckCommand } from "./PermissionCheckCommand"; import { execCreateListCommand } from "./CreateBanListCommand"; import { execUnwatchCommand, execWatchCommand } from "./WatchUnwatchCommand"; -import { execRedactCommand } from "./RedactCommand"; +import { execHardRedactCommand } from "./HardRedactCommand"; +import { execSoftRedactCommand } from "./SoftRedactCommand"; import { execImportCommand } from "./ImportCommand"; import { execSetDefaultListCommand } from "./SetDefaultBanListCommand"; import { execDeactivateCommand } from "./DeactivateCommand"; @@ -63,7 +64,9 @@ export async function handleCommand(roomId: string, event: any, mjolnir: Mjolnir } else if (parts[1] === 'unwatch' && parts.length > 1) { return await execUnwatchCommand(roomId, event, mjolnir, parts); } else if (parts[1] === 'redact' && parts.length > 1) { - return await execRedactCommand(roomId, event, mjolnir, parts); + return await execHardRedactCommand(roomId, event, mjolnir, parts); + } else if (parts[1] === 'hide' && parts.length > 1) { + return await execSoftRedactCommand(roomId, event, mjolnir, parts); } else if (parts[1] === 'import' && parts.length > 2) { return await execImportCommand(roomId, event, mjolnir, parts); } else if (parts[1] === 'default' && parts.length > 2) { diff --git a/src/commands/RedactCommand.ts b/src/commands/HardRedactCommand.ts similarity index 85% rename from src/commands/RedactCommand.ts rename to src/commands/HardRedactCommand.ts index 4896758..d92f75c 100644 --- a/src/commands/RedactCommand.ts +++ b/src/commands/HardRedactCommand.ts @@ -1,5 +1,5 @@ /* -Copyright 2019 The Matrix.org Foundation C.I.C. +Copyright 2019-2022 The Matrix.org Foundation C.I.C. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -18,8 +18,16 @@ import { Mjolnir } from "../Mjolnir"; import { redactUserMessagesIn } from "../utils"; import { Permalinks } from "matrix-bot-sdk"; +/*! + * "Hard redaction", cannot be undone. + * + * Instruct all participant homeservers to permanently erase the + * contents of the message from the timeline and to request the + * same from all clients. + */ + // !mjolnir redact [room alias] [limit] -export async function execRedactCommand(roomId: string, event: any, mjolnir: Mjolnir, parts: string[]) { +export async function execHardRedactCommand(roomId: string, event: any, mjolnir: Mjolnir, parts: string[]) { const userId = parts[2]; let roomAlias: string|null = null; let limit = Number.parseInt(parts.length > 3 ? parts[3] : "", 10); // default to NaN for later diff --git a/src/commands/SoftRedactCommand.ts b/src/commands/SoftRedactCommand.ts new file mode 100644 index 0000000..7ee5157 --- /dev/null +++ b/src/commands/SoftRedactCommand.ts @@ -0,0 +1,66 @@ +/* +Copyright 2022 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import { Mjolnir } from "../Mjolnir"; +import { redactUserMessagesIn } from "../utils"; +import { Permalinks } from "matrix-bot-sdk"; + +/*! + * "Soft redaction", aka "undoable redaction". + * + * 1. Instruct all clients to mark the message as hidden + * pending moderation. Only moderators and the author + * of the message may still see it. + * 2. Copy the message to the trashcan room. + * 3. In the trashcan room, moderators have the ability + * to either restore the message or trash (i.e. redact) + * it permanently. + */ + +// !mjolnir hide [room alias] [limit] +export async function execSoftRedactCommand(roomId: string, instructionEvent: any, mjolnir: Mjolnir, parts: string[]) { + // This could either be an eventId or a userId. + const targetId = parts[2]; + let roomAlias: string | null = null; + let limit = Number.parseInt(parts.length > 3 ? parts[3] : "", 10); // default to NaN for later + if (parts.length > 3 && isNaN(limit)) { + roomAlias = await mjolnir.client.resolveRoom(parts[3]); + if (parts.length > 4) { + limit = Number.parseInt(parts[4], 10); + } + } + + // Make sure we always have a limit set + if (isNaN(limit)) limit = 1000; + + const processingReactionId = await mjolnir.client.unstableApis.addReactionToEvent(roomId, instructionEvent['event_id'], 'In Progress'); + + if (userId[0] !== '@') { + // Assume it's a permalink + const parsed = Permalinks.parseUrl(parts[2]); + const targetRoomId = await mjolnir.client.resolveRoom(parsed.roomIdOrAlias); + await mjolnir.client.redactEvent(targetRoomId, parsed.eventId); + await mjolnir.client.unstableApis.addReactionToEvent(roomId, instructionEvent['event_id'], '✅'); + await mjolnir.client.redactEvent(roomId, processingReactionId, 'done processing command'); + return; + } + + const targetRoomIds = roomAlias ? [roomAlias] : Object.keys(mjolnir.protectedRooms); + await redactUserMessagesIn(mjolnir.client, userId, targetRoomIds, limit); + + await mjolnir.client.unstableApis.addReactionToEvent(roomId, instructionEvent['event_id'], '✅'); + await mjolnir.client.redactEvent(roomId, processingReactionId, 'done processing'); +} diff --git a/src/config.ts b/src/config.ts index d1108e3..a1e0399 100644 --- a/src/config.ts +++ b/src/config.ts @@ -76,7 +76,15 @@ interface IConfig { abuseReporting: { enabled: boolean; } - } + }; + trashcan?: { + roomAliasOrId: string; + softRedact?: { + goodAfterMs: number; + badAfterMs: number; + retainMs: number; + } + }; /** * Config options only set at runtime. Try to avoid using the objects