diff --git a/src/backingstore/better-sqlite3/HashStore.ts b/src/backingstore/better-sqlite3/HashStore.ts index 58de2c63..53eaeee5 100644 --- a/src/backingstore/better-sqlite3/HashStore.ts +++ b/src/backingstore/better-sqlite3/HashStore.ts @@ -10,6 +10,7 @@ import { HashedLiteralPolicyRule, LiteralPolicyRule, makeReversedHashedPolicy, + RoomBasicDetails, RoomHashRecord, SHA256RoomHashStore, } from "matrix-protection-suite"; @@ -29,6 +30,14 @@ const schema = [ room_id TEXT PRIMARY KEY NOT NULL, sha256 TEXT NOT NULL ) STRICT;`, + `CREATE TABLE room_detail ( + room_id TEXT PRIMARY KEY NOT NULL, + creator TEXT, + name TEXT, + topic TEXT, + joined_members INTEGER + ) STRICT; +`, ]; type RoomSha256 = { @@ -197,4 +206,33 @@ export class SqliteHashReversalStore } } } + + public async storeRoomDetails( + roomDetails: RoomBasicDetails + ): Promise> { + try { + const statement = this.db.prepare(` + REPLACE INTO room_detail SELECT + value ->> 'room_id', + value ->> 'creator', + value ->> 'name', + value ->> 'topic', + value ->> 'joined_members' + FROM json_each(?)`); + statement.run(JSON.stringify(roomDetails)); + return Ok(undefined); + } catch (exception) { + if (exception instanceof Error) { + return ActionException.Result( + `Error while trying to store details about a hashed room`, + { + exception, + exceptionKind: ActionExceptionKind.Unknown, + } + ); + } else { + throw exception; + } + } + } } diff --git a/src/capabilities/RoomTakedownCapability.tsx b/src/capabilities/RoomTakedownCapability.tsx index 55cb3dac..dfe060ab 100644 --- a/src/capabilities/RoomTakedownCapability.tsx +++ b/src/capabilities/RoomTakedownCapability.tsx @@ -4,23 +4,17 @@ import { Result } from "@gnuxie/typescript-result"; import { Type } from "@sinclair/typebox"; -import { - StringRoomID, - StringUserID, -} from "@the-draupnir-project/matrix-basic-types"; +import { StringRoomID } from "@the-draupnir-project/matrix-basic-types"; import { Capability, CapabilityMethodSchema, describeCapabilityInterface, + RoomBasicDetails, } from "matrix-protection-suite"; -export type RoomTakedownDetails = Partial<{ - creator: StringUserID | undefined; - room_id: StringRoomID; - name: string | undefined; - topic: string | undefined; - avatar: string | undefined; -}>; +export interface RoomDetailsProvider { + getRoomDetails(roomID: StringRoomID): Promise>; +} export const RoomTakedownCapability = Type.Intersect([ Type.Object({ @@ -35,7 +29,7 @@ export const RoomTakedownCapability = Type.Intersect([ // to get those details on conduwuit. export type RoomTakedownCapability = { isRoomTakendown(roomID: StringRoomID): Promise>; - takedownRoom(roomID: StringRoomID): Promise>; + takedownRoom(roomID: StringRoomID): Promise>; } & Capability; describeCapabilityInterface({ diff --git a/src/capabilities/RoomTakedownCapabilityRenderer.tsx b/src/capabilities/RoomTakedownCapabilityRenderer.tsx index 11f53613..0792dde0 100644 --- a/src/capabilities/RoomTakedownCapabilityRenderer.tsx +++ b/src/capabilities/RoomTakedownCapabilityRenderer.tsx @@ -5,11 +5,9 @@ import { describeCapabilityRenderer, DescriptionMeta, + RoomBasicDetails, } from "matrix-protection-suite"; -import { - RoomTakedownCapability, - RoomTakedownDetails, -} from "./RoomTakedownCapability"; +import { RoomTakedownCapability } from "./RoomTakedownCapability"; import { RendererMessageCollector } from "./RendererMessageCollector"; import { MatrixRoomReference, @@ -37,7 +35,7 @@ function renderCodeOrDefault( function renderTakedown( roomID: StringRoomID, - details: RoomTakedownDetails + details: RoomBasicDetails ): DocumentNode { return (
@@ -83,7 +81,7 @@ class StandardRoomTakedownCapabilityRenderer implements RoomTakedownCapability { public async takedownRoom( roomID: StringRoomID - ): Promise> { + ): Promise> { const capabilityResult = await this.capability.takedownRoom(roomID); if (isError(capabilityResult)) { this.messageCollector.addOneliner( diff --git a/src/capabilities/SynapseAdminRoomTakedown/SynapseAdminRoomTakedown.ts b/src/capabilities/SynapseAdminRoomTakedown/SynapseAdminRoomTakedown.ts index 88bb4490..e0db23e7 100644 --- a/src/capabilities/SynapseAdminRoomTakedown/SynapseAdminRoomTakedown.ts +++ b/src/capabilities/SynapseAdminRoomTakedown/SynapseAdminRoomTakedown.ts @@ -3,22 +3,47 @@ // SPDX-License-Identifier: Apache-2.0 import { StringRoomID } from "@the-draupnir-project/matrix-basic-types"; -import { Logger } from "matrix-protection-suite"; +import { Logger, RoomBasicDetails } from "matrix-protection-suite"; import { + RoomDetailsProvider, RoomTakedownCapability, - RoomTakedownDetails, } from "../RoomTakedownCapability"; import { SynapseAdminClient } from "matrix-protection-suite-for-matrix-bot-sdk"; import { isError, Ok, Result } from "@gnuxie/typescript-result"; const log = new Logger("SynapseAdminRoomTakedownCapability"); +export class SynapseAdminRoomDetailsProvider implements RoomDetailsProvider { + public constructor(private readonly adminClient: SynapseAdminClient) { + // nothing to do mare. + } + public async getRoomDetails( + roomID: StringRoomID + ): Promise> { + const detailsResponse = await this.adminClient.getRoomDetails(roomID); + if (isError(detailsResponse)) { + return detailsResponse; + } else { + return Ok({ + name: detailsResponse.ok?.name ?? undefined, + creator: detailsResponse.ok?.creator, + avatar: detailsResponse.ok?.avatar ?? undefined, + topic: detailsResponse.ok?.topic ?? undefined, + room_id: roomID, + }); + } + } +} + export class SynapseAdminRoomTakedownCapability implements RoomTakedownCapability { public readonly requiredPermissions = []; public readonly requiredStatePermissions = []; public readonly requiredEventPermissions = []; + private readonly roomDetailsProvider = new SynapseAdminRoomDetailsProvider( + this.adminClient + ); public constructor(private readonly adminClient: SynapseAdminClient) { // nothing to do mare. } @@ -33,9 +58,10 @@ export class SynapseAdminRoomTakedownCapability public async takedownRoom( roomID: StringRoomID - ): Promise> { - const detailsResponse = await this.adminClient.getRoomDetails(roomID); - let details: RoomTakedownDetails; + ): Promise> { + const detailsResponse = + await this.roomDetailsProvider.getRoomDetails(roomID); + let details: RoomBasicDetails; if (isError(detailsResponse)) { log.warn( "Unable to fetch details for a room being requested to shutdown", @@ -43,13 +69,7 @@ export class SynapseAdminRoomTakedownCapability ); details = { room_id: roomID }; } else { - details = { - name: detailsResponse.ok?.name ?? undefined, - creator: detailsResponse.ok?.creator, - avatar: detailsResponse.ok?.avatar ?? undefined, - topic: detailsResponse.ok?.topic ?? undefined, - room_id: roomID, - }; + details = detailsResponse.ok; } const takedownResult = await this.adminClient.deleteRoom(roomID, { block: true, diff --git a/src/protections/RoomTakedown/RoomDiscovery.ts b/src/protections/RoomTakedown/RoomDiscovery.ts index bddbdca6..6d1cd66d 100644 --- a/src/protections/RoomTakedown/RoomDiscovery.ts +++ b/src/protections/RoomTakedown/RoomDiscovery.ts @@ -6,22 +6,34 @@ import { ConstantPeriodItemBatch, isError, Logger, + RoomBasicDetails, + RoomHashRecord, SHA256RoomHashStore, StandardBatcher, + Task, } from "matrix-protection-suite"; import { CheckEventForSpamRequestBody } from "../../webapis/SynapseHTTPAntispam/CheckEventForSpamEndpoint"; import { SynapseHttpAntispam } from "../../webapis/SynapseHTTPAntispam/SynapseHttpAntispam"; import { StringRoomID } from "@the-draupnir-project/matrix-basic-types"; import { UserMayInviteRequestBody } from "../../webapis/SynapseHTTPAntispam/UserMayInviteEndpoint"; import { UserMayJoinRoomRequestBody } from "../../webapis/SynapseHTTPAntispam/UserMayJoinRoomEndpoint"; +import { EventEmitter } from "stream"; +import { RoomDetailsProvider } from "../../capabilities/RoomTakedownCapability"; const log = new Logger("SynapseHTTPAntispamRoomDiscovery"); +export type RoomDiscoveryListener = (details: RoomBasicDetails) => void; + export interface RoomDiscovery { unregisterListeners(): void; + on(event: "RoomDiscovery", listener: RoomDiscoveryListener): this; + off(event: "RoomDiscovery", listener: RoomDiscoveryListener): this; + emit(event: "RoomDiscovery", details: RoomBasicDetails): void; } - -export class SynapseHTTPAntispamRoomDiscovery implements RoomDiscovery { +export class SynapseHTTPAntispamRoomDiscovery + extends EventEmitter + implements RoomDiscovery +{ private readonly discoveredRooms = new Set(); private readonly batcher = new StandardBatcher( () => @@ -32,8 +44,10 @@ export class SynapseHTTPAntispamRoomDiscovery implements RoomDiscovery { ); constructor( private readonly synapseHTTPAntispam: SynapseHttpAntispam, - private readonly hashStore: SHA256RoomHashStore + private readonly hashStore: SHA256RoomHashStore, + private readonly roomDetailsProvider: RoomDetailsProvider ) { + super(); synapseHTTPAntispam.checkEventForSpamHandles.registerNonBlockingHandle( this.handleCheckEventForSpam ); @@ -45,6 +59,36 @@ export class SynapseHTTPAntispamRoomDiscovery implements RoomDiscovery { ); } + private addRoomDetails(discoveredRooms: RoomHashRecord[]): void { + void Task( + (async () => { + for (const { room_id: roomID } of discoveredRooms) { + const detailsResult = + await this.roomDetailsProvider.getRoomDetails(roomID); + if (isError(detailsResult)) { + log.error( + "Error fetching details for a discovered room", + roomID, + detailsResult.error + ); + continue; + } + this.emit("RoomDiscovery", detailsResult.ok); + const storeResult = await this.hashStore.storeRoomDetails( + detailsResult.ok + ); + if (isError(storeResult)) { + log.error( + "Error storing room details for a room", + roomID, + detailsResult.ok + ); + } + } + })() + ); + } + private readonly forwardDiscoveredBatch = async function ( this: SynapseHTTPAntispamRoomDiscovery, rawEntries: [StringRoomID][] @@ -61,6 +105,7 @@ export class SynapseHTTPAntispamRoomDiscovery implements RoomDiscovery { for (const roomID of entries) { this.discoveredRooms.add(roomID); } + this.addRoomDetails(storeResult.ok); }.bind(this); private readonly handleCheckEventForSpam = function ( diff --git a/src/protections/RoomTakedown/RoomDiscoveryRenderer.tsx b/src/protections/RoomTakedown/RoomDiscoveryRenderer.tsx new file mode 100644 index 00000000..b3af140d --- /dev/null +++ b/src/protections/RoomTakedown/RoomDiscoveryRenderer.tsx @@ -0,0 +1,32 @@ +// SPDX-FileCopyrightText: 2025 Gnuxie +// +// SPDX-License-Identifier: AFL-3.0 + +import { DocumentNode } from "@the-draupnir-project/interface-manager"; +import { RoomBasicDetails } from "matrix-protection-suite"; +import { DeadDocumentJSX } from "@the-draupnir-project/interface-manager"; + +export function renderDiscoveredRoom(details: RoomBasicDetails): DocumentNode { + return ( + +

Room Discovered

+
    +
  • + name: {details.name ?? "Unamed room"} +
  • +
  • + member count: {details.joined_members ?? "unknown"} +
  • +
  • + room ID: {details.room_id} +
  • +
  • + creator: {details.creator ?? "unknown"} +
  • +
  • + topic:
    {details.topic ?? "unknown"}
    +
  • +
+
+ ); +} diff --git a/src/protections/RoomTakedown/RoomTakedown.ts b/src/protections/RoomTakedown/RoomTakedown.ts index db4fb3a3..778734c7 100644 --- a/src/protections/RoomTakedown/RoomTakedown.ts +++ b/src/protections/RoomTakedown/RoomTakedown.ts @@ -22,6 +22,10 @@ const log = new Logger("RoomTakedown"); // FIXME: How can we segment this so that rooms are takendown on prompt in // the abscence of policy approval? +// Probably by using a simulated capability that asks to confirm and then +// does the takedown for real? +// Probably by using an updated version of the shutdown command... +// Ok no that all sucks we'll have to wait for policy approval... export type RoomTakedownService = { handleDiscoveredRooms(rooms: StringRoomID[]): Promise>; diff --git a/src/protections/RoomTakedown/RoomTakedownProtection.ts b/src/protections/RoomTakedown/RoomTakedownProtection.ts index 6d3ff8a4..1e70a7c6 100644 --- a/src/protections/RoomTakedown/RoomTakedownProtection.ts +++ b/src/protections/RoomTakedown/RoomTakedownProtection.ts @@ -13,30 +13,68 @@ import { ProtectedRoomsSet, Protection, ProtectionDescription, + RoomBasicDetails, + RoomMessageSender, SHA256RoomHashStore, + StringRoomIDSchema, Task, - UnknownConfig, } from "matrix-protection-suite"; import { RoomTakedownCapability } from "../../capabilities/RoomTakedownCapability"; import { Draupnir } from "../../Draupnir"; import { StandardRoomTakedown } from "./RoomTakedown"; import { RoomAuditLog } from "./RoomAuditLog"; -import { SynapseAdminRoomTakedownCapability } from "../../capabilities/SynapseAdminRoomTakedown/SynapseAdminRoomTakedown"; -import { ResultError } from "@gnuxie/typescript-result"; +import { + SynapseAdminRoomDetailsProvider, + SynapseAdminRoomTakedownCapability, +} from "../../capabilities/SynapseAdminRoomTakedown/SynapseAdminRoomTakedown"; +import { isError, ResultError } from "@gnuxie/typescript-result"; import { RoomDiscovery, + RoomDiscoveryListener, SynapseHTTPAntispamRoomDiscovery, } from "./RoomDiscovery"; +import { StringRoomID } from "@the-draupnir-project/matrix-basic-types"; +import { sendMatrixEventsFromDeadDocument } from "../../commands/interface-manager/MPSMatrixInterfaceAdaptor"; +import { wrapInRoot } from "../../commands/interface-manager/MatrixHelpRenderer"; +import { Type } from "@sinclair/typebox"; +import { EDStatic } from "matrix-protection-suite/dist/Interface/Static"; +import { renderDiscoveredRoom } from "./RoomDiscoveryRenderer"; const log = new Logger("RoomTakedownProtection"); +const RoomTakedownProtectionSettings = Type.Object({ + discoveryNotificationMembershipThreshold: Type.Integer({ + default: 20, + description: + "The number of members required in the room for it to appear in the notification. This is to prevent showing direct messages or small rooms that could be too much of an invasion of privacy. We don't have access to enough information to determine this a better way.", + }), + // There needs to be a transform for room references + discoveryNotificationRoom: Type.Union( + [StringRoomIDSchema, Type.Undefined()], + { + default: undefined, + description: + "The room where notifications should be sent. Currently broken and needs to be edited from a state event while we figure something out", + } + ), + discoveryNotificationEnabled: Type.Boolean({ + default: true, + description: + "Wether to send notifications for newly discovered rooms from the homerserver.", + }), +}); + +type RoomTakedownProtectionSettings = EDStatic< + typeof RoomTakedownProtectionSettings +>; + type RoomTakedownProtectionCapabilities = { roomTakedownCapability: RoomTakedownCapability; }; type RoomTakedownProtectionDescription = ProtectionDescription< Draupnir, - UnknownConfig, + typeof RoomTakedownProtectionSettings, RoomTakedownProtectionCapabilities >; @@ -51,6 +89,10 @@ export class RoomTakedownProtection protectedRoomsSet: ProtectedRoomsSet, hashStore: SHA256RoomHashStore, auditLog: RoomAuditLog, + private readonly roomMessageSender: RoomMessageSender, + private readonly discoveryNotificationEnabled: boolean, + private readonly discoveryNotificationMembershipThreshold: number, + private readonly discoveryNotificationRoom: StringRoomID, private readonly roomDiscovery: RoomDiscovery | undefined ) { super(description, capabilities, protectedRoomsSet, {}); @@ -64,8 +106,40 @@ export class RoomTakedownProtection this.protectedRoomsSet.watchedPolicyRooms.currentRevision ) ); + if (this.discoveryNotificationEnabled) { + this.roomDiscovery?.on("RoomDiscovery", this.roomDiscoveryListener); + } } + private readonly roomDiscoveryListener: RoomDiscoveryListener = function ( + this: RoomTakedownProtection, + details: RoomBasicDetails + ) { + if ( + (details.joined_members ?? 0) < + this.discoveryNotificationMembershipThreshold + ) { + return; + } + void Task( + (async () => { + const sendResult = await sendMatrixEventsFromDeadDocument( + this.roomMessageSender, + this.discoveryNotificationRoom, + wrapInRoot(renderDiscoveredRoom(details)), + {} + ); + if (isError(sendResult)) { + log.error( + "Error sending a notification about a discovered room", + details.room_id, + sendResult.error + ); + } + })() + ); + }.bind(this); + handlePolicyChange( revision: PolicyListRevision, changes: PolicyRuleChange[] @@ -75,10 +149,15 @@ export class RoomTakedownProtection handleProtectionDisable(): void { this.roomDiscovery?.unregisterListeners(); + this.roomDiscovery?.off("RoomDiscovery", this.roomDiscoveryListener); } } -describeProtection({ +describeProtection< + RoomTakedownProtectionCapabilities, + Draupnir, + typeof RoomTakedownProtectionSettings +>({ name: RoomTakedownProtection.name, description: `A protection to shutdown rooms matching policies from watched lists`, capabilityInterfaces: { @@ -87,7 +166,7 @@ describeProtection({ defaultCapabilities: { roomTakedownCapability: SynapseAdminRoomTakedownCapability.name, }, - factory(description, protectedRoomsSet, draupnir, capabilitySet, _settings) { + factory(description, protectedRoomsSet, draupnir, capabilitySet, settings) { if ( draupnir.stores.hashStore === undefined || draupnir.stores.roomAuditLog === undefined @@ -97,10 +176,20 @@ describeProtection({ ); } const roomDiscovery = (() => { + const roomDetailsProvider = draupnir.synapseAdminClient + ? new SynapseAdminRoomDetailsProvider(draupnir.synapseAdminClient) + : undefined; + if (roomDetailsProvider === undefined) { + log.warn( + "This protection currently requires synapse admin capability in order to fetch room details" + ); + return undefined; + } if (draupnir.synapseHTTPAntispam !== undefined) { return new SynapseHTTPAntispamRoomDiscovery( draupnir.synapseHTTPAntispam, - draupnir.stores.hashStore + draupnir.stores.hashStore, + roomDetailsProvider ); } else { log.warn( @@ -116,6 +205,10 @@ describeProtection({ protectedRoomsSet, draupnir.stores.hashStore, draupnir.stores.roomAuditLog, + draupnir.clientPlatform.toRoomMessageSender(), + settings.discoveryNotificationEnabled, + settings.discoveryNotificationMembershipThreshold, + settings.discoveryNotificationRoom ?? draupnir.managementRoomID, roomDiscovery ) ); diff --git a/src/protections/RoomTakedown/SynapseAdminRoomDetails.ts b/src/protections/RoomTakedown/SynapseAdminRoomDetails.ts new file mode 100644 index 00000000..b1678c30 --- /dev/null +++ b/src/protections/RoomTakedown/SynapseAdminRoomDetails.ts @@ -0,0 +1,3 @@ +// SPDX-FileCopyrightText: 2025 Gnuxie +// +// SPDX-License-Identifier: AFL-3.0