Flesh out RoomDiscovery some more.

I really need to move the room details to the audit log, it's there
so we can see what the rooms are that have been takendown.
This commit is contained in:
gnuxie
2025-03-21 16:19:09 +00:00
parent 4fdb142ac2
commit 7f0a8da41f
9 changed files with 267 additions and 40 deletions
@@ -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<Result<void>> {
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;
}
}
}
}
+6 -12
View File
@@ -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<Result<RoomBasicDetails>>;
}
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<Result<boolean>>;
takedownRoom(roomID: StringRoomID): Promise<Result<RoomTakedownDetails>>;
takedownRoom(roomID: StringRoomID): Promise<Result<RoomBasicDetails>>;
} & Capability;
describeCapabilityInterface({
@@ -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 (
<details>
@@ -83,7 +81,7 @@ class StandardRoomTakedownCapabilityRenderer implements RoomTakedownCapability {
public async takedownRoom(
roomID: StringRoomID
): Promise<Result<RoomTakedownDetails>> {
): Promise<Result<RoomBasicDetails>> {
const capabilityResult = await this.capability.takedownRoom(roomID);
if (isError(capabilityResult)) {
this.messageCollector.addOneliner(
@@ -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<Result<RoomBasicDetails>> {
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<Result<RoomTakedownDetails>> {
const detailsResponse = await this.adminClient.getRoomDetails(roomID);
let details: RoomTakedownDetails;
): Promise<Result<RoomBasicDetails>> {
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,
+48 -3
View File
@@ -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<StringRoomID>();
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 (
@@ -0,0 +1,32 @@
// SPDX-FileCopyrightText: 2025 Gnuxie <Gnuxie@protonmail.com>
//
// 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 (
<fragment>
<h3>Room Discovered</h3>
<ul>
<li>
name: <code>{details.name ?? "Unamed room"}</code>
</li>
<li>
member count: <code>{details.joined_members ?? "unknown"}</code>
</li>
<li>
room ID: <code>{details.room_id}</code>
</li>
<li>
creator: <code>{details.creator ?? "unknown"}</code>
</li>
<li>
topic: <pre>{details.topic ?? "unknown"}</pre>
</li>
</ul>
</fragment>
);
}
@@ -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<Result<void>>;
@@ -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<RoomTakedownProtectionCapabilities, Draupnir>({
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<RoomTakedownProtectionCapabilities, Draupnir>({
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<RoomTakedownProtectionCapabilities, Draupnir>({
);
}
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<RoomTakedownProtectionCapabilities, Draupnir>({
protectedRoomsSet,
draupnir.stores.hashStore,
draupnir.stores.roomAuditLog,
draupnir.clientPlatform.toRoomMessageSender(),
settings.discoveryNotificationEnabled,
settings.discoveryNotificationMembershipThreshold,
settings.discoveryNotificationRoom ?? draupnir.managementRoomID,
roomDiscovery
)
);
@@ -0,0 +1,3 @@
// SPDX-FileCopyrightText: 2025 Gnuxie <Gnuxie@protonmail.com>
//
// SPDX-License-Identifier: AFL-3.0