diff --git a/src/protections/RoomTakedown/RoomDiscovery.ts b/src/protections/RoomTakedown/RoomDiscovery.ts new file mode 100644 index 00000000..bddbdca6 --- /dev/null +++ b/src/protections/RoomTakedown/RoomDiscovery.ts @@ -0,0 +1,106 @@ +// SPDX-FileCopyrightText: 2025 Gnuxie +// +// SPDX-License-Identifier: Apache-2.0 + +import { + ConstantPeriodItemBatch, + isError, + Logger, + SHA256RoomHashStore, + StandardBatcher, +} 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"; + +const log = new Logger("SynapseHTTPAntispamRoomDiscovery"); + +export interface RoomDiscovery { + unregisterListeners(): void; +} + +export class SynapseHTTPAntispamRoomDiscovery implements RoomDiscovery { + private readonly discoveredRooms = new Set(); + private readonly batcher = new StandardBatcher( + () => + new ConstantPeriodItemBatch( + this.forwardDiscoveredBatch, + { waitPeriodMS: 2500 } + ) + ); + constructor( + private readonly synapseHTTPAntispam: SynapseHttpAntispam, + private readonly hashStore: SHA256RoomHashStore + ) { + synapseHTTPAntispam.checkEventForSpamHandles.registerNonBlockingHandle( + this.handleCheckEventForSpam + ); + synapseHTTPAntispam.userMayInviteHandles.registerNonBlockingHandle( + this.handleUserMayInvite + ); + synapseHTTPAntispam.userMayJoinRoomHandles.registerNonBlockingHandle( + this.handleUserMayJoin + ); + } + + private readonly forwardDiscoveredBatch = async function ( + this: SynapseHTTPAntispamRoomDiscovery, + rawEntries: [StringRoomID][] + ): Promise { + const entries = rawEntries.flat(); + const storeResult = await this.hashStore.storeUndiscoveredRooms(entries); + if (isError(storeResult)) { + log.error( + "Unxpected error while trying to store undiscovered rooms", + storeResult.error + ); + return; + } + for (const roomID of entries) { + this.discoveredRooms.add(roomID); + } + }.bind(this); + + private readonly handleCheckEventForSpam = function ( + this: SynapseHTTPAntispamRoomDiscovery, + { event }: CheckEventForSpamRequestBody + ): void { + if (event.type === "m.room.member") { + if (!this.discoveredRooms.has(event.room_id)) { + this.batcher.add(event.room_id); + } + } + }.bind(this); + + private readonly handleUserMayInvite = function ( + this: SynapseHTTPAntispamRoomDiscovery, + { room_id }: UserMayInviteRequestBody + ): void { + if (!this.discoveredRooms.has(room_id)) { + this.batcher.add(room_id); + } + }.bind(this); + + private readonly handleUserMayJoin = function ( + this: SynapseHTTPAntispamRoomDiscovery, + { room }: UserMayJoinRoomRequestBody + ): void { + if (!this.discoveredRooms.has(room)) { + this.batcher.add(room); + } + }.bind(this); + + unregisterListeners(): void { + this.synapseHTTPAntispam.checkEventForSpamHandles.unregisterHandle( + this.handleCheckEventForSpam + ); + this.synapseHTTPAntispam.userMayInviteHandles.unregisterHandle( + this.handleUserMayInvite + ); + this.synapseHTTPAntispam.userMayJoinRoomHandles.unregisterHandle( + this.handleUserMayJoin + ); + } +} diff --git a/src/protections/RoomTakedown/RoomTakedownProtection.ts b/src/protections/RoomTakedown/RoomTakedownProtection.ts index ae6b60c5..6d3ff8a4 100644 --- a/src/protections/RoomTakedown/RoomTakedownProtection.ts +++ b/src/protections/RoomTakedown/RoomTakedownProtection.ts @@ -6,6 +6,7 @@ import { AbstractProtection, ActionResult, describeProtection, + Logger, Ok, PolicyListRevision, PolicyRuleChange, @@ -21,12 +22,13 @@ 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 { + RoomDiscovery, + SynapseHTTPAntispamRoomDiscovery, +} from "./RoomDiscovery"; -// FIXME: We still haven't figured out how to poll for new rooms via the -// Synapse admin API. - -// FIXME: We need to add the stores to draupnir somehow. -// probably from the toplevel. +const log = new Logger("RoomTakedownProtection"); type RoomTakedownProtectionCapabilities = { roomTakedownCapability: RoomTakedownCapability; @@ -48,7 +50,8 @@ export class RoomTakedownProtection capabilities: RoomTakedownProtectionCapabilities, protectedRoomsSet: ProtectedRoomsSet, hashStore: SHA256RoomHashStore, - auditLog: RoomAuditLog + auditLog: RoomAuditLog, + private readonly roomDiscovery: RoomDiscovery | undefined ) { super(description, capabilities, protectedRoomsSet, {}); this.roomTakedown = new StandardRoomTakedown( @@ -69,6 +72,10 @@ export class RoomTakedownProtection ): Promise> { return this.roomTakedown.handlePolicyChange(revision, changes); } + + handleProtectionDisable(): void { + this.roomDiscovery?.unregisterListeners(); + } } describeProtection({ @@ -81,13 +88,35 @@ describeProtection({ roomTakedownCapability: SynapseAdminRoomTakedownCapability.name, }, factory(description, protectedRoomsSet, draupnir, capabilitySet, _settings) { + if ( + draupnir.stores.hashStore === undefined || + draupnir.stores.roomAuditLog === undefined + ) { + return ResultError.Result( + "This protection requires a hash store and audit log to be available to draupnir, and they are not in your configuration." + ); + } + const roomDiscovery = (() => { + if (draupnir.synapseHTTPAntispam !== undefined) { + return new SynapseHTTPAntispamRoomDiscovery( + draupnir.synapseHTTPAntispam, + draupnir.stores.hashStore + ); + } else { + log.warn( + "synapseHTTPAntispam is not configured for this draupnir, and will not be used for room discovery" + ); + return undefined; + } + })(); return Ok( new RoomTakedownProtection( description, capabilitySet, protectedRoomsSet, - draupnir.hashStore, - draupnir.auditLog + draupnir.stores.hashStore, + draupnir.stores.roomAuditLog, + roomDiscovery ) ); },