mirror of
https://github.com/the-draupnir-project/Draupnir.git
synced 2026-10-23 01:21:16 +00:00
Merge pull request #761 from the-draupnir-project/gnuxie/room-takedown
Support for room policies with hashed entity and `org.matrix.msc4204.takedown` recommendation in Synapse Story: https://github.com/the-draupnir-project/planning/issues/41 documentation: https://the-draupnir-project.github.io/draupnir-documentation/protections/room-takedown-protection This PR introduces room takedown functionality into Draupnir. A new `draupnir takedown` command is added similar to the ban command, but marks entities to be taken down. Because the content is illegal or intolerable. To begin with we only allow takedown of rooms. These takedown policies are sharable with policy lists just like normal bans. Draupnir responds to takedown policies on Synapse by calling the [room shutdown](https://element-hq.github.io/synapse/latest/admin_api/rooms.html#version-2-new-version) API with the options `block` and `purge`. The policies that are created by the takedown command are hashed, and this is in order to prevent the room id's being shared directly, and so that we do not create a directory of intolerable content. To be able to use the policies, draupnir therefore needs to be aware of all the rooms that the homeserver is participating in, in order to calculate their hashes and find matching policies, and then takedown the marked rooms. As part of this process, Draupnir has to "discover" the rooms your server is participating in. This is done via the [synapse-http-antispam](https://the-draupnir-project.github.io/draupnir-documentation/bot/synapse-http-antispam) recently added to draupnir. When draupnir discovers rooms, it will prompt the management room with a notification with some details of the title, room description, and creator. This functionality will be toggleable but will be strongly recommended for servers that have public registration
This commit is contained in:
48 files changed
+2919
-306
No files matched your search
+3
-3
@@ -53,7 +53,7 @@
|
||||
"@sentry/node": "^7.17.2",
|
||||
"@sinclair/typebox": "0.34.13",
|
||||
"@the-draupnir-project/interface-manager": "4.0.2",
|
||||
"@the-draupnir-project/matrix-basic-types": "1.2.0",
|
||||
"@the-draupnir-project/matrix-basic-types": "1.3.0",
|
||||
"better-sqlite3": "^9.4.3",
|
||||
"body-parser": "^1.20.2",
|
||||
"config": "^3.3.9",
|
||||
@@ -63,8 +63,8 @@
|
||||
"jsdom": "^24.0.0",
|
||||
"matrix-appservice-bridge": "^10.3.1",
|
||||
"matrix-bot-sdk": "npm:@vector-im/matrix-bot-sdk@^0.7.1-element.6",
|
||||
"matrix-protection-suite": "npm:@gnuxie/matrix-protection-suite@2.10.0",
|
||||
"matrix-protection-suite-for-matrix-bot-sdk": "npm:@gnuxie/matrix-protection-suite-for-matrix-bot-sdk@2.10.1",
|
||||
"matrix-protection-suite": "npm:@gnuxie/matrix-protection-suite@3.0.0",
|
||||
"matrix-protection-suite-for-matrix-bot-sdk": "npm:@gnuxie/matrix-protection-suite-for-matrix-bot-sdk@3.0.0",
|
||||
"pg": "^8.8.0",
|
||||
"yaml": "^2.3.2"
|
||||
},
|
||||
|
||||
@@ -82,6 +82,7 @@ import {
|
||||
makeConfirmationPromptListener,
|
||||
} from "./commands/interface-manager/MatrixPromptForConfirmation";
|
||||
import { SynapseHttpAntispam } from "./webapis/SynapseHTTPAntispam/SynapseHttpAntispam";
|
||||
import { DraupnirStores } from "./backingstore/DraupnirStores";
|
||||
const log = new Logger("Draupnir");
|
||||
|
||||
// webAPIS should not be included on the Draupnir class.
|
||||
@@ -145,6 +146,7 @@ export class Draupnir implements Client, MatrixAdaptorContext {
|
||||
public readonly acceptInvitesFromRoom: MatrixRoomID,
|
||||
public readonly acceptInvitesFromRoomIssuer: RoomMembershipRevisionIssuer,
|
||||
public readonly safeModeToggle: SafeModeToggle,
|
||||
public readonly stores: DraupnirStores,
|
||||
public readonly synapseAdminClient: SynapseAdminClient | undefined,
|
||||
public readonly synapseHTTPAntispam: SynapseHttpAntispam | undefined
|
||||
) {
|
||||
@@ -212,6 +214,7 @@ export class Draupnir implements Client, MatrixAdaptorContext {
|
||||
config: IConfig,
|
||||
loggableConfigTracker: LoggableConfigTracker,
|
||||
safeModeToggle: SafeModeToggle,
|
||||
stores: DraupnirStores,
|
||||
synapseHTTPAntispam: SynapseHttpAntispam | undefined
|
||||
): Promise<ActionResult<Draupnir>> {
|
||||
const acceptInvitesFromRoom = await (async () => {
|
||||
@@ -270,6 +273,7 @@ export class Draupnir implements Client, MatrixAdaptorContext {
|
||||
acceptInvitesFromRoom.ok,
|
||||
acceptInvitesFromRoomIssuer.ok,
|
||||
safeModeToggle,
|
||||
stores,
|
||||
new SynapseAdminClient(client, clientUserID),
|
||||
synapseHTTPAntispam
|
||||
);
|
||||
@@ -388,6 +392,7 @@ export class Draupnir implements Client, MatrixAdaptorContext {
|
||||
this.clientRooms.off("timeline", this.timelineEventListener);
|
||||
this.reportPoller?.stop();
|
||||
this.protectedRoomsSet.unregisterListeners();
|
||||
this.stores.dispose();
|
||||
}
|
||||
|
||||
public createRoomReference(roomID: StringRoomID): MatrixRoomID {
|
||||
|
||||
+11
-13
@@ -11,7 +11,6 @@
|
||||
import {
|
||||
StandardClientsInRoomMap,
|
||||
DefaultEventDecoder,
|
||||
RoomStateBackingStore,
|
||||
ClientsInRoomMap,
|
||||
Task,
|
||||
Logger,
|
||||
@@ -48,12 +47,16 @@ import { SafeModeDraupnir } from "./safemode/DraupnirSafeMode";
|
||||
import { ResultError } from "@gnuxie/typescript-result";
|
||||
import { SafeModeCause, SafeModeReason } from "./safemode/SafeModeCause";
|
||||
import { SafeModeBootOption } from "./safemode/BootOption";
|
||||
import { SynapseHttpAntispam } from "./webapis/SynapseHTTPAntispam/SynapseHttpAntispam";
|
||||
import { TopLevelStores } from "./backingstore/DraupnirStores";
|
||||
|
||||
const log = new Logger("DraupnirBotMode");
|
||||
|
||||
export function constructWebAPIs(draupnir: Draupnir): WebAPIs {
|
||||
return new WebAPIs(draupnir.reportManager, draupnir.config);
|
||||
return new WebAPIs(
|
||||
draupnir.reportManager,
|
||||
draupnir.config,
|
||||
draupnir.synapseHTTPAntispam
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -74,9 +77,6 @@ interface BotModeTogle extends SafeModeToggle {
|
||||
error: ResultError,
|
||||
options?: SafeModeToggleOptions
|
||||
): Promise<Result<SafeModeDraupnir>>;
|
||||
// The SynapseHTTPAntispam listeners, if available.
|
||||
// Which they won't be for some bot mode and all application service users.
|
||||
readonly synapseHTTPAntispam: SynapseHttpAntispam | undefined;
|
||||
}
|
||||
|
||||
export class DraupnirBotModeToggle implements BotModeTogle {
|
||||
@@ -84,10 +84,6 @@ export class DraupnirBotModeToggle implements BotModeTogle {
|
||||
private safeModeDraupnir: SafeModeDraupnir | null = null;
|
||||
private webAPIs: WebAPIs | null = null;
|
||||
|
||||
public get synapseHTTPAntispam() {
|
||||
return this.webAPIs?.synapseHTTPAntispam ?? undefined;
|
||||
}
|
||||
|
||||
private constructor(
|
||||
private readonly clientUserID: StringUserID,
|
||||
private readonly managementRoom: MatrixRoomID,
|
||||
@@ -113,7 +109,7 @@ export class DraupnirBotModeToggle implements BotModeTogle {
|
||||
client: MatrixSendClient,
|
||||
matrixEmitter: SafeMatrixEmitter,
|
||||
config: IConfig,
|
||||
backingStore?: RoomStateBackingStore
|
||||
stores: TopLevelStores
|
||||
): Promise<DraupnirBotModeToggle> {
|
||||
const clientUserID = await client.getUserId();
|
||||
if (!isStringUserID(clientUserID)) {
|
||||
@@ -163,13 +159,15 @@ export class DraupnirBotModeToggle implements BotModeTogle {
|
||||
clientsInRoomMap,
|
||||
clientProvider,
|
||||
DefaultEventDecoder,
|
||||
backingStore
|
||||
stores.roomStateBackingStore,
|
||||
stores.hashStore
|
||||
);
|
||||
const draupnirFactory = new DraupnirFactory(
|
||||
clientsInRoomMap,
|
||||
clientCapabilityFactory,
|
||||
clientProvider,
|
||||
roomStateManagerFactory
|
||||
roomStateManagerFactory,
|
||||
stores
|
||||
);
|
||||
return new DraupnirBotModeToggle(
|
||||
clientUserID,
|
||||
|
||||
@@ -35,7 +35,6 @@ import {
|
||||
ClientsInRoomMap,
|
||||
DefaultEventDecoder,
|
||||
EventDecoder,
|
||||
RoomStateBackingStore,
|
||||
StandardClientsInRoomMap,
|
||||
Task,
|
||||
isError,
|
||||
@@ -49,6 +48,7 @@ import {
|
||||
StringUserID,
|
||||
} from "@the-draupnir-project/matrix-basic-types";
|
||||
import { SqliteRoomStateBackingStore } from "../backingstore/better-sqlite3/SqliteRoomStateBackingStore";
|
||||
import { TopLevelStores } from "../backingstore/DraupnirStores";
|
||||
|
||||
const log = new Logger("AppService");
|
||||
/**
|
||||
@@ -100,7 +100,7 @@ export class MjolnirAppService {
|
||||
dataStore: DataStore,
|
||||
eventDecoder: EventDecoder,
|
||||
registrationFilePath: string,
|
||||
backingStore?: RoomStateBackingStore
|
||||
stores: TopLevelStores
|
||||
) {
|
||||
const bridge = new Bridge({
|
||||
homeserverUrl: config.homeserver.url,
|
||||
@@ -128,7 +128,8 @@ export class MjolnirAppService {
|
||||
clientsInRoomMap,
|
||||
clientProvider,
|
||||
eventDecoder,
|
||||
backingStore
|
||||
stores.roomStateBackingStore,
|
||||
stores.hashStore
|
||||
);
|
||||
const clientCapabilityFactory = new ClientCapabilityFactory(
|
||||
clientsInRoomMap,
|
||||
@@ -190,6 +191,7 @@ export class MjolnirAppService {
|
||||
bridge,
|
||||
accessControl,
|
||||
roomStateManagerFactory,
|
||||
stores,
|
||||
clientCapabilityFactory,
|
||||
clientProvider,
|
||||
instanceCountGauge
|
||||
@@ -239,7 +241,7 @@ export class MjolnirAppService {
|
||||
dataStore,
|
||||
DefaultEventDecoder,
|
||||
registrationFilePath,
|
||||
backingStore
|
||||
{ roomStateBackingStore: backingStore } // we don't support any stores in appservice atm except backing store.
|
||||
);
|
||||
// The call to `start` MUST happen last. As it needs the datastore, and the mjolnir manager to be initialized before it can process events from the homeserver.
|
||||
await service.start(port);
|
||||
|
||||
@@ -45,6 +45,7 @@ import {
|
||||
userLocalpart,
|
||||
isStringRoomID,
|
||||
} from "@the-draupnir-project/matrix-basic-types";
|
||||
import { TopLevelStores } from "../backingstore/DraupnirStores";
|
||||
|
||||
const log = new Logger("AppServiceDraupnirManager");
|
||||
|
||||
@@ -63,6 +64,7 @@ export class AppServiceDraupnirManager {
|
||||
private readonly bridge: Bridge,
|
||||
private readonly accessControl: AccessControl,
|
||||
private readonly roomStateManagerFactory: RoomStateManagerFactory,
|
||||
stores: TopLevelStores,
|
||||
private readonly clientCapabilityFactory: ClientCapabilityFactory,
|
||||
clientProvider: ClientForUserID,
|
||||
private readonly instanceCountGauge: Gauge<"status" | "uuid">
|
||||
@@ -71,7 +73,8 @@ export class AppServiceDraupnirManager {
|
||||
this.roomStateManagerFactory.clientsInRoomMap,
|
||||
this.clientCapabilityFactory,
|
||||
clientProvider,
|
||||
this.roomStateManagerFactory
|
||||
this.roomStateManagerFactory,
|
||||
stores
|
||||
);
|
||||
this.baseManager = new StandardDraupnirManager(draupnirFactory);
|
||||
}
|
||||
@@ -93,6 +96,7 @@ export class AppServiceDraupnirManager {
|
||||
bridge: Bridge,
|
||||
accessControl: AccessControl,
|
||||
roomStateManagerFactory: RoomStateManagerFactory,
|
||||
stores: TopLevelStores,
|
||||
clientCapabilityFactory: ClientCapabilityFactory,
|
||||
clientProvider: ClientForUserID,
|
||||
instanceCountGauge: Gauge<"status" | "uuid">
|
||||
@@ -103,6 +107,7 @@ export class AppServiceDraupnirManager {
|
||||
bridge,
|
||||
accessControl,
|
||||
roomStateManagerFactory,
|
||||
stores,
|
||||
clientCapabilityFactory,
|
||||
clientProvider,
|
||||
instanceCountGauge
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
// SPDX-FileCopyrightText: 2025 Gnuxie <Gnuxie@protonmail.com>
|
||||
//
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import { EventDecoder, SHA256HashStore } from "matrix-protection-suite";
|
||||
import { RoomAuditLog } from "../protections/RoomTakedown/RoomAuditLog";
|
||||
import { SqliteRoomStateBackingStore } from "./better-sqlite3/SqliteRoomStateBackingStore";
|
||||
import { SqliteHashReversalStore } from "./better-sqlite3/HashStore";
|
||||
import { SqliteRoomAuditLog } from "../protections/RoomTakedown/SqliteRoomAuditLog";
|
||||
|
||||
export type TopLevelStores = {
|
||||
hashStore?: SHA256HashStore;
|
||||
roomAuditLog?: RoomAuditLog | undefined;
|
||||
roomStateBackingStore?: SqliteRoomStateBackingStore | undefined;
|
||||
};
|
||||
|
||||
/**
|
||||
* These stores will usually be created at the entrypoint of the draupnir
|
||||
* application or attenuated for each draupnir.
|
||||
*
|
||||
* No i don't like it. Some of these stores ARE specific to the draupnir
|
||||
* such as the hasStore's event emitter... that just can't be mixed.
|
||||
*
|
||||
* We could create a wrapper that disposes of only the stores that
|
||||
* have been attenuated... but i don't know about it.
|
||||
*/
|
||||
export type DraupnirStores = {
|
||||
hashStore?: SHA256HashStore | undefined;
|
||||
roomAuditLog?: RoomAuditLog | undefined;
|
||||
/**
|
||||
* Dispose of stores relevant to a specific draupnir instance.
|
||||
* For example, the hash store is usually specific to a single draupnir.
|
||||
*/
|
||||
dispose(): void;
|
||||
};
|
||||
|
||||
export function createDraupnirStores(
|
||||
topLevelStores: TopLevelStores
|
||||
): DraupnirStores {
|
||||
return Object.freeze({
|
||||
roomAuditLog: topLevelStores.roomAuditLog,
|
||||
hashStore: topLevelStores.hashStore,
|
||||
dispose() {},
|
||||
} satisfies DraupnirStores);
|
||||
}
|
||||
|
||||
export function makeTopLevelStores(
|
||||
storagePath: string,
|
||||
eventDecoder: EventDecoder,
|
||||
{
|
||||
isRoomStateBackingStoreEnabled,
|
||||
}: { isRoomStateBackingStoreEnabled: boolean }
|
||||
): TopLevelStores {
|
||||
return Object.freeze({
|
||||
roomStateBackingStore: isRoomStateBackingStoreEnabled
|
||||
? SqliteRoomStateBackingStore.create(storagePath, eventDecoder)
|
||||
: undefined,
|
||||
hashStore: SqliteHashReversalStore.createToplevel(storagePath),
|
||||
roomAuditLog: SqliteRoomAuditLog.createToplevel(storagePath),
|
||||
} satisfies TopLevelStores);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
// Copyright 2024 Gnuxie <Gnuxie@protonmail.com>
|
||||
// Copyright 2024 - 2025 Gnuxie <Gnuxie@protonmail.com>
|
||||
// Copyright 2022 The Matrix.org Foundation C.I.C.
|
||||
//
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
@@ -10,38 +10,53 @@
|
||||
|
||||
import BetterSqlite3, { Database } from "better-sqlite3";
|
||||
import { Logger } from "matrix-protection-suite";
|
||||
|
||||
const log = new Logger("BetterSqliteStore");
|
||||
|
||||
export function sqliteV0Schema(db: Database) {
|
||||
// we have to prepare and run them separately because `prepare` checks if the
|
||||
// table exists.
|
||||
const createTable = db.transaction(() => {
|
||||
db.prepare(
|
||||
`CREATE TABLE schema (
|
||||
version INTEGER UNIQUE NOT NULL
|
||||
) STRICT;`
|
||||
).run();
|
||||
db.prepare("INSERT INTO schema VALUES (0);").run();
|
||||
});
|
||||
createTable();
|
||||
}
|
||||
import { ensureSqliteSchema, SqliteSchemaOptions } from "./SqliteSchema";
|
||||
|
||||
export interface BetterSqliteOptions extends BetterSqlite3.Options {
|
||||
path: string;
|
||||
/**
|
||||
* Should the schema table be automatically created (the v0 schema effectively).
|
||||
* Defaults to `true`.
|
||||
*/
|
||||
autocreateSchemaTable?: boolean;
|
||||
WALMode?: boolean;
|
||||
foreignKeys?: boolean;
|
||||
}
|
||||
|
||||
export function makeBetterSqliteDB(
|
||||
options: BetterSqliteOptions,
|
||||
log: Logger
|
||||
): Database {
|
||||
log.info("Opening db: ", options.path);
|
||||
const db = new BetterSqlite3(options.path, options);
|
||||
if (options.path === ":memory:") {
|
||||
db.pragma("temp_store = memory");
|
||||
} else {
|
||||
db.pragma("temp_store = file"); // Avoid unnecessary memory usage.
|
||||
}
|
||||
if (options.WALMode) {
|
||||
db.pragma("journal_mode = WAL");
|
||||
}
|
||||
if (options.foreignKeys) {
|
||||
db.pragma("foreign_keys = ON");
|
||||
}
|
||||
process.once("beforeExit", () => {
|
||||
// Ensure we clean up on exit
|
||||
try {
|
||||
log.info("Destroy called on db", options.path);
|
||||
if (db.open) {
|
||||
// No-op if end has already been called.
|
||||
return;
|
||||
}
|
||||
db.close();
|
||||
log.info("connection ended on db", options.path);
|
||||
} catch (ex) {
|
||||
log.warn("Failed to cleanly exit", ex);
|
||||
}
|
||||
});
|
||||
return db;
|
||||
}
|
||||
|
||||
export type SchemaUpdateFunction = (db: Database) => void;
|
||||
|
||||
/**
|
||||
* BetterSqliteStore datastore abstraction which can be inherited by a specialised bridge class.
|
||||
* Please note, that the client library provides synchronous access to sqlite, due to the nature of
|
||||
* node.js FFI to C I imagine.
|
||||
* Please note, that the client library provides synchronous access to sqlite.
|
||||
*
|
||||
* @example
|
||||
* class MyBridgeStore extends BetterSqliteStore {
|
||||
@@ -59,14 +74,7 @@ export type SchemaUpdateFunction = (db: Database) => void;
|
||||
* store.ensureSchema();
|
||||
* const data = await store.getData();
|
||||
*/
|
||||
export abstract class BetterSqliteStore {
|
||||
private hasEnded = false;
|
||||
public readonly db: Database;
|
||||
|
||||
public get latestSchema() {
|
||||
return this.schemas.length;
|
||||
}
|
||||
|
||||
export class BetterSqliteStore {
|
||||
/**
|
||||
* Construct a new store.
|
||||
* @param schemas The set of schema functions to apply to a database. The ordering of this array determines the
|
||||
@@ -74,106 +82,24 @@ export abstract class BetterSqliteStore {
|
||||
* @param opts Options to supply to the BetterSqliteStore client, such as `path`.
|
||||
*/
|
||||
constructor(
|
||||
private readonly schemas: SchemaUpdateFunction[],
|
||||
private readonly opts: BetterSqliteOptions
|
||||
private readonly schema: SqliteSchemaOptions,
|
||||
protected readonly db: Database,
|
||||
private readonly log: Logger
|
||||
) {
|
||||
opts.autocreateSchemaTable = opts.autocreateSchemaTable ?? true;
|
||||
this.db = new BetterSqlite3(opts.path, opts);
|
||||
process.once("beforeExit", () => {
|
||||
// Ensure we clean up on exit
|
||||
try {
|
||||
this.destroy();
|
||||
} catch (ex) {
|
||||
log.warn("Failed to cleanly exit", ex);
|
||||
}
|
||||
});
|
||||
ensureSqliteSchema(this.db, this.log, this.schema);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure the database schema is up to date. If you supplied
|
||||
* `autocreateSchemaTable` to `opts` in the constructor, a fresh database
|
||||
* will have a `schema` table created for it.
|
||||
*
|
||||
* @throws If a schema could not be applied cleanly.
|
||||
*/
|
||||
public ensureSchema(): void {
|
||||
log.info("Starting database engine");
|
||||
let currentVersion = this.getSchemaVersion();
|
||||
|
||||
if (currentVersion === -1) {
|
||||
if (this.opts.autocreateSchemaTable) {
|
||||
log.info(`Applying v0 schema (schema table)`);
|
||||
sqliteV0Schema(this.db);
|
||||
currentVersion = 0;
|
||||
} else {
|
||||
// We aren't autocreating the schema table, so assume schema 0.
|
||||
currentVersion = 0;
|
||||
}
|
||||
}
|
||||
|
||||
// Zero-indexed, so schema 1 would be in slot 0.
|
||||
while (this.schemas[currentVersion]) {
|
||||
log.info(`Updating schema to v${currentVersion + 1}`);
|
||||
const runSchema = this.schemas[currentVersion];
|
||||
if (runSchema === undefined) {
|
||||
throw new TypeError(
|
||||
`there is no schema defined for the schema version: ${currentVersion}`
|
||||
);
|
||||
}
|
||||
try {
|
||||
runSchema(this.db);
|
||||
currentVersion++;
|
||||
this.updateSchemaVersion(currentVersion);
|
||||
} catch (ex) {
|
||||
log.warn(`Failed to run schema v${currentVersion + 1}:`, ex);
|
||||
throw Error("Failed to update database schema");
|
||||
}
|
||||
}
|
||||
log.info(`Database schema is at version v${currentVersion}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean away any resources used by the database. This is automatically
|
||||
* called before the process exits.
|
||||
* Clean away any resources used by the database
|
||||
*/
|
||||
public destroy(): void {
|
||||
log.info("Destroy called");
|
||||
if (this.hasEnded) {
|
||||
this.log.info("Destroy called");
|
||||
if (this.db.open) {
|
||||
// No-op if end has already been called.
|
||||
return;
|
||||
}
|
||||
this.hasEnded = true;
|
||||
this.db.close();
|
||||
log.info("connection ended");
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the current schema version.
|
||||
* @param version
|
||||
*/
|
||||
protected updateSchemaVersion(version: number): void {
|
||||
log.debug(`updateSchemaVersion: ${version}`);
|
||||
this.db.prepare(`UPDATE schema SET version = ?;`).run(version);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current schema version.
|
||||
* @returns The current schema version, or `-1` if no schema table is found.
|
||||
*/
|
||||
protected getSchemaVersion(): number {
|
||||
try {
|
||||
const result = this.db.prepare(`SELECT version FROM SCHEMA;`).get() as {
|
||||
version: number;
|
||||
};
|
||||
return result.version;
|
||||
} catch (ex) {
|
||||
if (ex instanceof Error && ex.message === "no such table: SCHEMA") {
|
||||
return -1;
|
||||
} else {
|
||||
log.error("Failed to get schema version", ex);
|
||||
}
|
||||
}
|
||||
throw Error("Couldn't fetch schema version");
|
||||
this.log.info("connection ended");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,389 @@
|
||||
// SPDX-FileCopyrightText: 2025 Gnuxie <Gnuxie@protonmail.com>
|
||||
//
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
//
|
||||
// SPDX-FileAttributionText: <text>
|
||||
// This modified file incorporates work from Draupnir
|
||||
// https://github.com/the-draupnir-project/Draupnir
|
||||
// </text>
|
||||
|
||||
import { isError, Ok, Result } from "@gnuxie/typescript-result";
|
||||
import {
|
||||
roomIDServerName,
|
||||
StringRoomID,
|
||||
StringServerName,
|
||||
StringUserID,
|
||||
userServerName,
|
||||
} from "@the-draupnir-project/matrix-basic-types";
|
||||
import {
|
||||
ActionException,
|
||||
ActionExceptionKind,
|
||||
HashedLiteralPolicyRule,
|
||||
LiteralPolicyRule,
|
||||
makeReversedHashedPolicy,
|
||||
RoomHashRecord,
|
||||
HashedRoomDetails,
|
||||
SHA256HashStore,
|
||||
Logger,
|
||||
UserHashRecord,
|
||||
PolicyRuleType,
|
||||
} from "matrix-protection-suite";
|
||||
import { BetterSqliteStore, makeBetterSqliteDB } from "./BetterSqliteStore";
|
||||
import { Database, Statement } from "better-sqlite3";
|
||||
import path from "path";
|
||||
import { checkKnownTables, SqliteSchemaOptions } from "./SqliteSchema";
|
||||
import EventEmitter from "events";
|
||||
import { createHash } from "crypto";
|
||||
|
||||
const log = new Logger("SqliteHashReversalStore");
|
||||
|
||||
// The reason why room is split into room_identification is because the creator
|
||||
// is usually not readily available when discovering rooms from the anti-spam apis.
|
||||
const SchemaText = [
|
||||
`
|
||||
CREATE TABLE room_sha256 (
|
||||
room_id TEXT PRIMARY KEY NOT NULL,
|
||||
sha256 TEXT NOT NULL
|
||||
) STRICT, WITHOUT ROWID;
|
||||
CREATE INDEX idx_room_sha256 ON room_sha256 (sha256, room_id);
|
||||
CREATE TABLE room_identification (
|
||||
room_id TEXT PRIMARY KEY NOT NULL,
|
||||
creator TEXT NOT NULL,
|
||||
server TEXT NOT NULL,
|
||||
FOREIGN KEY (room_id) REFERENCES room_sha256(room_id)
|
||||
) STRICT;
|
||||
CREATE TABLE user_sha256 (
|
||||
user_id TEXT PRIMARY KEY NOT NULL,
|
||||
server_name TEXT NOT NULL,
|
||||
sha256 TEXT NOT NULL
|
||||
) STRICT, WITHOUT ROWID;
|
||||
CREATE INDEX idx_user_sha256 ON user_sha256 (sha256, user_id);
|
||||
CREATE TABLE server_sha256 (
|
||||
server_name TEXT PRIMARY KEY NOT NULL,
|
||||
sha256 TEXT NOT NULL
|
||||
) STRICT, WITHOUT ROWID;
|
||||
CREATE INDEX idx_server_sha256 ON server_sha256 (sha256, server_name);
|
||||
`,
|
||||
];
|
||||
|
||||
const SchemaOptions = {
|
||||
upgradeSteps: SchemaText.map(
|
||||
(text) =>
|
||||
function (db) {
|
||||
db.exec(text);
|
||||
}
|
||||
),
|
||||
consistencyCheck(db) {
|
||||
return checkKnownTables(db, [
|
||||
"room_sha256",
|
||||
"room_identification",
|
||||
"user_sha256",
|
||||
"server_sha256",
|
||||
]);
|
||||
},
|
||||
} satisfies SqliteSchemaOptions;
|
||||
|
||||
function wrapInTryCatch<T>(cb: () => Result<T>, message: string): Result<T> {
|
||||
try {
|
||||
return cb();
|
||||
} catch (e) {
|
||||
if (e instanceof Error) {
|
||||
return ActionException.Result(message, {
|
||||
exception: e,
|
||||
exceptionKind: ActionExceptionKind.Unknown,
|
||||
});
|
||||
} else {
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class SqliteHashReversalStore
|
||||
extends EventEmitter
|
||||
implements SHA256HashStore
|
||||
{
|
||||
private readonly baseStore: BetterSqliteStore;
|
||||
constructor(private readonly db: Database) {
|
||||
super();
|
||||
this.baseStore = new BetterSqliteStore(SchemaOptions, db, log);
|
||||
}
|
||||
public async findUserHash(
|
||||
hash: string
|
||||
): Promise<Result<StringUserID | undefined>> {
|
||||
return wrapInTryCatch(
|
||||
() =>
|
||||
Ok(
|
||||
this.db
|
||||
.prepare(`SELECT user_id FROM user_sha256 WHERE sha256 = ?`)
|
||||
.pluck()
|
||||
.get(hash) as StringUserID | undefined
|
||||
),
|
||||
"error while querying hash for a user"
|
||||
);
|
||||
}
|
||||
public async findServerHash(
|
||||
hash: string
|
||||
): Promise<Result<string | undefined>> {
|
||||
return wrapInTryCatch(
|
||||
() =>
|
||||
Ok(
|
||||
this.db
|
||||
.prepare(`SELECT server_name FROM server_sha256 WHERE sha256 = ?`)
|
||||
.pluck()
|
||||
.get(hash) as string | undefined
|
||||
),
|
||||
"error while querying hash for a server name"
|
||||
);
|
||||
}
|
||||
|
||||
public static readonly StoreName = "hash-store.db";
|
||||
public static createToplevel(storagePath: string): SqliteHashReversalStore {
|
||||
const options = {
|
||||
path: path.join(storagePath, SqliteHashReversalStore.StoreName),
|
||||
WALMode: true,
|
||||
foreignKeys: true,
|
||||
fileMustExist: false,
|
||||
};
|
||||
return new SqliteHashReversalStore(makeBetterSqliteDB(options, log));
|
||||
}
|
||||
|
||||
public async findRoomHash(
|
||||
hash: string
|
||||
): Promise<Result<StringRoomID | undefined>> {
|
||||
try {
|
||||
return Ok(
|
||||
this.db
|
||||
.prepare(`SELECT room_id FROM room_sha256 WHERE sha256 = ?`)
|
||||
.pluck()
|
||||
.get(hash) as StringRoomID | undefined
|
||||
);
|
||||
} catch (e) {
|
||||
if (e instanceof Error) {
|
||||
return ActionException.Result(
|
||||
`Unexpected error while querying for a room hash`,
|
||||
{
|
||||
exception: e,
|
||||
exceptionKind: ActionExceptionKind.Unknown,
|
||||
}
|
||||
);
|
||||
} else {
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private reversePolicies(
|
||||
policies: HashedLiteralPolicyRule[],
|
||||
selectEntityFromSha256: Statement
|
||||
): LiteralPolicyRule[] {
|
||||
const reversedPolicies: LiteralPolicyRule[] = [];
|
||||
for (const policy of policies) {
|
||||
const sha256 = policy.hashes["sha256"];
|
||||
if (sha256 === undefined) {
|
||||
continue;
|
||||
}
|
||||
const entity = selectEntityFromSha256.get(sha256) as string | undefined;
|
||||
if (entity === undefined) {
|
||||
continue;
|
||||
}
|
||||
reversedPolicies.push(makeReversedHashedPolicy(entity, policy));
|
||||
}
|
||||
return reversedPolicies;
|
||||
}
|
||||
|
||||
private reverseRoomPolicies(
|
||||
policies: HashedLiteralPolicyRule[]
|
||||
): LiteralPolicyRule[] {
|
||||
return this.reversePolicies(
|
||||
policies,
|
||||
this.db
|
||||
.prepare(
|
||||
`
|
||||
SELECT room_id
|
||||
FROM room_sha256
|
||||
WHERE sha256 = ?`
|
||||
)
|
||||
.pluck()
|
||||
);
|
||||
}
|
||||
|
||||
private reverseUserPolicies(
|
||||
policies: HashedLiteralPolicyRule[]
|
||||
): LiteralPolicyRule[] {
|
||||
return this.reversePolicies(
|
||||
policies,
|
||||
this.db
|
||||
.prepare(
|
||||
`
|
||||
SELECT user_id
|
||||
FROM user_sha256
|
||||
WHERE sha256 = ?`
|
||||
)
|
||||
.pluck()
|
||||
);
|
||||
}
|
||||
|
||||
private reverseServerPolicies(
|
||||
policies: HashedLiteralPolicyRule[]
|
||||
): LiteralPolicyRule[] {
|
||||
return this.reversePolicies(
|
||||
policies,
|
||||
this.db
|
||||
.prepare(
|
||||
`
|
||||
SELECT server_name
|
||||
FROM server_sha256
|
||||
WHERE sha256 = ?`
|
||||
)
|
||||
.pluck()
|
||||
);
|
||||
}
|
||||
|
||||
public async reverseHashedPolicies(
|
||||
policies: HashedLiteralPolicyRule[]
|
||||
): Promise<Result<LiteralPolicyRule[]>> {
|
||||
const hashedRoomPolicies = policies.filter(
|
||||
(policy) => policy.kind === PolicyRuleType.Room
|
||||
);
|
||||
const hashedUserPolicies = policies.filter(
|
||||
(policy) => policy.kind === PolicyRuleType.User
|
||||
);
|
||||
const hashedServerPolicies = policies.filter(
|
||||
(policy) => policy.kind === PolicyRuleType.Server
|
||||
);
|
||||
return wrapInTryCatch(() => {
|
||||
return Ok([
|
||||
...this.reverseRoomPolicies(hashedRoomPolicies),
|
||||
...this.reverseUserPolicies(hashedUserPolicies),
|
||||
...this.reverseServerPolicies(hashedServerPolicies),
|
||||
]);
|
||||
}, "Error trying to reverse hashed policies");
|
||||
}
|
||||
|
||||
private storeUndiscoveredEntities<Record>(
|
||||
entities: string[],
|
||||
filterStatement: Statement,
|
||||
insertOrIgnore: Statement,
|
||||
recordFactoryFn: (entity: string, hash: string) => Record,
|
||||
eventEmitFn: (newRecords: Record[]) => void,
|
||||
serverExtractFn?: (record: Record) => StringServerName
|
||||
): Result<Record[]> {
|
||||
return wrapInTryCatch(() => {
|
||||
const undiscoveredEntities = entities.filter((entity) =>
|
||||
filterStatement.get(entity)
|
||||
);
|
||||
const rowsToInsert: Record[] = undiscoveredEntities.map((entity) =>
|
||||
recordFactoryFn(
|
||||
entity,
|
||||
createHash("sha256").update(entity, "utf8").digest("base64")
|
||||
)
|
||||
);
|
||||
insertOrIgnore.run(JSON.stringify(rowsToInsert));
|
||||
// try to discover servers too while we're here
|
||||
if (serverExtractFn) {
|
||||
const storeResult = this.storeUndiscoveredEntities(
|
||||
rowsToInsert.map(serverExtractFn),
|
||||
this.db
|
||||
.prepare(
|
||||
`SELECT NOT EXISTS (SELECT 1 FROM server_sha256 where server_name = ?)`
|
||||
)
|
||||
.pluck(),
|
||||
this.db.prepare(`
|
||||
INSERT OR IGNORE INTO server_sha256 (server_name, sha256)
|
||||
SELECT value ->> 'server_name', value ->> 'sha256'
|
||||
FROM json_each(?)`),
|
||||
(server_name: StringServerName, sha256) => ({ server_name, sha256 }),
|
||||
(serverRecords) => {
|
||||
this.emit("ReversedHashes", [], [], serverRecords);
|
||||
}
|
||||
);
|
||||
if (isError(storeResult)) {
|
||||
return storeResult.elaborate("Failed to update discovered servers");
|
||||
}
|
||||
}
|
||||
eventEmitFn(rowsToInsert);
|
||||
return Ok(rowsToInsert);
|
||||
}, "Failed to insert undiscovered entities into hash store");
|
||||
}
|
||||
|
||||
public async storeUndiscoveredRooms(
|
||||
roomIDs: StringRoomID[]
|
||||
): Promise<Result<RoomHashRecord[]>> {
|
||||
return this.storeUndiscoveredEntities(
|
||||
roomIDs,
|
||||
this.db
|
||||
.prepare(
|
||||
`SELECT NOT EXISTS (SELECT 1 FROM room_sha256 where room_id = ?)`
|
||||
)
|
||||
.pluck(),
|
||||
this.db.prepare(`
|
||||
INSERT OR IGNORE INTO room_sha256 (room_id, sha256)
|
||||
SELECT value ->> 'room_id', value ->> 'sha256'
|
||||
FROM json_each(?)`),
|
||||
(room_id: StringRoomID, sha256) => ({ room_id, sha256 }),
|
||||
(roomRecords) => {
|
||||
this.emit("ReversedHashes", roomRecords, [], []);
|
||||
},
|
||||
(roomRecord) => roomIDServerName(roomRecord.room_id)
|
||||
);
|
||||
}
|
||||
|
||||
public async storeUndiscoveredUsers(
|
||||
userIDs: StringUserID[]
|
||||
): Promise<Result<UserHashRecord[]>> {
|
||||
return this.storeUndiscoveredEntities(
|
||||
userIDs,
|
||||
this.db
|
||||
.prepare(
|
||||
`SELECT NOT EXISTS (SELECT 1 FROM user_sha256 WHERE user_id = ?)`
|
||||
)
|
||||
.pluck(),
|
||||
this.db.prepare(`
|
||||
INSERT OR IGNORE INTO user_sha256 (user_id, server_name, sha256)
|
||||
SELECT value ->> 'user_id', value ->> 'server_name', value ->> 'sha256'
|
||||
FROM json_each(?)`),
|
||||
(user_id: StringUserID, sha256) => ({
|
||||
user_id,
|
||||
server_name: userServerName(user_id),
|
||||
sha256,
|
||||
}),
|
||||
(userRecords) => {
|
||||
this.emit("ReversedHashes", [], userRecords, []);
|
||||
},
|
||||
(userRecord) => userRecord.server_name
|
||||
);
|
||||
}
|
||||
|
||||
public async storeRoomIdentification(
|
||||
roomDetails: HashedRoomDetails
|
||||
): Promise<Result<void>> {
|
||||
try {
|
||||
const statement = this.db.prepare(
|
||||
`REPLACE INTO room_identification (room_id, creator, server)
|
||||
VALUES (?, ?, ?)`
|
||||
);
|
||||
statement.run([
|
||||
roomDetails.roomID,
|
||||
roomDetails.creator,
|
||||
roomDetails.server,
|
||||
]);
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public destroy(): void {
|
||||
this.baseStore.destroy();
|
||||
}
|
||||
}
|
||||
@@ -15,28 +15,58 @@ import {
|
||||
StateEvent,
|
||||
isError,
|
||||
} from "matrix-protection-suite";
|
||||
import { BetterSqliteStore, flatTransaction } from "./BetterSqliteStore";
|
||||
import {
|
||||
BetterSqliteOptions,
|
||||
BetterSqliteStore,
|
||||
flatTransaction,
|
||||
makeBetterSqliteDB,
|
||||
} from "./BetterSqliteStore";
|
||||
import { jsonReviver } from "../../utils";
|
||||
import { StringRoomID } from "@the-draupnir-project/matrix-basic-types";
|
||||
import path from "path";
|
||||
import { Database } from "better-sqlite3";
|
||||
import { checkKnownTables, SqliteSchemaOptions } from "./SqliteSchema";
|
||||
|
||||
const log = new Logger("SqliteRoomStateBackingStore");
|
||||
|
||||
const schema = [
|
||||
`CREATE TABLE room_info (
|
||||
room_id TEXT PRIMARY KEY NOT NULL,
|
||||
last_complete_writeback INTEGER NOT NULL
|
||||
) STRICT;`,
|
||||
`CREATE TABLE room_state_event (
|
||||
room_id TEXT NOT NULL,
|
||||
event_type TEXT NOT NULL,
|
||||
state_key TEXT NOT NULL,
|
||||
event BLOB NOT NULL,
|
||||
PRIMARY KEY (room_id, event_type, state_key),
|
||||
FOREIGN KEY (room_id) REFERENCES room_info(room_id)
|
||||
) STRICT;`,
|
||||
const SchemaText = [
|
||||
`
|
||||
CREATE TABLE room_info (
|
||||
room_id TEXT PRIMARY KEY NOT NULL,
|
||||
last_complete_writeback INTEGER NOT NULL
|
||||
) STRICT, WITHOUT ROWID;
|
||||
CREATE TABLE room_state_event (
|
||||
room_id TEXT NOT NULL,
|
||||
event_type TEXT NOT NULL,
|
||||
state_key TEXT NOT NULL,
|
||||
event BLOB NOT NULL,
|
||||
PRIMARY KEY (room_id, event_type, state_key),
|
||||
FOREIGN KEY (room_id) REFERENCES room_info(room_id)
|
||||
) STRICT;
|
||||
`,
|
||||
];
|
||||
|
||||
const SchemaOptions = {
|
||||
upgradeSteps: SchemaText.map(
|
||||
(text) =>
|
||||
function (db) {
|
||||
db.exec(text);
|
||||
}
|
||||
),
|
||||
legacyUpgrade(db) {
|
||||
// An older version of this store used a different schema management system
|
||||
// fortunatley the data is just a psersistent cache so we don't need to worry about removing it.
|
||||
db.exec(`
|
||||
DELETE TABLE room_state_event;
|
||||
DELETE TABLE room_info;
|
||||
DELETE TABLE schema;
|
||||
`);
|
||||
},
|
||||
consistencyCheck(db) {
|
||||
return checkKnownTables(db, ["room_info", "room_state_event"]);
|
||||
},
|
||||
} satisfies SqliteSchemaOptions;
|
||||
|
||||
type RoomStateEventReplaceValue = [StringRoomID, string, string, string];
|
||||
|
||||
type RoomInfo = { last_complete_writeback: number; room_id: StringRoomID };
|
||||
@@ -48,33 +78,27 @@ export class SqliteRoomStateBackingStore
|
||||
private readonly roomInfoMap = new Map<StringRoomID, RoomInfo>();
|
||||
public readonly revisionListener = this.handleRevision.bind(this);
|
||||
public constructor(
|
||||
path: string,
|
||||
options: BetterSqliteOptions,
|
||||
db: Database,
|
||||
private readonly eventDecoder: EventDecoder
|
||||
) {
|
||||
super(
|
||||
schema.map(
|
||||
(text) =>
|
||||
function (db) {
|
||||
db.prepare(text).run();
|
||||
}
|
||||
),
|
||||
{
|
||||
path,
|
||||
fileMustExist: false,
|
||||
}
|
||||
);
|
||||
this.db.pragma("journal_mode = WAL");
|
||||
this.db.pragma("foreign_keys = ON");
|
||||
this.db.pragma("temp_store = file"); // Avoid unnecessary memory usage.
|
||||
this.ensureSchema();
|
||||
super(SchemaOptions, db, log);
|
||||
}
|
||||
|
||||
public static readonly StoreName = "room-state-backing-store.db";
|
||||
public static create(
|
||||
storagePath: string,
|
||||
eventDecoder: EventDecoder
|
||||
): SqliteRoomStateBackingStore {
|
||||
const options = {
|
||||
path: path.join(storagePath, SqliteRoomStateBackingStore.StoreName),
|
||||
WALMode: true,
|
||||
foreignKeys: true,
|
||||
fileMustExist: false,
|
||||
};
|
||||
return new SqliteRoomStateBackingStore(
|
||||
path.join(storagePath, "room-state-backing-store.db"),
|
||||
options,
|
||||
makeBetterSqliteDB(options, log),
|
||||
eventDecoder
|
||||
);
|
||||
}
|
||||
@@ -193,15 +217,15 @@ export class SqliteRoomStateBackingStore
|
||||
}
|
||||
|
||||
public forgetRoom(roomID: StringRoomID): Promise<ActionResult<void>> {
|
||||
const deleteMetaStatement = this.db.prepare(
|
||||
`DELETE FROM room_info WHERE room_id = ?`
|
||||
);
|
||||
const deleteStateStatement = this.db.prepare(
|
||||
`DELETE FROM room_state_event WHERE room_id = ?`
|
||||
);
|
||||
const deleteMetaStatement = this.db.prepare(
|
||||
`DELETE FROM room_info WHERE room_id = ?`
|
||||
);
|
||||
const deleteRoom = this.db.transaction(() => {
|
||||
deleteMetaStatement.run(roomID);
|
||||
deleteStateStatement.run(roomID);
|
||||
deleteMetaStatement.run(roomID); // needs to be last to avoid violating foriegn key cnostraint
|
||||
});
|
||||
try {
|
||||
deleteRoom();
|
||||
@@ -217,12 +241,13 @@ export class SqliteRoomStateBackingStore
|
||||
}
|
||||
|
||||
public async forgetAllRooms(): Promise<ActionResult<void>> {
|
||||
for (const roomID of this.roomInfoMap.keys()) {
|
||||
const result = await this.forgetRoom(roomID);
|
||||
if (isError(result)) {
|
||||
return result;
|
||||
}
|
||||
}
|
||||
this.db.transaction(() => {
|
||||
this.db.exec(`
|
||||
DELETE FROM room_state_event;
|
||||
DELETE FROM room_info;
|
||||
`);
|
||||
this.roomInfoMap.clear();
|
||||
})();
|
||||
return Ok(undefined);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
// Copyright 2025 Bea <20361868+enbea@users.noreply.github.com>
|
||||
// Copyright 2025 Gnuxie <Gnuxie@protonmail.com>
|
||||
//
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import { Database } from "better-sqlite3";
|
||||
import { Logger } from "matrix-protection-suite";
|
||||
|
||||
export type SqliteSchemaOptions = {
|
||||
upgradeSteps: ((db: Database) => void)[];
|
||||
legacyUpgrade?: (db: Database) => void;
|
||||
consistencyCheck?: (db: Database) => boolean;
|
||||
};
|
||||
|
||||
export type SqliteSchemaMigrationResult = {
|
||||
previousVersion: number;
|
||||
version: number;
|
||||
};
|
||||
|
||||
// NOTE: Must be run in a `db.transaction` to rollback on thrown errors
|
||||
// and prevent partial upgrades. (allows user to revert to a last
|
||||
// known working version of the software in case of errors)
|
||||
function ensureSchemaPragma(
|
||||
db: Database,
|
||||
log: Logger,
|
||||
{ upgradeSteps, consistencyCheck, legacyUpgrade }: SqliteSchemaOptions
|
||||
): SqliteSchemaMigrationResult {
|
||||
// remove this check to allow empty (v0) databases
|
||||
if (upgradeSteps.length === 0) {
|
||||
throw new TypeError("Missing version definitions.");
|
||||
}
|
||||
|
||||
// https://www.sqlite.org/pragma.html#pragma_user_version
|
||||
const previousVersion = db.pragma("user_version", { simple: true }) as number;
|
||||
|
||||
if (previousVersion > upgradeSteps.length) {
|
||||
throw new RangeError("Database is newer than defined `upgradeSteps`.");
|
||||
}
|
||||
|
||||
const tableCount = db
|
||||
.prepare('SELECT count(*) FROM "sqlite_master";')
|
||||
.pluck()
|
||||
.get() as number;
|
||||
if (previousVersion === 0 && tableCount > 0) {
|
||||
// Database isn't empty, try to migrate it
|
||||
log.info("Running legacy schema upgrade");
|
||||
if (legacyUpgrade) {
|
||||
legacyUpgrade(db);
|
||||
} else {
|
||||
log.error("Incompatible database"); // log so we can tell which database this is.
|
||||
throw new TypeError("Incompatible database");
|
||||
}
|
||||
}
|
||||
|
||||
// read the version again in case of legacy upgrade changing the number.
|
||||
const startAt = db.pragma("user_version", { simple: true }) as number;
|
||||
upgradeSteps.slice(startAt).forEach((step) => {
|
||||
step(db);
|
||||
});
|
||||
db.pragma(`user_version = ${upgradeSteps.length}`);
|
||||
if (consistencyCheck && !consistencyCheck(db)) {
|
||||
throw new Error("Database failed the consistency check.");
|
||||
}
|
||||
const version = db.pragma("user_version", { simple: true }) as number;
|
||||
if (previousVersion !== version) {
|
||||
log.info("Migrated database version from", previousVersion, "to", version);
|
||||
}
|
||||
return {
|
||||
previousVersion,
|
||||
version,
|
||||
};
|
||||
}
|
||||
|
||||
export function ensureSqliteSchema(
|
||||
db: Database,
|
||||
log: Logger,
|
||||
options: SqliteSchemaOptions
|
||||
): SqliteSchemaMigrationResult {
|
||||
const migrationVersion = db.transaction(ensureSchemaPragma)(db, log, options);
|
||||
db.exec("VACUUM;"); // why not
|
||||
return migrationVersion;
|
||||
}
|
||||
|
||||
export function checkKnownTables(
|
||||
db: Database,
|
||||
unsortedKnownTables: string[]
|
||||
): boolean {
|
||||
const knownTables = unsortedKnownTables.slice().sort(),
|
||||
currentTables = db
|
||||
.prepare(`SELECT name FROM "sqlite_master" WHERE type = 'table';`)
|
||||
.pluck()
|
||||
.all()
|
||||
.sort()
|
||||
.filter((table: string) => !/^sqlite_/.test(table));
|
||||
if (knownTables.length !== currentTables.length) {
|
||||
return false;
|
||||
}
|
||||
if (!knownTables.every((name, i) => currentTables[i] === name)) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
// SPDX-FileCopyrightText: 2025 Gnuxie <Gnuxie@protonmail.com>
|
||||
//
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import { Result } from "@gnuxie/typescript-result";
|
||||
import { Type } from "@sinclair/typebox";
|
||||
import { StringRoomID } from "@the-draupnir-project/matrix-basic-types";
|
||||
import {
|
||||
Capability,
|
||||
CapabilityMethodSchema,
|
||||
describeCapabilityInterface,
|
||||
RoomBasicDetails,
|
||||
} from "matrix-protection-suite";
|
||||
|
||||
export interface RoomDetailsProvider {
|
||||
getRoomDetails(roomID: StringRoomID): Promise<Result<RoomBasicDetails>>;
|
||||
}
|
||||
|
||||
export const RoomTakedownCapability = Type.Intersect([
|
||||
Type.Object({
|
||||
isRoomTakendown: CapabilityMethodSchema,
|
||||
takedownRoom: CapabilityMethodSchema,
|
||||
}),
|
||||
Capability,
|
||||
]);
|
||||
// we'll probably want to include room details too so we can provide some
|
||||
// rudinemtary information to takedown.
|
||||
// I'd make the fields optional however because it may be impossible
|
||||
// to get those details on conduwuit.
|
||||
export type RoomTakedownCapability = {
|
||||
isRoomTakendown(roomID: StringRoomID): Promise<Result<boolean>>;
|
||||
takedownRoom(roomID: StringRoomID): Promise<Result<RoomBasicDetails>>;
|
||||
} & RoomDetailsProvider &
|
||||
Capability;
|
||||
|
||||
describeCapabilityInterface({
|
||||
name: "RoomTakedownCapability",
|
||||
description: "Capability that targets matrix rooms",
|
||||
schema: RoomTakedownCapability,
|
||||
});
|
||||
@@ -0,0 +1,128 @@
|
||||
// SPDX-FileCopyrightText: 2025 Gnuxie <Gnuxie@protonmail.com>
|
||||
//
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import {
|
||||
describeCapabilityRenderer,
|
||||
DescriptionMeta,
|
||||
RoomBasicDetails,
|
||||
} from "matrix-protection-suite";
|
||||
import { RoomTakedownCapability } from "./RoomTakedownCapability";
|
||||
import { RendererMessageCollector } from "./RendererMessageCollector";
|
||||
import {
|
||||
MatrixRoomReference,
|
||||
StringRoomID,
|
||||
} from "@the-draupnir-project/matrix-basic-types";
|
||||
import { isError, Result } from "@gnuxie/typescript-result";
|
||||
import {
|
||||
DeadDocumentJSX,
|
||||
DocumentNode,
|
||||
} from "@the-draupnir-project/interface-manager";
|
||||
import { renderFailedSingularConsequence } from "./CommonRenderers";
|
||||
import { renderRoomPill } from "../commands/interface-manager/MatrixHelpRenderer";
|
||||
import { Draupnir } from "../Draupnir";
|
||||
|
||||
function renderCodeOrDefault(
|
||||
item: string | undefined,
|
||||
defaultText: string
|
||||
): DocumentNode {
|
||||
return item === undefined ? (
|
||||
<fragment>{defaultText}</fragment>
|
||||
) : (
|
||||
<code>{item}</code>
|
||||
);
|
||||
}
|
||||
|
||||
function renderTakedown(
|
||||
roomID: StringRoomID,
|
||||
details: RoomBasicDetails
|
||||
): DocumentNode {
|
||||
return (
|
||||
<details>
|
||||
<summary>
|
||||
Successfully takendown the room{" "}
|
||||
{renderRoomPill(MatrixRoomReference.fromRoomID(roomID))}
|
||||
</summary>
|
||||
<ul>
|
||||
<li>name: {renderCodeOrDefault(details.name, "no name available")}</li>
|
||||
<li>
|
||||
creator:{" "}
|
||||
{renderCodeOrDefault(
|
||||
details.creator,
|
||||
"creator information unavailable"
|
||||
)}
|
||||
</li>
|
||||
<li>
|
||||
topic: {renderCodeOrDefault(details.topic, "topic unavailable")}
|
||||
</li>
|
||||
</ul>
|
||||
</details>
|
||||
);
|
||||
}
|
||||
|
||||
class StandardRoomTakedownCapabilityRenderer implements RoomTakedownCapability {
|
||||
public readonly requiredEventPermissions =
|
||||
this.capability.requiredEventPermissions;
|
||||
public readonly requiredPermissions = this.capability.requiredPermissions;
|
||||
public readonly requiredStatePermissions =
|
||||
this.capability.requiredStatePermissions;
|
||||
constructor(
|
||||
private readonly description: DescriptionMeta,
|
||||
private readonly messageCollector: RendererMessageCollector,
|
||||
private readonly capability: RoomTakedownCapability
|
||||
) {
|
||||
// nothing to do.
|
||||
}
|
||||
|
||||
public async isRoomTakendown(roomID: StringRoomID): Promise<Result<boolean>> {
|
||||
// pass this one through without rendering because it doesn't really have a consequence
|
||||
return await this.capability.isRoomTakendown(roomID);
|
||||
}
|
||||
|
||||
public async takedownRoom(
|
||||
roomID: StringRoomID
|
||||
): Promise<Result<RoomBasicDetails>> {
|
||||
const capabilityResult = await this.capability.takedownRoom(roomID);
|
||||
if (isError(capabilityResult)) {
|
||||
this.messageCollector.addOneliner(
|
||||
this.description,
|
||||
this.capability,
|
||||
renderFailedSingularConsequence(
|
||||
this.description,
|
||||
<span>
|
||||
Failed to takedown room{" "}
|
||||
{renderRoomPill(MatrixRoomReference.fromRoomID(roomID))}
|
||||
</span>,
|
||||
capabilityResult.error
|
||||
)
|
||||
);
|
||||
return capabilityResult;
|
||||
}
|
||||
this.messageCollector.addMessage(
|
||||
this.description,
|
||||
this.capability,
|
||||
renderTakedown(roomID, capabilityResult.ok)
|
||||
);
|
||||
return capabilityResult;
|
||||
}
|
||||
|
||||
public async getRoomDetails(
|
||||
roomID: StringRoomID
|
||||
): Promise<Result<RoomBasicDetails>> {
|
||||
return await this.capability.getRoomDetails(roomID);
|
||||
}
|
||||
}
|
||||
|
||||
describeCapabilityRenderer<RoomTakedownCapability, Draupnir>({
|
||||
name: "StandardRoomTakedownCapabilityRenderer",
|
||||
description: "Renders the standard room takedown capability result",
|
||||
interface: "RoomTakedownCapability",
|
||||
isDefaultForInterface: true,
|
||||
factory(description, draupnir, capability) {
|
||||
return new StandardRoomTakedownCapabilityRenderer(
|
||||
description,
|
||||
draupnir.capabilityMessageRenderer,
|
||||
capability
|
||||
);
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,112 @@
|
||||
// SPDX-FileCopyrightText: 2025 Gnuxie <Gnuxie@protonmail.com>
|
||||
//
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import { StringRoomID } from "@the-draupnir-project/matrix-basic-types";
|
||||
import {
|
||||
describeCapabilityProvider,
|
||||
Logger,
|
||||
RoomBasicDetails,
|
||||
} from "matrix-protection-suite";
|
||||
import {
|
||||
RoomDetailsProvider,
|
||||
RoomTakedownCapability,
|
||||
} from "../RoomTakedownCapability";
|
||||
import { SynapseAdminClient } from "matrix-protection-suite-for-matrix-bot-sdk";
|
||||
import { isError, Ok, Result } from "@gnuxie/typescript-result";
|
||||
import { Draupnir } from "../../Draupnir";
|
||||
import "../RoomTakedownCapability"; // needed for the interface to load.
|
||||
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.
|
||||
}
|
||||
|
||||
isSimulated?: true;
|
||||
public async isRoomTakendown(roomID: StringRoomID): Promise<Result<boolean>> {
|
||||
const blockStatusResponse = await this.adminClient.getBlockStatus(roomID);
|
||||
if (isError(blockStatusResponse)) {
|
||||
return blockStatusResponse;
|
||||
} else {
|
||||
return Ok(blockStatusResponse.ok.block);
|
||||
}
|
||||
}
|
||||
|
||||
public async takedownRoom(
|
||||
roomID: StringRoomID
|
||||
): 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",
|
||||
detailsResponse.error
|
||||
);
|
||||
details = { room_id: roomID };
|
||||
} else {
|
||||
details = detailsResponse.ok;
|
||||
}
|
||||
log.debug("Taking down room", roomID);
|
||||
const takedownResult = await this.adminClient.deleteRoom(roomID, {
|
||||
block: true,
|
||||
purge: true,
|
||||
});
|
||||
if (isError(takedownResult)) {
|
||||
return takedownResult;
|
||||
} else {
|
||||
return Ok(details);
|
||||
}
|
||||
}
|
||||
|
||||
public async getRoomDetails(
|
||||
roomID: StringRoomID
|
||||
): Promise<Result<RoomBasicDetails>> {
|
||||
return await this.roomDetailsProvider.getRoomDetails(roomID);
|
||||
}
|
||||
}
|
||||
|
||||
describeCapabilityProvider<Draupnir>({
|
||||
name: SynapseAdminRoomTakedownCapability.name,
|
||||
interface: "RoomTakedownCapability",
|
||||
description: "Takesdown rooms using the synapse admin API",
|
||||
factory(description, draupnir) {
|
||||
if (draupnir.synapseAdminClient === undefined) {
|
||||
throw new TypeError(
|
||||
"Synapse admin client is not available on this draupnir instance"
|
||||
);
|
||||
}
|
||||
return new SynapseAdminRoomTakedownCapability(draupnir.synapseAdminClient);
|
||||
},
|
||||
});
|
||||
@@ -11,3 +11,6 @@
|
||||
import "./StandardEventConsequencesRenderer";
|
||||
import "./ServerACLConsequencesRenderer";
|
||||
import "./StandardUserConsequencesRenderer";
|
||||
import "./RoomTakedownCapability";
|
||||
import "./RoomTakedownCapabilityRenderer";
|
||||
import "./SynapseAdminRoomTakedown/SynapseAdminRoomTakedown";
|
||||
@@ -65,7 +65,7 @@ export const DraupnirBanCommand = describeCommand({
|
||||
{
|
||||
name: "entity",
|
||||
description:
|
||||
"The entity to ban. This can be a user ID, room ID, or server name.",
|
||||
"The entity to ban. This can be a user ID, or server name. For rooms please see the takedown command",
|
||||
acceptor: union(
|
||||
MatrixUserIDPresentationType,
|
||||
MatrixRoomReferencePresentationSchema,
|
||||
|
||||
@@ -55,6 +55,7 @@ import { DraupnirSafeModeCommand } from "./SafeModeCommand";
|
||||
import { DraupnirProtectionsShowCommand } from "./ProtectionsShowCommand";
|
||||
import { DraupnirProtectionsCapabilityCommand } from "./ProtectionsCapabilitiesCommand";
|
||||
import { JoinWaveCommandTable } from "../protections/JoinWaveShortCircuit";
|
||||
import { DraupnirTakedownCommand } from "./Takedown";
|
||||
|
||||
// TODO: These commands should all be moved to subdirectories tbh and this
|
||||
// should be split like an index file for each subdirectory.
|
||||
@@ -120,6 +121,7 @@ const DraupnirCommands = new StandardCommandTable("draupnir")
|
||||
.internCommand(DraupnirDisplaynameCommand, ["displayname"])
|
||||
.internCommand(DraupnirSetPowerLevelCommand, ["powerlevel"])
|
||||
.internCommand(DraupnirStatusCommand, ["status"])
|
||||
.internCommand(DraupnirTakedownCommand, ["takedown"])
|
||||
.internCommand(DraupnirUnbanCommand, ["unban"])
|
||||
.internCommand(DraupnirWatchPolicyRoomCommand, ["watch"])
|
||||
.internCommand(DraupnirUnwatchPolicyRoomCommand, ["unwatch"]);
|
||||
|
||||
+33
-2
@@ -9,10 +9,14 @@
|
||||
// </text>
|
||||
|
||||
import {
|
||||
GlobPolicyRule,
|
||||
HashedLiteralPolicyRule,
|
||||
LiteralPolicyRule,
|
||||
MemberPolicyMatches,
|
||||
Ok,
|
||||
PolicyRoomWatchProfile,
|
||||
PolicyRule,
|
||||
PolicyRuleMatchType,
|
||||
isError,
|
||||
} from "matrix-protection-suite";
|
||||
import {
|
||||
@@ -53,11 +57,35 @@ function renderListMatches(
|
||||
);
|
||||
}
|
||||
|
||||
export function renderRuleHashes(rule: HashedLiteralPolicyRule): DocumentNode {
|
||||
return (
|
||||
<ul>
|
||||
{Object.entries(rule.hashes).map(([algorithm, hash]) => (
|
||||
<li>
|
||||
<code>{algorithm}</code>: <code>{hash}</code>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
export function renderRuleClearText(
|
||||
rule: LiteralPolicyRule | GlobPolicyRule
|
||||
): DocumentNode {
|
||||
return (
|
||||
<fragment>
|
||||
<code>{rule.entity}</code> ({rule.reason ?? "<no reason supplied>"})
|
||||
</fragment>
|
||||
);
|
||||
}
|
||||
|
||||
function renderRuleSummary(rule: PolicyRule) {
|
||||
return (
|
||||
<li>
|
||||
{rule.kind} (<code>{rule.recommendation}</code>):{" "}
|
||||
<code>{rule.entity}</code> ({rule.reason})
|
||||
{rule.matchType === PolicyRuleMatchType.HashedLiteral
|
||||
? renderRuleHashes(rule)
|
||||
: renderRuleClearText(rule)}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
@@ -130,7 +158,10 @@ export const DraupnirRulesMatchingCommand = describeCommand({
|
||||
return {
|
||||
room: profile.revision.room,
|
||||
roomID: profile.revision.room.toRoomIDOrAlias(),
|
||||
matches: profile.revision.allRulesMatchingEntity(entity.toString()),
|
||||
matches: profile.revision.allRulesMatchingEntity(
|
||||
entity.toString(),
|
||||
{}
|
||||
),
|
||||
profile: profile,
|
||||
};
|
||||
})
|
||||
|
||||
@@ -0,0 +1,298 @@
|
||||
// SPDX-FileCopyrightText: 2025 Gnuxie <Gnuxie@protonmail.com>
|
||||
//
|
||||
// SPDX-License-Identifier: AFL-3.0
|
||||
|
||||
import {
|
||||
BasicInvocationInformation,
|
||||
DeadDocumentJSX,
|
||||
describeCommand,
|
||||
DocumentNode,
|
||||
MatrixRoomIDPresentationType,
|
||||
MatrixRoomReferencePresentationSchema,
|
||||
MatrixUserIDPresentationType,
|
||||
StringPresentationType,
|
||||
tuple,
|
||||
union,
|
||||
} from "@the-draupnir-project/interface-manager";
|
||||
import { findPolicyRoomEditorFromRoomReference } from "./Ban";
|
||||
import {
|
||||
isError,
|
||||
Logger,
|
||||
Ok,
|
||||
PolicyRoomManager,
|
||||
PolicyRuleType,
|
||||
RoomResolver,
|
||||
SHA256HashStore,
|
||||
Task,
|
||||
WatchedPolicyRooms,
|
||||
} from "matrix-protection-suite";
|
||||
import { Result, ResultError } from "@gnuxie/typescript-result";
|
||||
import {
|
||||
MatrixRoomID,
|
||||
MatrixUserID,
|
||||
StringEventID,
|
||||
StringRoomID,
|
||||
StringUserID,
|
||||
userServerName,
|
||||
} from "@the-draupnir-project/matrix-basic-types";
|
||||
import {
|
||||
DraupnirContextToCommandContextTranslator,
|
||||
DraupnirInterfaceAdaptor,
|
||||
} from "./DraupnirCommandPrerequisites";
|
||||
import { renderRoomPill } from "./interface-manager/MatrixHelpRenderer";
|
||||
import { RoomDetailsProvider } from "../capabilities/RoomTakedownCapability";
|
||||
import { SynapseAdminRoomDetailsProvider } from "../capabilities/SynapseAdminRoomTakedown/SynapseAdminRoomTakedown";
|
||||
|
||||
const log = new Logger("DraupnirTakedownCommand");
|
||||
|
||||
/**
|
||||
* Make sure that the hash store is up to date with the entity
|
||||
*/
|
||||
function handleRoomDiscovery(
|
||||
roomID: StringRoomID,
|
||||
store: SHA256HashStore | undefined,
|
||||
detailsProvider: RoomDetailsProvider | undefined
|
||||
): void {
|
||||
if (store === undefined) {
|
||||
log.warn(
|
||||
"Unable to discover a room provided by the takedown command because the hash store has not been configured"
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (detailsProvider === undefined) {
|
||||
log.warn(
|
||||
"Unable to discover a room provided by the takedown command because the details provider has not been configured"
|
||||
);
|
||||
return;
|
||||
}
|
||||
void Task(
|
||||
(async () => {
|
||||
const storeResult = await store.storeUndiscoveredRooms([roomID]);
|
||||
if (isError(storeResult)) {
|
||||
return storeResult.elaborate(
|
||||
"Unable to store the room from takedown command into the hash store"
|
||||
);
|
||||
}
|
||||
const detailsResult = await detailsProvider.getRoomDetails(roomID);
|
||||
if (isError(detailsResult)) {
|
||||
return detailsResult.elaborate(
|
||||
"Failed to fetch details for a room discovered via the takedown command"
|
||||
);
|
||||
}
|
||||
if (detailsResult.ok.creator === undefined) {
|
||||
log.warn(
|
||||
"No creator was provided in the details for the room, so we cannot store them",
|
||||
roomID,
|
||||
detailsResult.ok
|
||||
);
|
||||
return Ok(undefined);
|
||||
}
|
||||
return await store.storeRoomIdentification({
|
||||
roomID,
|
||||
creator: detailsResult.ok.creator,
|
||||
server: userServerName(detailsResult.ok.creator),
|
||||
});
|
||||
})()
|
||||
);
|
||||
}
|
||||
|
||||
export type TakedownPolicyPreview = {
|
||||
ruleType: PolicyRuleType;
|
||||
entity: string;
|
||||
policyRoom: MatrixRoomID;
|
||||
};
|
||||
|
||||
export const DraupnirTakedownCommand = describeCommand({
|
||||
summary:
|
||||
"Mark an entity for takedown. This command is used to mark illegal or intollerable content as takedown. This is the strongest of consequences and is usualy irreversable.\
|
||||
This can be used to block rooms on your homeserver and discard spam invitations.",
|
||||
parameters: tuple(
|
||||
{
|
||||
name: "entity",
|
||||
description:
|
||||
"The entity to ban. This can be a user ID, room ID, or server name.",
|
||||
acceptor: union(
|
||||
MatrixUserIDPresentationType,
|
||||
MatrixRoomReferencePresentationSchema,
|
||||
StringPresentationType
|
||||
),
|
||||
},
|
||||
{
|
||||
name: "list",
|
||||
acceptor: union(
|
||||
MatrixRoomReferencePresentationSchema,
|
||||
StringPresentationType
|
||||
),
|
||||
prompt: async function ({
|
||||
policyRoomManager,
|
||||
clientUserID,
|
||||
}: DraupnirTakedownCommandContext) {
|
||||
return Ok({
|
||||
suggestions: policyRoomManager
|
||||
.getEditablePolicyRoomIDs(clientUserID, PolicyRuleType.User)
|
||||
.map((room) => MatrixRoomIDPresentationType.wrap(room)),
|
||||
});
|
||||
},
|
||||
}
|
||||
),
|
||||
keywords: {
|
||||
keywordDescriptions: {
|
||||
"no-confirm": {
|
||||
isFlag: true,
|
||||
description:
|
||||
"Runs the command without the preview of the unban and the confirmation prompt.",
|
||||
},
|
||||
"plain-text": {
|
||||
isFlag: true,
|
||||
description:
|
||||
"Creates a plain-text version of the policy rather than masking the entity with SHA256. There are not many reason to do this other than compatibility with other tools.",
|
||||
},
|
||||
},
|
||||
},
|
||||
async executor(
|
||||
{
|
||||
watchedPolicyRooms,
|
||||
policyRoomManager,
|
||||
roomResolver,
|
||||
hashStore,
|
||||
detailsProvider,
|
||||
}: DraupnirTakedownCommandContext,
|
||||
_info: BasicInvocationInformation,
|
||||
keywords,
|
||||
_rest,
|
||||
entity,
|
||||
policyRoomDesignator
|
||||
): Promise<Result<TakedownPolicyPreview | StringEventID>> {
|
||||
const policyRoomReference =
|
||||
typeof policyRoomDesignator === "string"
|
||||
? Ok(
|
||||
watchedPolicyRooms.findPolicyRoomFromShortcode(policyRoomDesignator)
|
||||
?.room
|
||||
)
|
||||
: Ok(policyRoomDesignator);
|
||||
if (isError(policyRoomReference)) {
|
||||
return policyRoomReference;
|
||||
}
|
||||
if (policyRoomReference.ok === undefined) {
|
||||
return ResultError.Result(
|
||||
`Unable to find a policy room from the shortcode ${policyRoomDesignator.toString()}`
|
||||
);
|
||||
}
|
||||
const policyRoomEditorResult = await findPolicyRoomEditorFromRoomReference(
|
||||
roomResolver,
|
||||
policyRoomManager,
|
||||
policyRoomReference.ok
|
||||
);
|
||||
if (isError(policyRoomEditorResult)) {
|
||||
return policyRoomEditorResult;
|
||||
}
|
||||
const policyRoomEditor = policyRoomEditorResult.ok;
|
||||
const preview = await (async () => {
|
||||
if (entity instanceof MatrixUserID) {
|
||||
return Ok({
|
||||
ruleType: PolicyRuleType.User,
|
||||
entity: entity.toString(),
|
||||
policyRoom: policyRoomEditor.room,
|
||||
});
|
||||
} else if (typeof entity === "string") {
|
||||
return Ok({
|
||||
ruleType: PolicyRuleType.Server,
|
||||
entity,
|
||||
policyRoom: policyRoomEditor.room,
|
||||
});
|
||||
} else {
|
||||
const resolvedRoomReference = await roomResolver.resolveRoom(entity);
|
||||
if (isError(resolvedRoomReference)) {
|
||||
return resolvedRoomReference;
|
||||
}
|
||||
return Ok({
|
||||
ruleType: PolicyRuleType.Room,
|
||||
entity: resolvedRoomReference.ok.toRoomIDOrAlias(),
|
||||
policyRoom: policyRoomEditor.room,
|
||||
});
|
||||
}
|
||||
})();
|
||||
if (isError(preview)) {
|
||||
return preview;
|
||||
}
|
||||
if (!keywords.getKeywordValue<boolean>("no-confirm", false)) {
|
||||
return preview;
|
||||
}
|
||||
if (preview.ok.ruleType === PolicyRuleType.Room) {
|
||||
handleRoomDiscovery(
|
||||
preview.ok.entity as StringRoomID,
|
||||
hashStore,
|
||||
detailsProvider
|
||||
);
|
||||
}
|
||||
const plainText = keywords.getKeywordValue<boolean>("plain-text", false);
|
||||
return await policyRoomEditor.takedownEntity(
|
||||
preview.ok.ruleType,
|
||||
preview.ok.entity,
|
||||
{ shouldHash: !plainText }
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
function renderTakedownPreview(preview: TakedownPolicyPreview): DocumentNode {
|
||||
return (
|
||||
<fragment>
|
||||
You are about to mark an entity to be takendown by any means necessary:
|
||||
<ul>
|
||||
<li>
|
||||
entity: <code>{preview.entity}</code>
|
||||
</li>
|
||||
<li>
|
||||
policy type: <code>{preview.ruleType}</code>
|
||||
</li>
|
||||
<li>policy room: {renderRoomPill(preview.policyRoom)}</li>
|
||||
</ul>
|
||||
Please consider that doing so may have irreversable effects.
|
||||
<b>
|
||||
You MUST only use takedown policies to mark spam, illegal, or otherwise
|
||||
intolerable content. DO NOT takedown users who have committed a code of
|
||||
conduct violation.
|
||||
</b>
|
||||
</fragment>
|
||||
);
|
||||
}
|
||||
|
||||
DraupnirInterfaceAdaptor.describeRenderer(DraupnirTakedownCommand, {
|
||||
isAlwaysSupposedToUseDefaultRenderer: true,
|
||||
confirmationPromptJSXRenderer(commandResult) {
|
||||
if (isError(commandResult)) {
|
||||
return Ok(undefined);
|
||||
} else if (typeof commandResult.ok === "string") {
|
||||
return Ok(undefined); // it's an event id that's got nothing going for it.
|
||||
} else {
|
||||
return Ok(<root>{renderTakedownPreview(commandResult.ok)}</root>);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
export type DraupnirTakedownCommandContext = {
|
||||
policyRoomManager: PolicyRoomManager;
|
||||
watchedPolicyRooms: WatchedPolicyRooms;
|
||||
defaultReasons: string[];
|
||||
roomResolver: RoomResolver;
|
||||
clientUserID: StringUserID;
|
||||
hashStore: SHA256HashStore | undefined;
|
||||
detailsProvider: RoomDetailsProvider | undefined;
|
||||
};
|
||||
|
||||
DraupnirContextToCommandContextTranslator.registerTranslation(
|
||||
DraupnirTakedownCommand,
|
||||
function (draupnir) {
|
||||
return {
|
||||
policyRoomManager: draupnir.policyRoomManager,
|
||||
watchedPolicyRooms: draupnir.protectedRoomsSet.watchedPolicyRooms,
|
||||
defaultReasons: draupnir.config.commands.ban.defaultReasons,
|
||||
roomResolver: draupnir.clientPlatform.toRoomResolver(),
|
||||
clientUserID: draupnir.clientUserID,
|
||||
hashStore: draupnir.stores.hashStore,
|
||||
detailsProvider: draupnir.synapseAdminClient
|
||||
? new SynapseAdminRoomDetailsProvider(draupnir.synapseAdminClient)
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
);
|
||||
@@ -36,10 +36,13 @@ export function findPoliciesToRemove(
|
||||
room: profile.room,
|
||||
roomID: profile.room.toRoomIDOrAlias(),
|
||||
profile,
|
||||
matches: profile.revision.allRulesMatchingEntity(
|
||||
entity.toString(),
|
||||
entityType,
|
||||
Recommendation.Ban
|
||||
matches: [Recommendation.Ban, Recommendation.Takedown].flatMap(
|
||||
(recommendation) =>
|
||||
profile.revision.allRulesMatchingEntity(entity.toString(), {
|
||||
type: entityType,
|
||||
searchHashedRules: true,
|
||||
recommendation,
|
||||
})
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -27,6 +27,8 @@ import {
|
||||
isError,
|
||||
Ok,
|
||||
PolicyRuleType,
|
||||
PolicyRuleMatchType,
|
||||
PolicyListRevision,
|
||||
} from "matrix-protection-suite";
|
||||
import { UnlistedUserRedactionQueue } from "../../queues/UnlistedUserRedactionQueue";
|
||||
import { ListMatches } from "../Rules";
|
||||
@@ -90,6 +92,42 @@ export function matchMembers(
|
||||
return [...map.values()];
|
||||
}
|
||||
|
||||
export function revisionRulesMatchingEntity(
|
||||
entity: string,
|
||||
ruleType: PolicyRuleType,
|
||||
recommendations: Recommendation[],
|
||||
revision: PolicyListRevision
|
||||
): PolicyRule[] {
|
||||
return recommendations.flatMap((recommendation) =>
|
||||
revision.allRulesMatchingEntity(entity, {
|
||||
type: ruleType,
|
||||
recommendation,
|
||||
searchHashedRules: true,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
export function revisionRulesMatchingUser(
|
||||
userID: StringUserID,
|
||||
recommendations: Recommendation[],
|
||||
revision: PolicyListRevision
|
||||
): PolicyRule[] {
|
||||
return [
|
||||
...revisionRulesMatchingEntity(
|
||||
userID,
|
||||
PolicyRuleType.User,
|
||||
recommendations,
|
||||
revision
|
||||
),
|
||||
...revisionRulesMatchingEntity(
|
||||
userServerName(userID),
|
||||
PolicyRuleType.Server,
|
||||
recommendations,
|
||||
revision
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
export function findBanPoliciesMatchingUsers(
|
||||
watchedPolicyRooms: WatchedPolicyRooms,
|
||||
users: StringUserID[]
|
||||
@@ -104,18 +142,11 @@ export function findBanPoliciesMatchingUsers(
|
||||
}
|
||||
};
|
||||
for (const user of users) {
|
||||
const memberPolicies = [
|
||||
...watchedPolicyRooms.currentRevision.allRulesMatchingEntity(
|
||||
user,
|
||||
PolicyRuleType.User,
|
||||
Recommendation.Ban
|
||||
),
|
||||
...watchedPolicyRooms.currentRevision.allRulesMatchingEntity(
|
||||
userServerName(user),
|
||||
PolicyRuleType.Server,
|
||||
Recommendation.Ban
|
||||
),
|
||||
];
|
||||
const memberPolicies = revisionRulesMatchingUser(
|
||||
user,
|
||||
[Recommendation.Ban, Recommendation.Takedown],
|
||||
watchedPolicyRooms.currentRevision
|
||||
);
|
||||
for (const policy of memberPolicies) {
|
||||
addPolicy(policy);
|
||||
}
|
||||
@@ -161,11 +192,10 @@ export async function unbanMembers(
|
||||
for (const policy of policyRoom.matches) {
|
||||
policiesRemoved.addResult(
|
||||
policyRoom.roomID,
|
||||
(await policyRoomEditor.ok.removePolicy(
|
||||
await policyRoomEditor.ok.removePolicyByStateKey(
|
||||
policy.kind,
|
||||
policy.recommendation,
|
||||
policy.entity
|
||||
)) as Result<void>
|
||||
policy.sourceEvent.state_key
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -240,11 +270,16 @@ export function findUnbanInformationForMember(
|
||||
];
|
||||
for (const { matches } of policyMatchesToRemove) {
|
||||
for (const match of matches) {
|
||||
if (match.isGlob()) {
|
||||
if (match.matchType === PolicyRuleMatchType.Glob) {
|
||||
globsToScan.push(new MatrixGlob(match.entity));
|
||||
} else if (isStringUserID(match.entity)) {
|
||||
} else if (
|
||||
match.matchType === PolicyRuleMatchType.Literal &&
|
||||
isStringUserID(match.entity)
|
||||
) {
|
||||
literalsToScan.push(match.entity);
|
||||
}
|
||||
// HashedLiterals that match will be removed indriectly when their sourceEvent's
|
||||
// get removed.
|
||||
}
|
||||
}
|
||||
const membersMatchingPoliciesAndEntity = matchMembers(
|
||||
@@ -253,18 +288,11 @@ export function findUnbanInformationForMember(
|
||||
{ inviteMembers }
|
||||
);
|
||||
const isMemberStillBannedAfterPolicyRemoval = (member: MemberRooms) => {
|
||||
const policiesMatchingMember = [
|
||||
...watchedPolicyRooms.currentRevision.allRulesMatchingEntity(
|
||||
member.member,
|
||||
PolicyRuleType.User,
|
||||
Recommendation.Ban
|
||||
),
|
||||
...watchedPolicyRooms.currentRevision.allRulesMatchingEntity(
|
||||
userServerName(member.member),
|
||||
PolicyRuleType.Server,
|
||||
Recommendation.Ban
|
||||
),
|
||||
];
|
||||
const policiesMatchingMember = revisionRulesMatchingUser(
|
||||
member.member,
|
||||
[Recommendation.Takedown, Recommendation.Ban],
|
||||
watchedPolicyRooms.currentRevision
|
||||
);
|
||||
return (
|
||||
policiesMatchingMember.filter(
|
||||
(policy) =>
|
||||
|
||||
@@ -29,7 +29,11 @@ import { SafeModeDraupnir } from "../safemode/DraupnirSafeMode";
|
||||
import { SafeModeCause } from "../safemode/SafeModeCause";
|
||||
import { SafeModeToggle } from "../safemode/SafeModeToggle";
|
||||
import { StandardManagementRoomDetail } from "../managementroom/ManagementRoomDetail";
|
||||
import { DraupnirBotModeToggle } from "../DraupnirBotMode";
|
||||
import {
|
||||
createDraupnirStores,
|
||||
TopLevelStores,
|
||||
} from "../backingstore/DraupnirStores";
|
||||
import { SynapseHttpAntispam } from "../webapis/SynapseHTTPAntispam/SynapseHttpAntispam";
|
||||
|
||||
const log = new Logger("DraupnirFactory");
|
||||
|
||||
@@ -59,7 +63,8 @@ export class DraupnirFactory {
|
||||
private readonly clientsInRoomMap: ClientsInRoomMap,
|
||||
private readonly clientCapabilityFactory: ClientCapabilityFactory,
|
||||
private readonly clientProvider: ClientForUserID,
|
||||
private readonly roomStateManagerFactory: RoomStateManagerFactory
|
||||
private readonly roomStateManagerFactory: RoomStateManagerFactory,
|
||||
private readonly stores: TopLevelStores
|
||||
) {
|
||||
// nothing to do.
|
||||
}
|
||||
@@ -128,6 +133,11 @@ export class DraupnirFactory {
|
||||
managementRoomMembership.ok,
|
||||
managementRoomState.ok
|
||||
);
|
||||
// synapesHTTPAntispam is made here so that the listeners get destroyed
|
||||
// when draupnir is destroyed.
|
||||
const synapseHTTPAntispam = config.web.synapseHTTPAntispam.enabled
|
||||
? new SynapseHttpAntispam(config.web.synapseHTTPAntispam.authorization)
|
||||
: undefined;
|
||||
const clientProfile = await safelyFetchProfile(client, clientUserID);
|
||||
return await Draupnir.makeDraupnirBot(
|
||||
client,
|
||||
@@ -143,10 +153,9 @@ export class DraupnirFactory {
|
||||
config,
|
||||
configLogTracker,
|
||||
toggle,
|
||||
createDraupnirStores(this.stores),
|
||||
// synapseHTTPAntispam is only available in bot mode.
|
||||
toggle instanceof DraupnirBotModeToggle
|
||||
? toggle.synapseHTTPAntispam
|
||||
: undefined
|
||||
synapseHTTPAntispam
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
+6
-5
@@ -24,7 +24,7 @@ import { initializeSentry, patchMatrixClient } from "./utils";
|
||||
import { DraupnirBotModeToggle } from "./DraupnirBotMode";
|
||||
import { SafeMatrixEmitterWrapper } from "matrix-protection-suite-for-matrix-bot-sdk";
|
||||
import { DefaultEventDecoder } from "matrix-protection-suite";
|
||||
import { SqliteRoomStateBackingStore } from "./backingstore/better-sqlite3/SqliteRoomStateBackingStore";
|
||||
import { makeTopLevelStores } from "./backingstore/DraupnirStores";
|
||||
|
||||
void (async function () {
|
||||
const config = configRead();
|
||||
@@ -83,14 +83,15 @@ void (async function () {
|
||||
}
|
||||
patchMatrixClient();
|
||||
const eventDecoder = DefaultEventDecoder;
|
||||
const store = config.roomStateBackingStore.enabled
|
||||
? SqliteRoomStateBackingStore.create(storagePath, eventDecoder)
|
||||
: undefined;
|
||||
const stores = makeTopLevelStores(storagePath, eventDecoder, {
|
||||
isRoomStateBackingStoreEnabled:
|
||||
config.roomStateBackingStore.enabled ?? false,
|
||||
});
|
||||
bot = await DraupnirBotModeToggle.create(
|
||||
client,
|
||||
new SafeMatrixEmitterWrapper(client, eventDecoder),
|
||||
config,
|
||||
store
|
||||
stores
|
||||
);
|
||||
|
||||
// We don't want to send the status on start, as we need to initialize e2ee first (using client.start);
|
||||
|
||||
@@ -50,7 +50,10 @@ import {
|
||||
UnbanMembersPreview,
|
||||
renderUnbanMembersPreview,
|
||||
} from "../commands/unban/Unban";
|
||||
import { findUnbanInformationForMember } from "../commands/unban/UnbanUsers";
|
||||
import {
|
||||
findUnbanInformationForMember,
|
||||
revisionRulesMatchingUser,
|
||||
} from "../commands/unban/UnbanUsers";
|
||||
|
||||
const log = new Logger("BanPropagationProtection");
|
||||
|
||||
@@ -254,10 +257,10 @@ export class BanPropagationProtection
|
||||
private handleBan(change: MembershipChange): void {
|
||||
const policyRevision =
|
||||
this.protectedRoomsSet.watchedPolicyRooms.currentRevision;
|
||||
const rulesMatchingUser = policyRevision.allRulesMatchingEntity(
|
||||
const rulesMatchingUser = revisionRulesMatchingUser(
|
||||
change.userID,
|
||||
PolicyRuleType.User,
|
||||
Recommendation.Ban
|
||||
[Recommendation.Ban, Recommendation.Takedown],
|
||||
policyRevision
|
||||
);
|
||||
if (rulesMatchingUser.length > 0) {
|
||||
return; // user is already banned.
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
*/
|
||||
|
||||
// keep alphabetical please.
|
||||
import "./RoomTakedown/RoomTakedownProtection";
|
||||
import "./BanPropagation";
|
||||
import "./BasicFlooding";
|
||||
import "./FirstMessageIsImage";
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
PolicyRoomManager,
|
||||
PolicyRoomRevisionIssuer,
|
||||
PolicyRuleChange,
|
||||
PolicyRuleMatchType,
|
||||
ProtectedRoomsSet,
|
||||
ProtectionDescription,
|
||||
UnknownConfig,
|
||||
@@ -38,6 +39,7 @@ import {
|
||||
DocumentNode,
|
||||
} from "@the-draupnir-project/interface-manager";
|
||||
import { sendMatrixEventsFromDeadDocument } from "../commands/interface-manager/MPSMatrixInterfaceAdaptor";
|
||||
import { renderRuleHashes, renderRuleClearText } from "../commands/Rules";
|
||||
|
||||
const log = new Logger("PolicyChangeNotification");
|
||||
|
||||
@@ -134,7 +136,9 @@ function renderListChange(change: PolicyRuleChange): DocumentNode {
|
||||
{renderMentionPill(change.sender, change.sender)}{" "}
|
||||
<code>{change.changeType}</code>  
|
||||
{change.rule.kind} (<code>{change.rule.recommendation}</code>)  
|
||||
<code>{change.rule.entity}</code> ({change.rule.reason})
|
||||
{change.rule.matchType === PolicyRuleMatchType.HashedLiteral
|
||||
? renderRuleHashes(change.rule)
|
||||
: renderRuleClearText(change.rule)}
|
||||
</li>
|
||||
</fragment>
|
||||
);
|
||||
|
||||
@@ -17,6 +17,8 @@ import {
|
||||
PolicyListRevision,
|
||||
PolicyRule,
|
||||
PolicyRuleChange,
|
||||
PolicyRuleChangeType,
|
||||
PolicyRuleMatchType,
|
||||
PolicyRuleType,
|
||||
PowerLevelPermission,
|
||||
ProtectedRoomsSet,
|
||||
@@ -24,7 +26,6 @@ import {
|
||||
ProtectionDescription,
|
||||
Recommendation,
|
||||
RoomMembershipRevision,
|
||||
SimpleChangeType,
|
||||
Task,
|
||||
describeProtection,
|
||||
} from "matrix-protection-suite";
|
||||
@@ -59,7 +60,10 @@ export class RedactionSynchronisationProtection
|
||||
}
|
||||
public redactForNewUserPolicy(policy: PolicyRule): void {
|
||||
const rooms: StringRoomID[] = [];
|
||||
if (policy.isGlob()) {
|
||||
if (policy.matchType === PolicyRuleMatchType.HashedLiteral) {
|
||||
return; // wait for them to be reversed and placed into the model as clear text.
|
||||
}
|
||||
if (policy.matchType === PolicyRuleMatchType.Glob) {
|
||||
this.protectedRoomsSet.allProtectedRooms.forEach((room) =>
|
||||
rooms.push(room.toRoomIDOrAlias())
|
||||
);
|
||||
@@ -89,10 +93,10 @@ export class RedactionSynchronisationProtection
|
||||
): Promise<ActionResult<void>> {
|
||||
const relevantChanges = changes.filter(
|
||||
(change) =>
|
||||
change.changeType === SimpleChangeType.Added &&
|
||||
change.changeType !== PolicyRuleChangeType.Removed &&
|
||||
change.rule.kind === PolicyRuleType.User &&
|
||||
this.automaticRedactionReasons.some((reason) =>
|
||||
reason.test(change.rule.reason)
|
||||
reason.test(change.rule.reason ?? "<no reason supplied>")
|
||||
)
|
||||
);
|
||||
// Can't see this fucking up at all when watching a new list :skull:.
|
||||
@@ -125,15 +129,21 @@ export class RedactionSynchronisationProtection
|
||||
) {
|
||||
const policyRevision =
|
||||
this.protectedRoomsSet.watchedPolicyRooms.currentRevision;
|
||||
const matchingPolicy = policyRevision.findRuleMatchingEntity(
|
||||
change.userID,
|
||||
PolicyRuleType.User,
|
||||
Recommendation.Ban
|
||||
);
|
||||
const matchingPolicy =
|
||||
policyRevision.findRuleMatchingEntity(change.userID, {
|
||||
type: PolicyRuleType.User,
|
||||
recommendation: Recommendation.Ban,
|
||||
searchHashedRules: false,
|
||||
}) ??
|
||||
policyRevision.findRuleMatchingEntity(change.userID, {
|
||||
type: PolicyRuleType.User,
|
||||
recommendation: Recommendation.Takedown,
|
||||
searchHashedRules: false,
|
||||
});
|
||||
return (
|
||||
matchingPolicy !== undefined &&
|
||||
this.automaticRedactionReasons.some((reason) =>
|
||||
reason.test(matchingPolicy.reason)
|
||||
reason.test(matchingPolicy.reason ?? "<no reason supplied>")
|
||||
)
|
||||
);
|
||||
} else {
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
// SPDX-FileCopyrightText: 2025 Gnuxie <Gnuxie@protonmail.com>
|
||||
//
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import { Result } from "@gnuxie/typescript-result";
|
||||
import {
|
||||
StringEventID,
|
||||
StringRoomID,
|
||||
} from "@the-draupnir-project/matrix-basic-types";
|
||||
import { LiteralPolicyRule, RoomBasicDetails } from "matrix-protection-suite";
|
||||
|
||||
export type RoomTakedownDetails = Omit<RoomBasicDetails, "avatar"> & {
|
||||
policy_id: StringEventID;
|
||||
created_at: number;
|
||||
};
|
||||
|
||||
export interface RoomAuditLog {
|
||||
takedownRoom(
|
||||
policy: LiteralPolicyRule,
|
||||
details: RoomBasicDetails
|
||||
): Promise<Result<void>>;
|
||||
isRoomTakendown(roomID: StringRoomID): boolean;
|
||||
getTakedownDetails(
|
||||
roomID: StringRoomID
|
||||
): Promise<Result<RoomTakedownDetails | undefined>>;
|
||||
destroy(): void;
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
// SPDX-FileCopyrightText: 2025 Gnuxie <Gnuxie@protonmail.com>
|
||||
//
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import {
|
||||
ConstantPeriodItemBatch,
|
||||
isError,
|
||||
Logger,
|
||||
RoomBasicDetails,
|
||||
RoomHashRecord,
|
||||
SHA256HashStore,
|
||||
StandardBatcher,
|
||||
Task,
|
||||
} from "matrix-protection-suite";
|
||||
import { CheckEventForSpamRequestBody } from "../../webapis/SynapseHTTPAntispam/CheckEventForSpamEndpoint";
|
||||
import { SynapseHttpAntispam } from "../../webapis/SynapseHTTPAntispam/SynapseHttpAntispam";
|
||||
import {
|
||||
StringRoomID,
|
||||
userServerName,
|
||||
} 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";
|
||||
|
||||
// FIXME: This should really have its own "Room acknowledged" table in the audit log...
|
||||
// which only sends when a notification has been sent to the admin room.
|
||||
// This would allow for complete coverage...
|
||||
|
||||
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
|
||||
extends EventEmitter
|
||||
implements RoomDiscovery
|
||||
{
|
||||
private readonly discoveredRooms = new Set<StringRoomID>();
|
||||
private readonly batcher: StandardBatcher<StringRoomID, void>;
|
||||
|
||||
constructor(
|
||||
private readonly synapseHTTPAntispam: SynapseHttpAntispam,
|
||||
private readonly hashStore: SHA256HashStore,
|
||||
private readonly roomDetailsProvider: RoomDetailsProvider,
|
||||
private readonly batchWaitPeriodMS = 500
|
||||
) {
|
||||
super();
|
||||
synapseHTTPAntispam.checkEventForSpamHandles.registerNonBlockingHandle(
|
||||
this.handleCheckEventForSpam
|
||||
);
|
||||
synapseHTTPAntispam.userMayInviteHandles.registerNonBlockingHandle(
|
||||
this.handleUserMayInvite
|
||||
);
|
||||
synapseHTTPAntispam.userMayJoinRoomHandles.registerNonBlockingHandle(
|
||||
this.handleUserMayJoin
|
||||
);
|
||||
this.batcher = new StandardBatcher(
|
||||
() =>
|
||||
new ConstantPeriodItemBatch<StringRoomID, void>(
|
||||
this.forwardDiscoveredBatch,
|
||||
{ waitPeriodMS: this.batchWaitPeriodMS }
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
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);
|
||||
if (detailsResult.ok.creator == undefined) {
|
||||
log.error(
|
||||
"Creator is missing from room details, which isn't great",
|
||||
detailsResult.ok
|
||||
);
|
||||
return;
|
||||
}
|
||||
const storeResult = await this.hashStore.storeRoomIdentification({
|
||||
creator: detailsResult.ok.creator,
|
||||
roomID: detailsResult.ok.room_id,
|
||||
server: userServerName(detailsResult.ok.creator),
|
||||
});
|
||||
if (isError(storeResult)) {
|
||||
log.error(
|
||||
"Error storing room details for a room",
|
||||
roomID,
|
||||
detailsResult.ok
|
||||
);
|
||||
}
|
||||
}
|
||||
})()
|
||||
);
|
||||
}
|
||||
|
||||
private readonly forwardDiscoveredBatch = async function (
|
||||
this: SynapseHTTPAntispamRoomDiscovery,
|
||||
rawEntries: [StringRoomID, undefined][]
|
||||
): Promise<void> {
|
||||
const entries = rawEntries.flatMap((entry) => entry[0]);
|
||||
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);
|
||||
}
|
||||
this.addRoomDetails(storeResult.ok);
|
||||
}.bind(this);
|
||||
|
||||
private readonly handleCheckEventForSpam = function (
|
||||
this: SynapseHTTPAntispamRoomDiscovery,
|
||||
{ event }: CheckEventForSpamRequestBody
|
||||
): void {
|
||||
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
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
// SPDX-FileCopyrightText: 2025 Gnuxie <Gnuxie@protonmail.com>
|
||||
//
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import { StringRoomID } from "@the-draupnir-project/matrix-basic-types";
|
||||
import {
|
||||
LiteralPolicyRule,
|
||||
Logger,
|
||||
PolicyListRevision,
|
||||
PolicyRuleChange,
|
||||
PolicyRuleChangeType,
|
||||
PolicyRuleMatchType,
|
||||
PolicyRuleType,
|
||||
Recommendation,
|
||||
} from "matrix-protection-suite";
|
||||
import { RoomAuditLog } from "./RoomAuditLog";
|
||||
import { isError, Ok, Result, ResultError } from "@gnuxie/typescript-result";
|
||||
import { RoomTakedownCapability } from "../../capabilities/RoomTakedownCapability";
|
||||
|
||||
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 = {
|
||||
handlePolicyChange(
|
||||
revision: PolicyListRevision,
|
||||
changes: PolicyRuleChange[]
|
||||
): Promise<Result<void>>;
|
||||
checkAllRooms(revision: PolicyListRevision): Promise<Result<void>>;
|
||||
};
|
||||
|
||||
/**
|
||||
* This exists as the main handler for reacting to literal room policies
|
||||
* (that have been reversed from hashed policies elsewhere) AND
|
||||
* moving discovered rooms into the hashStore. Although it's not clear
|
||||
* whether we need to do that?
|
||||
*/
|
||||
export class StandardRoomTakedown implements RoomTakedownService {
|
||||
public constructor(
|
||||
private readonly auditLog: RoomAuditLog,
|
||||
private readonly takedownCapability: RoomTakedownCapability
|
||||
) {
|
||||
// nothing to do
|
||||
}
|
||||
|
||||
private async takedownRoom(
|
||||
roomID: StringRoomID,
|
||||
rule: LiteralPolicyRule
|
||||
): Promise<Result<void>> {
|
||||
const isRoomTakendownResult =
|
||||
await this.takedownCapability.isRoomTakendown(roomID);
|
||||
if (isError(isRoomTakendownResult)) {
|
||||
return isRoomTakendownResult;
|
||||
}
|
||||
if (isRoomTakendownResult.ok) {
|
||||
return ResultError.Result(
|
||||
`The room ${roomID} has already been takendown according to your homeserver`
|
||||
);
|
||||
}
|
||||
const detailsResult = await this.takedownCapability.getRoomDetails(roomID);
|
||||
const takedownResult = await this.takedownCapability.takedownRoom(roomID);
|
||||
if (isError(takedownResult)) {
|
||||
return takedownResult;
|
||||
}
|
||||
// Only audit the takedown if the capability is not simulated.
|
||||
if (this.takedownCapability.isSimulated) {
|
||||
return Ok(undefined);
|
||||
}
|
||||
const details = (() => {
|
||||
if (isError(detailsResult)) {
|
||||
log.error(
|
||||
"Unable to fetch details for room before takedown",
|
||||
roomID,
|
||||
detailsResult.error
|
||||
);
|
||||
return { room_id: roomID };
|
||||
} else {
|
||||
return detailsResult.ok;
|
||||
}
|
||||
})();
|
||||
return await this.auditLog.takedownRoom(rule, details);
|
||||
}
|
||||
|
||||
public async handlePolicyChange(
|
||||
revision: PolicyListRevision,
|
||||
changes: PolicyRuleChange[]
|
||||
): Promise<Result<void>> {
|
||||
const roomsToTakedown: LiteralPolicyRule[] = [];
|
||||
for (const change of changes) {
|
||||
if (
|
||||
change.rule.kind === PolicyRuleType.Room &&
|
||||
change.changeType !== PolicyRuleChangeType.Removed &&
|
||||
change.rule.matchType === PolicyRuleMatchType.Literal &&
|
||||
change.rule.recommendation === Recommendation.Takedown
|
||||
) {
|
||||
roomsToTakedown.push(change.rule);
|
||||
}
|
||||
}
|
||||
for (const policy of roomsToTakedown) {
|
||||
const takedownResult = await this.takedownRoom(
|
||||
policy.entity as StringRoomID,
|
||||
policy
|
||||
);
|
||||
if (isError(takedownResult)) {
|
||||
log.error(
|
||||
"Error while trying to takedown the room:",
|
||||
policy.entity,
|
||||
takedownResult.error
|
||||
);
|
||||
}
|
||||
}
|
||||
return Ok(undefined);
|
||||
}
|
||||
|
||||
public async checkAllRooms(
|
||||
revision: PolicyListRevision
|
||||
): Promise<Result<void>> {
|
||||
const roomPolicies = revision
|
||||
.allRulesOfType(PolicyRuleType.Room, Recommendation.Takedown)
|
||||
.filter((policy) => policy.matchType === PolicyRuleMatchType.Literal);
|
||||
for (const policy of roomPolicies) {
|
||||
if (this.auditLog.isRoomTakendown(policy.entity as StringRoomID)) {
|
||||
continue; // room already takendown
|
||||
}
|
||||
const takedownResult = await this.takedownRoom(
|
||||
policy.entity as StringRoomID,
|
||||
policy
|
||||
);
|
||||
if (isError(takedownResult)) {
|
||||
log.error(
|
||||
"Error while trying to takedown the room:",
|
||||
policy.entity,
|
||||
takedownResult.error
|
||||
);
|
||||
}
|
||||
}
|
||||
return Ok(undefined);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
// SPDX-FileCopyrightText: 2025 Gnuxie <Gnuxie@protonmail.com>
|
||||
//
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import {
|
||||
AbstractProtection,
|
||||
ActionResult,
|
||||
describeProtection,
|
||||
Logger,
|
||||
Ok,
|
||||
PolicyListRevision,
|
||||
PolicyRuleChange,
|
||||
ProtectedRoomsSet,
|
||||
Protection,
|
||||
ProtectionDescription,
|
||||
RoomBasicDetails,
|
||||
RoomMessageSender,
|
||||
StringRoomIDSchema,
|
||||
Task,
|
||||
} from "matrix-protection-suite";
|
||||
import { RoomTakedownCapability } from "../../capabilities/RoomTakedownCapability";
|
||||
import { Draupnir } from "../../Draupnir";
|
||||
import { StandardRoomTakedown } from "./RoomTakedown";
|
||||
import { RoomAuditLog } from "./RoomAuditLog";
|
||||
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,
|
||||
typeof RoomTakedownProtectionSettings,
|
||||
RoomTakedownProtectionCapabilities
|
||||
>;
|
||||
|
||||
export class RoomTakedownProtection
|
||||
extends AbstractProtection<RoomTakedownProtectionDescription>
|
||||
implements Protection<RoomTakedownProtectionDescription>
|
||||
{
|
||||
private readonly roomTakedown: StandardRoomTakedown;
|
||||
constructor(
|
||||
description: RoomTakedownProtectionDescription,
|
||||
capabilities: RoomTakedownProtectionCapabilities,
|
||||
protectedRoomsSet: ProtectedRoomsSet,
|
||||
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, {});
|
||||
this.roomTakedown = new StandardRoomTakedown(
|
||||
auditLog,
|
||||
capabilities.roomTakedownCapability
|
||||
);
|
||||
void Task(
|
||||
this.roomTakedown.checkAllRooms(
|
||||
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[]
|
||||
): Promise<ActionResult<void>> {
|
||||
return this.roomTakedown.handlePolicyChange(revision, changes);
|
||||
}
|
||||
|
||||
handleProtectionDisable(): void {
|
||||
this.roomDiscovery?.unregisterListeners();
|
||||
this.roomDiscovery?.off("RoomDiscovery", this.roomDiscoveryListener);
|
||||
}
|
||||
}
|
||||
|
||||
describeProtection<
|
||||
RoomTakedownProtectionCapabilities,
|
||||
Draupnir,
|
||||
typeof RoomTakedownProtectionSettings
|
||||
>({
|
||||
name: RoomTakedownProtection.name,
|
||||
description: `A protection to shutdown rooms matching policies from watched lists`,
|
||||
capabilityInterfaces: {
|
||||
roomTakedownCapability: "RoomTakedownCapability",
|
||||
},
|
||||
defaultCapabilities: {
|
||||
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 = (() => {
|
||||
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,
|
||||
roomDetailsProvider
|
||||
);
|
||||
} 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.stores.roomAuditLog,
|
||||
draupnir.clientPlatform.toRoomMessageSender(),
|
||||
settings.discoveryNotificationEnabled,
|
||||
settings.discoveryNotificationMembershipThreshold,
|
||||
settings.discoveryNotificationRoom ?? draupnir.managementRoomID,
|
||||
roomDiscovery
|
||||
)
|
||||
);
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,223 @@
|
||||
// SPDX-FileCopyrightText: 2025 Gnuxie <Gnuxie@protonmail.com>
|
||||
//
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import { Ok, Result } from "@gnuxie/typescript-result";
|
||||
import {
|
||||
StringEventID,
|
||||
StringRoomID,
|
||||
} from "@the-draupnir-project/matrix-basic-types";
|
||||
import { RoomAuditLog, RoomTakedownDetails } from "./RoomAuditLog";
|
||||
import {
|
||||
ActionException,
|
||||
ActionExceptionKind,
|
||||
LiteralPolicyRule,
|
||||
Logger,
|
||||
PolicyRuleType,
|
||||
RoomBasicDetails,
|
||||
} from "matrix-protection-suite";
|
||||
import {
|
||||
BetterSqliteOptions,
|
||||
BetterSqliteStore,
|
||||
makeBetterSqliteDB,
|
||||
} from "../../backingstore/better-sqlite3/BetterSqliteStore";
|
||||
import { Database } from "better-sqlite3";
|
||||
import path from "path";
|
||||
import {
|
||||
checkKnownTables,
|
||||
SqliteSchemaOptions,
|
||||
} from "../../backingstore/better-sqlite3/SqliteSchema";
|
||||
|
||||
const log = new Logger("SqliteRoomAuditLog");
|
||||
|
||||
// NOTE: This should only be used to check in bulk whether rooms are taken down
|
||||
// upon getting a policy, you probably always want to try again or
|
||||
// check with the server rather than check the audit log.
|
||||
// Incase rooms were unbanned.
|
||||
|
||||
const SchemaText = [
|
||||
`
|
||||
CREATE TABLE policy_info (
|
||||
policy_id TEXT PRIMARY KEY NOT NULL,
|
||||
sender_user_id TEXT NOT NULL,
|
||||
entity TEXT NOT NULL,
|
||||
policy_room_id TEXT NOT NULL,
|
||||
state_key TEXT NOT NULL,
|
||||
type TEXT NOT NULL,
|
||||
recommendation TEXT NOT NULL
|
||||
) STRICT;
|
||||
CREATE TABLE room_takedown (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
policy_id TEXT NOT NULL,
|
||||
target_room_id TEXT NOT NULL,
|
||||
created_at INTEGER DEFAULT (unixepoch()) NOT NULL,
|
||||
FOREIGN KEY (policy_id) REFERENCES policy_info(policy_id)
|
||||
) STRICT;
|
||||
CREATE TABLE room_detail_at_takedown (
|
||||
takedown_id INTEGER NOT NULL,
|
||||
room_id TEXT NOT NULL,
|
||||
creator TEXT,
|
||||
name TEXT,
|
||||
topic TEXT,
|
||||
joined_members INTEGER,
|
||||
FOREIGN KEY (takedown_id) REFERENCES room_takedown(id),
|
||||
PRIMARY KEY (takedown_id, room_id)
|
||||
) STRICT;`,
|
||||
];
|
||||
|
||||
const SchemaOptions = {
|
||||
upgradeSteps: SchemaText.map(
|
||||
(text) =>
|
||||
function (db) {
|
||||
db.exec(text);
|
||||
}
|
||||
),
|
||||
consistencyCheck(db) {
|
||||
return checkKnownTables(db, [
|
||||
"policy_info",
|
||||
"room_takedown",
|
||||
"room_detail_at_takedown",
|
||||
]);
|
||||
},
|
||||
} satisfies SqliteSchemaOptions;
|
||||
|
||||
export type RoomTakedown = {
|
||||
id: number;
|
||||
policy_id: string;
|
||||
target_room_id: StringRoomID;
|
||||
created_at: number;
|
||||
};
|
||||
|
||||
export class SqliteRoomAuditLog
|
||||
extends BetterSqliteStore
|
||||
implements RoomAuditLog
|
||||
{
|
||||
private readonly takedownRooms: Set<StringRoomID>;
|
||||
public constructor(options: BetterSqliteOptions, db: Database) {
|
||||
super(SchemaOptions, db, log);
|
||||
this.takedownRooms = new Set(this.loadTakendownRooms());
|
||||
}
|
||||
|
||||
public static readonly StoreName = "room-audit-log.db";
|
||||
public static createToplevel(storagePath: string): SqliteRoomAuditLog {
|
||||
const options = {
|
||||
path: path.join(storagePath, SqliteRoomAuditLog.StoreName),
|
||||
WALMode: true,
|
||||
foreignKeys: true,
|
||||
fileMustExist: false,
|
||||
};
|
||||
return new SqliteRoomAuditLog(options, makeBetterSqliteDB(options, log));
|
||||
}
|
||||
|
||||
public async takedownRoom(
|
||||
policy: LiteralPolicyRule,
|
||||
details: RoomBasicDetails
|
||||
): Promise<Result<void>> {
|
||||
if (policy.kind !== PolicyRuleType.Room) {
|
||||
throw new TypeError(
|
||||
"You can only use takedownRoom on room policies what are you doing?"
|
||||
);
|
||||
}
|
||||
try {
|
||||
const policyInfoStatement = this.db.prepare(
|
||||
`REPLACE INTO policy_info (policy_id, sender_user_id, policy_room_id, entity, state_key, type, recommendation)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)`
|
||||
);
|
||||
const takedownStatement = this.db
|
||||
.prepare(
|
||||
`INSERT INTO room_takedown (policy_id, target_room_id) VALUES (?, ?) RETURNING id`
|
||||
)
|
||||
.pluck();
|
||||
const detailStatement = this.db.prepare(`
|
||||
INSERT INTO room_detail_at_takedown (takedown_id, room_id, creator, name, topic, joined_members)
|
||||
VALUES (?, ?, ?, ?, ?, ?)`);
|
||||
this.db.transaction(() => {
|
||||
policyInfoStatement.run([
|
||||
policy.sourceEvent.event_id,
|
||||
policy.sourceEvent.sender,
|
||||
policy.sourceEvent.room_id,
|
||||
policy.entity,
|
||||
policy.sourceEvent.state_key,
|
||||
policy.sourceEvent.type,
|
||||
policy.recommendation,
|
||||
]);
|
||||
const takedownID = takedownStatement.get([
|
||||
policy.sourceEvent.event_id,
|
||||
policy.entity,
|
||||
]) as number;
|
||||
detailStatement.run([
|
||||
takedownID,
|
||||
details.room_id,
|
||||
details.creator ?? null,
|
||||
details.name ?? null,
|
||||
details.topic ?? null,
|
||||
details.joined_members ?? null,
|
||||
]);
|
||||
})();
|
||||
this.takedownRooms.add(policy.entity as StringRoomID);
|
||||
return Ok(undefined);
|
||||
} catch (exception) {
|
||||
if (exception instanceof Error) {
|
||||
return ActionException.Result("Unable to log takedown", {
|
||||
exception,
|
||||
exceptionKind: ActionExceptionKind.Unknown,
|
||||
});
|
||||
} else {
|
||||
throw exception;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public async getTakedownDetails(
|
||||
roomID: StringRoomID
|
||||
): Promise<Result<RoomTakedownDetails | undefined>> {
|
||||
try {
|
||||
type RowType = {
|
||||
policy_id: StringEventID;
|
||||
created_at: number;
|
||||
room_id: StringEventID;
|
||||
} & RoomTakedownDetails;
|
||||
const row = this.db
|
||||
.prepare(
|
||||
`
|
||||
SELECT room_takedown.policy_id, room_takedown.created_at, room_detail_at_takedown.*
|
||||
FROM room_takedown
|
||||
JOIN room_detail_at_takedown ON room_takedown.id = room_detail_at_takedown.takedown_id
|
||||
WHERE room_takedown.target_room_id = ?
|
||||
ORDER BY room_takedown.created_at DESC
|
||||
LIMIT 1`
|
||||
)
|
||||
.get(roomID) as RowType | undefined;
|
||||
if (row === undefined) {
|
||||
return Ok(undefined);
|
||||
}
|
||||
return Ok({
|
||||
policy_id: row.policy_id,
|
||||
created_at: row.created_at,
|
||||
room_id: row.room_id,
|
||||
creator: row.creator ?? undefined,
|
||||
name: row.name ?? undefined,
|
||||
topic: row.topic ?? undefined,
|
||||
joined_members: row.joined_members ?? undefined,
|
||||
});
|
||||
} catch (exception) {
|
||||
if (exception instanceof Error) {
|
||||
return ActionException.Result("Unable to fetch takedown details", {
|
||||
exception,
|
||||
exceptionKind: ActionExceptionKind.Unknown,
|
||||
});
|
||||
} else {
|
||||
throw exception;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private loadTakendownRooms(): StringRoomID[] {
|
||||
return this.db
|
||||
.prepare(`SELECT target_room_id FROM room_takedown`)
|
||||
.all() as StringRoomID[];
|
||||
}
|
||||
isRoomTakendown(roomID: StringRoomID): boolean {
|
||||
return this.takedownRooms.has(roomID);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
// SPDX-FileCopyrightText: 2025 Gnuxie <Gnuxie@protonmail.com>
|
||||
//
|
||||
// SPDX-License-Identifier: AFL-3.0
|
||||
@@ -20,10 +20,10 @@ export type CheckEventForSpamListenerArguments = Parameters<
|
||||
(details: CheckEventForSpamRequestBody) => void
|
||||
>;
|
||||
|
||||
type CheckEventForSpamRequestBody = EDStatic<
|
||||
export type CheckEventForSpamRequestBody = EDStatic<
|
||||
typeof CheckEventForSpamRequestBody
|
||||
>;
|
||||
const CheckEventForSpamRequestBody = Type.Object({
|
||||
export const CheckEventForSpamRequestBody = Type.Object({
|
||||
event: RoomEvent(Type.Unknown()),
|
||||
});
|
||||
|
||||
|
||||
@@ -45,37 +45,34 @@ export class SynapseHttpAntispam {
|
||||
private readonly userMayInviteEndpoint = new UserMayInviteEndpoint(
|
||||
this.userMayInviteHandles
|
||||
);
|
||||
public readonly userMayJoinRoomhandles =
|
||||
public readonly userMayJoinRoomHandles =
|
||||
new SpamCheckEndpointPluginManager<UserMayJoinRoomListenerArguments>();
|
||||
private readonly userMayJoinRoomEndpoint = new UserMayJoinRoomEndpoint(
|
||||
this.userMayJoinRoomhandles
|
||||
this.userMayJoinRoomHandles
|
||||
);
|
||||
public readonly checkEventForSpamHandles =
|
||||
new SpamCheckEndpointPluginManager<CheckEventForSpamListenerArguments>();
|
||||
private readonly checkEventForSpamEndpoint = new CheckEventForSpamEndpoint(
|
||||
this.checkEventForSpamHandles
|
||||
);
|
||||
public constructor(
|
||||
private readonly webController: Express,
|
||||
private readonly secret: string
|
||||
) {
|
||||
public constructor(private readonly secret: string) {
|
||||
// nothing to do
|
||||
}
|
||||
|
||||
public register(): void {
|
||||
this.webController.post(
|
||||
public register(webController: Express): void {
|
||||
webController.post(
|
||||
`${SPAM_CHECK_PREFIX}/user_may_invite`,
|
||||
makeAuthenticatedEndpointHandler(this.secret, (request, response) => {
|
||||
this.userMayInviteEndpoint.handleUserMayInvite(request, response);
|
||||
})
|
||||
);
|
||||
this.webController.post(
|
||||
webController.post(
|
||||
`${SPAM_CHECK_PREFIX}/user_may_join_room`,
|
||||
makeAuthenticatedEndpointHandler(this.secret, (request, response) => {
|
||||
this.userMayJoinRoomEndpoint.handleUserMayJoinRoom(request, response);
|
||||
})
|
||||
);
|
||||
this.webController.post(
|
||||
webController.post(
|
||||
`${SPAM_CHECK_PREFIX}/check_event_for_spam`,
|
||||
makeAuthenticatedEndpointHandler(this.secret, (request, response) => {
|
||||
this.checkEventForSpamEndpoint.handleCheckEventForSpam(
|
||||
|
||||
@@ -23,8 +23,10 @@ export type UserMayInviteListenerArguments = Parameters<
|
||||
(details: UserMayInviteRequestBody) => void
|
||||
>;
|
||||
|
||||
type UserMayInviteRequestBody = EDStatic<typeof UserMayInviteRequestBody>;
|
||||
const UserMayInviteRequestBody = Type.Object({
|
||||
export type UserMayInviteRequestBody = EDStatic<
|
||||
typeof UserMayInviteRequestBody
|
||||
>;
|
||||
export const UserMayInviteRequestBody = Type.Object({
|
||||
inviter: StringUserIDSchema,
|
||||
invitee: StringUserIDSchema,
|
||||
room_id: StringRoomIDSchema,
|
||||
|
||||
@@ -21,8 +21,10 @@ export type UserMayJoinRoomListenerArguments = Parameters<
|
||||
(details: UserMayJoinRoomRequestBody) => void
|
||||
>;
|
||||
|
||||
type UserMayJoinRoomRequestBody = EDStatic<typeof UserMayJoinRoomRequestBody>;
|
||||
const UserMayJoinRoomRequestBody = Type.Object({
|
||||
export type UserMayJoinRoomRequestBody = EDStatic<
|
||||
typeof UserMayJoinRoomRequestBody
|
||||
>;
|
||||
export const UserMayJoinRoomRequestBody = Type.Object({
|
||||
user: StringUserIDSchema,
|
||||
room: StringRoomIDSchema,
|
||||
is_invited: Type.Boolean(),
|
||||
|
||||
@@ -34,17 +34,15 @@ const AUTHORIZATION = new RegExp("Bearer (.*)");
|
||||
export class WebAPIs {
|
||||
private webController: express.Express = express();
|
||||
private httpServer?: Server | undefined;
|
||||
public readonly synapseHTTPAntispam = new SynapseHttpAntispam(
|
||||
this.webController,
|
||||
this.config.web.synapseHTTPAntispam.authorization
|
||||
);
|
||||
|
||||
constructor(
|
||||
private reportManager: StandardReportManager,
|
||||
private readonly config: IConfig
|
||||
private readonly config: IConfig,
|
||||
private readonly synapseHTTPAntispam: SynapseHttpAntispam | undefined
|
||||
) {
|
||||
// Setup JSON parsing.
|
||||
this.webController.use(express.json());
|
||||
this.synapseHTTPAntispam?.register(this.webController);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -63,10 +61,7 @@ export class WebAPIs {
|
||||
}
|
||||
);
|
||||
});
|
||||
// enable synapse http antispam
|
||||
if (this.config.web.synapseHTTPAntispam.enabled) {
|
||||
this.synapseHTTPAntispam.register();
|
||||
}
|
||||
|
||||
// configure /report API.
|
||||
if (this.config.web.abuseReporting.enabled) {
|
||||
log.info(`configuring ${API_PREFIX}/report/:room_id/:event_id...`);
|
||||
|
||||
@@ -7,6 +7,7 @@ import { newTestUser } from "./clientHelper";
|
||||
import { getFirstEventMatching } from "./commands/commandUtils";
|
||||
import { DraupnirTestContext, draupnirClient } from "./mjolnirSetupUtils";
|
||||
import {
|
||||
LiteralPolicyRule,
|
||||
MembershipChangeType,
|
||||
NoticeMessageContent,
|
||||
PolicyRuleType,
|
||||
@@ -119,10 +120,12 @@ describe("Ban propagation test", function () {
|
||||
draupnir.protectedRoomsSet.watchedPolicyRooms.currentRevision;
|
||||
const rules = policyListRevisionAfterBan.allRulesMatchingEntity(
|
||||
spamUserID,
|
||||
PolicyRuleType.User
|
||||
{
|
||||
type: PolicyRuleType.User,
|
||||
}
|
||||
);
|
||||
expect(rules.length).toBe(1);
|
||||
expect(rules[0]?.entity).toBe(spamUserID);
|
||||
expect((rules[0] as LiteralPolicyRule).entity).toBe(spamUserID);
|
||||
expect(rules[0]?.reason).toBe("spam");
|
||||
|
||||
// now unban them >:3
|
||||
@@ -153,10 +156,9 @@ describe("Ban propagation test", function () {
|
||||
draupnir.protectedRoomsSet.watchedPolicyRooms.currentRevision;
|
||||
|
||||
const rulesAfterUnban =
|
||||
policyListRevisionAfterUnBan.allRulesMatchingEntity(
|
||||
spamUserID,
|
||||
PolicyRuleType.User
|
||||
);
|
||||
policyListRevisionAfterUnBan.allRulesMatchingEntity(spamUserID, {
|
||||
type: PolicyRuleType.User,
|
||||
});
|
||||
expect(rulesAfterUnban.length).toBe(0);
|
||||
for (const room of protectedRooms) {
|
||||
const membershipRevision =
|
||||
|
||||
@@ -9,10 +9,11 @@
|
||||
// </text>
|
||||
|
||||
import {
|
||||
DefaultEventDecoder,
|
||||
MJOLNIR_PROTECTED_ROOMS_EVENT_TYPE,
|
||||
MJOLNIR_WATCHED_POLICY_ROOMS_EVENT_TYPE,
|
||||
} from "matrix-protection-suite";
|
||||
import { configRead } from "../../src/config";
|
||||
import { configRead, getStoragePath } from "../../src/config";
|
||||
import { patchMatrixClient } from "../../src/utils";
|
||||
import {
|
||||
DraupnirTestContext,
|
||||
@@ -21,9 +22,46 @@ import {
|
||||
makeBotModeToggle,
|
||||
teardownManagementRoom,
|
||||
} from "./mjolnirSetupUtils";
|
||||
import { makeTopLevelStores } from "../../src/backingstore/DraupnirStores";
|
||||
import { SqliteRoomStateBackingStore } from "../../src/backingstore/better-sqlite3/SqliteRoomStateBackingStore";
|
||||
import { SqliteHashReversalStore } from "../../src/backingstore/better-sqlite3/HashStore";
|
||||
import { SqliteRoomAuditLog } from "../../src/protections/RoomTakedown/SqliteRoomAuditLog";
|
||||
import path from "path";
|
||||
import { promises as fs } from "fs";
|
||||
|
||||
patchMatrixClient();
|
||||
|
||||
/**
|
||||
* We cleanup the stores at the start of the tests, so that they can be
|
||||
* inspected if a test fails.
|
||||
*/
|
||||
async function cleanUpTopLevelStores(storagePath: string) {
|
||||
await fs.mkdir(storagePath, { recursive: true }); // Ensure storagePath exists
|
||||
const storePaths = [
|
||||
SqliteRoomStateBackingStore.StoreName,
|
||||
SqliteHashReversalStore.StoreName,
|
||||
SqliteRoomAuditLog.StoreName,
|
||||
].map((name) => path.join(storagePath, name));
|
||||
await Promise.all(
|
||||
storePaths.map(async (filePath) => {
|
||||
try {
|
||||
await fs.unlink(filePath);
|
||||
} catch (error: unknown) {
|
||||
if (
|
||||
typeof error === "object" &&
|
||||
error !== null &&
|
||||
"code" in error &&
|
||||
error.code === "ENOENT"
|
||||
) {
|
||||
// do nothing
|
||||
} else {
|
||||
throw error; // Re-throw other errors
|
||||
}
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
// When Draupnir starts (src/index.ts) it clobbers the config by resolving the management room
|
||||
// alias specified in the config (config.managementRoom) and overwriting that with the room ID.
|
||||
// Unfortunately every piece of code importing that config imports the same instance, including
|
||||
@@ -41,7 +79,17 @@ export const mochaHooks = {
|
||||
this.timeout(30000);
|
||||
const config = (this.config = configRead());
|
||||
this.managementRoomAlias = config.managementRoom;
|
||||
this.toggle = await makeBotModeToggle(config, { eraseAccountData: true });
|
||||
const storagePath = getStoragePath(config.dataPath);
|
||||
await cleanUpTopLevelStores(storagePath);
|
||||
this.stores = makeTopLevelStores(storagePath, DefaultEventDecoder, {
|
||||
isRoomStateBackingStoreEnabled: Boolean(
|
||||
config.roomStateBackingStore.enabled
|
||||
),
|
||||
});
|
||||
this.toggle = await makeBotModeToggle(config, {
|
||||
eraseAccountData: true,
|
||||
stores: this.stores,
|
||||
});
|
||||
this.draupnir = draupnir();
|
||||
const draupnirMatrixClient = draupnirClient();
|
||||
if (draupnirMatrixClient === null) {
|
||||
@@ -57,7 +105,13 @@ export const mochaHooks = {
|
||||
this.timeout(10000);
|
||||
await this.toggle?.stopEverything();
|
||||
draupnirClient()?.stop();
|
||||
|
||||
this.stores?.roomStateBackingStore?.destroy();
|
||||
this.stores?.roomAuditLog?.destroy();
|
||||
// We forgot to add a destroy method in the interface in MPS
|
||||
// and we'll probably need to wait until the next release to remove this.
|
||||
if (this.stores?.hashStore instanceof SqliteHashReversalStore) {
|
||||
this.stores.hashStore.destroy();
|
||||
}
|
||||
// remove alias from management room and leave it.
|
||||
if (this.draupnir !== undefined) {
|
||||
await Promise.all([
|
||||
|
||||
@@ -19,7 +19,7 @@ describe("Test for http antispam callbacks", function () {
|
||||
if (draupnir === undefined) {
|
||||
throw new TypeError(`setup code is wrong`);
|
||||
}
|
||||
const synapseHTTPAntispam = this.toggle?.synapseHTTPAntispam;
|
||||
const synapseHTTPAntispam = draupnir.synapseHTTPAntispam;
|
||||
if (synapseHTTPAntispam === undefined) {
|
||||
throw new TypeError("Setup code is wrong");
|
||||
}
|
||||
|
||||
@@ -13,18 +13,18 @@
|
||||
*/
|
||||
|
||||
import { draupnirClient, makeBotModeToggle } from "./mjolnirSetupUtils";
|
||||
import { configRead } from "../../src/config";
|
||||
import { SqliteRoomStateBackingStore } from "../../src/backingstore/better-sqlite3/SqliteRoomStateBackingStore";
|
||||
import path from "path";
|
||||
import { configRead, getStoragePath } from "../../src/config";
|
||||
import { DefaultEventDecoder } from "matrix-protection-suite";
|
||||
import { makeTopLevelStores } from "../../src/backingstore/DraupnirStores";
|
||||
|
||||
void (async () => {
|
||||
const config = configRead();
|
||||
const storagePath = getStoragePath(config.dataPath);
|
||||
const toggle = await makeBotModeToggle(config, {
|
||||
backingStore: new SqliteRoomStateBackingStore(
|
||||
path.join(config.dataPath, "room-state-backing-store.db"),
|
||||
DefaultEventDecoder
|
||||
),
|
||||
stores: makeTopLevelStores(storagePath, DefaultEventDecoder, {
|
||||
isRoomStateBackingStoreEnabled:
|
||||
config.roomStateBackingStore.enabled ?? false,
|
||||
}),
|
||||
allowSafeMode: true,
|
||||
});
|
||||
await draupnirClient()?.start();
|
||||
|
||||
@@ -29,9 +29,9 @@ import {
|
||||
DefaultEventDecoder,
|
||||
MJOLNIR_PROTECTED_ROOMS_EVENT_TYPE,
|
||||
MJOLNIR_WATCHED_POLICY_ROOMS_EVENT_TYPE,
|
||||
RoomStateBackingStore,
|
||||
} from "matrix-protection-suite";
|
||||
import { SafeModeDraupnir } from "../../src/safemode/DraupnirSafeMode";
|
||||
import { TopLevelStores } from "../../src/backingstore/DraupnirStores";
|
||||
|
||||
patchMatrixClient();
|
||||
|
||||
@@ -53,6 +53,7 @@ export interface DraupnirTestContext extends SafeMochaContext {
|
||||
managementRoomAlias?: string;
|
||||
toggle?: DraupnirBotModeToggle;
|
||||
config: IConfig;
|
||||
stores?: TopLevelStores;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -127,14 +128,14 @@ let globalSafeEmitter: SafeMatrixEmitter | undefined;
|
||||
export async function makeBotModeToggle(
|
||||
config: IConfig,
|
||||
{
|
||||
backingStore,
|
||||
stores,
|
||||
eraseAccountData,
|
||||
allowSafeMode,
|
||||
}: {
|
||||
backingStore?: RoomStateBackingStore;
|
||||
stores: TopLevelStores;
|
||||
eraseAccountData?: boolean;
|
||||
allowSafeMode?: boolean;
|
||||
} = {}
|
||||
} = { stores: {} }
|
||||
): Promise<DraupnirBotModeToggle> {
|
||||
await configureMjolnir(config);
|
||||
LogService.setLevel(LogLevel.fromString(config.logLevel, LogLevel.DEBUG));
|
||||
@@ -165,7 +166,7 @@ export async function makeBotModeToggle(
|
||||
client,
|
||||
new SafeMatrixEmitterWrapper(client, DefaultEventDecoder),
|
||||
config,
|
||||
backingStore
|
||||
stores
|
||||
);
|
||||
// we don't want to send status on startup incase we want to test e2ee from the manual launch script.
|
||||
const mj = (
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
// SPDX-FileCopyrightText: 2025 Gnuxie <Gnuxie@protonmail.com>
|
||||
//
|
||||
// SPDX-License-Identifier: AFL-3.0
|
||||
|
||||
import {
|
||||
MatrixRoomReference,
|
||||
StringRoomID,
|
||||
StringUserID,
|
||||
} from "@the-draupnir-project/matrix-basic-types";
|
||||
import { newTestUser } from "../clientHelper";
|
||||
import { DraupnirTestContext } from "../mjolnirSetupUtils";
|
||||
import { RoomTakedownProtection } from "../../../src/protections/RoomTakedown/RoomTakedownProtection";
|
||||
import expect from "expect";
|
||||
import { Draupnir } from "../../../src/Draupnir";
|
||||
import { PolicyRuleType } from "matrix-protection-suite";
|
||||
|
||||
async function createWatchedPolicyRoom(
|
||||
draupnir: Draupnir
|
||||
): Promise<StringRoomID> {
|
||||
const policyRoomID = (await draupnir.client.createRoom({
|
||||
preset: "public_chat",
|
||||
})) as StringRoomID;
|
||||
(
|
||||
await draupnir.protectedRoomsSet.watchedPolicyRooms.watchPolicyRoomDirectly(
|
||||
MatrixRoomReference.fromRoomID(policyRoomID)
|
||||
)
|
||||
).expect("Should be able to watch the new policy room");
|
||||
return policyRoomID;
|
||||
}
|
||||
|
||||
describe("RoomTakedownProtectionTest", function () {
|
||||
it("Will takedown a room that is added to the policy list", async function (
|
||||
this: DraupnirTestContext
|
||||
) {
|
||||
const draupnir = this.draupnir;
|
||||
if (draupnir === undefined) {
|
||||
throw new TypeError(`setup didn't run properly`);
|
||||
}
|
||||
const moderator = await newTestUser(this.config.homeserverUrl, {
|
||||
name: { contains: "moderator" },
|
||||
});
|
||||
const moderatorUserID = StringUserID(await moderator.getUserId());
|
||||
const takedownTarget = await newTestUser(this.config.homeserverUrl, {
|
||||
name: { contains: "takedown-target" },
|
||||
});
|
||||
const takedownTargetRoomID = StringRoomID(
|
||||
await takedownTarget.createRoom({
|
||||
preset: "public_chat",
|
||||
})
|
||||
);
|
||||
await moderator.joinRoom(draupnir.managementRoomID);
|
||||
const policyRoom = await createWatchedPolicyRoom(draupnir);
|
||||
(
|
||||
await draupnir.sendTextCommand(
|
||||
moderatorUserID,
|
||||
`!draupnir protections enable ${RoomTakedownProtection.name}`
|
||||
)
|
||||
).expect("Should be able to enable the protection");
|
||||
(
|
||||
await draupnir.sendTextCommand(
|
||||
moderatorUserID,
|
||||
`!draupnir takedown ${takedownTargetRoomID} ${policyRoom} --no-confirm`
|
||||
)
|
||||
).expect("Should be able to create the policy targetting the room");
|
||||
|
||||
// give some time for the room to be takendown, synapse can be quite slow at this...
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
expect(
|
||||
draupnir.stores.roomAuditLog?.isRoomTakendown(takedownTargetRoomID)
|
||||
).toBe(true);
|
||||
expect(
|
||||
(
|
||||
await draupnir.clientPlatform
|
||||
.toRoomJoiner()
|
||||
.joinRoom(takedownTargetRoomID)
|
||||
).isOkay
|
||||
).toBe(false);
|
||||
} as unknown as Mocha.AsyncFunc);
|
||||
it(
|
||||
"Takedown a room through discovery and a revealed Literal policy change",
|
||||
async function (this: DraupnirTestContext) {
|
||||
const draupnir = this.draupnir;
|
||||
if (draupnir === undefined) {
|
||||
throw new TypeError(`setup didn't run properly`);
|
||||
}
|
||||
const synapseHTTPAntispam = draupnir.synapseHTTPAntispam;
|
||||
if (synapseHTTPAntispam === undefined) {
|
||||
throw new TypeError("Setup code is wrong");
|
||||
}
|
||||
const moderator = await newTestUser(this.config.homeserverUrl, {
|
||||
name: { contains: "moderator" },
|
||||
});
|
||||
const moderatorUserID = StringUserID(await moderator.getUserId());
|
||||
const takedownTarget = await newTestUser(this.config.homeserverUrl, {
|
||||
name: { contains: "takedown-target" },
|
||||
});
|
||||
const takedownTargetRoomID = StringRoomID(
|
||||
await takedownTarget.createRoom({
|
||||
preset: "public_chat",
|
||||
})
|
||||
);
|
||||
await moderator.joinRoom(draupnir.managementRoomID);
|
||||
const policyRoom = await createWatchedPolicyRoom(draupnir);
|
||||
(
|
||||
await draupnir.sendTextCommand(
|
||||
moderatorUserID,
|
||||
`!draupnir protections enable ${RoomTakedownProtection.name}`
|
||||
)
|
||||
).expect("Should be able to enable the protection");
|
||||
|
||||
const policyRoomEditor = (
|
||||
await draupnir.policyRoomManager.getPolicyRoomEditor(
|
||||
MatrixRoomReference.fromRoomID(policyRoom)
|
||||
)
|
||||
).expect("Should be able to get the policy room editor");
|
||||
(
|
||||
await policyRoomEditor.takedownEntity(
|
||||
PolicyRuleType.Room,
|
||||
takedownTargetRoomID,
|
||||
{ shouldHash: true }
|
||||
)
|
||||
).expect("Should be able to takedown the room via a policy list editor");
|
||||
// give some time for the room to be takendown, synapse can be quite slow at this...
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
const bystander = await newTestUser(this.config.homeserverUrl, {
|
||||
name: { contains: "bystander" },
|
||||
});
|
||||
await bystander.joinRoom(takedownTargetRoomID);
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
expect(
|
||||
draupnir.stores.roomAuditLog?.isRoomTakendown(takedownTargetRoomID)
|
||||
).toBe(true);
|
||||
expect(
|
||||
(
|
||||
await draupnir.clientPlatform
|
||||
.toRoomJoiner()
|
||||
.joinRoom(takedownTargetRoomID)
|
||||
).isOkay
|
||||
).toBe(false);
|
||||
} as unknown as Mocha.AsyncFunc
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,134 @@
|
||||
// SPDX-FileCopyrightText: 2025 Gnuxie <Gnuxie@protonmail.com>
|
||||
//
|
||||
// SPDX-License-Identifier: AFL-3.0
|
||||
|
||||
import { createMock } from "ts-auto-mock";
|
||||
import { StandardRoomTakedown } from "../../../src/protections/RoomTakedown/RoomTakedown";
|
||||
import {
|
||||
describePolicyRule,
|
||||
describeProtectedRoomsSet,
|
||||
Ok,
|
||||
parsePolicyRule,
|
||||
PolicyRuleChangeType,
|
||||
PolicyRuleType,
|
||||
randomRoomID,
|
||||
Recommendation,
|
||||
StandardPolicyListRevision,
|
||||
} from "matrix-protection-suite";
|
||||
import { RoomAuditLog } from "../../../src/protections/RoomTakedown/RoomAuditLog";
|
||||
import { RoomTakedownCapability } from "../../../src/capabilities/RoomTakedownCapability";
|
||||
import { StringRoomID } from "@the-draupnir-project/matrix-basic-types";
|
||||
import expect from "expect";
|
||||
|
||||
function makeServiceMocks(): {
|
||||
auditLogItems: Parameters<RoomAuditLog["takedownRoom"]>[];
|
||||
auditLog: RoomAuditLog;
|
||||
takedownCapabilityItems: StringRoomID[];
|
||||
takedownCapability: RoomTakedownCapability;
|
||||
} {
|
||||
const auditLogItems: Parameters<RoomAuditLog["takedownRoom"]>[] = [];
|
||||
const takedownCapabilityItems: StringRoomID[] = [];
|
||||
return {
|
||||
auditLogItems,
|
||||
auditLog: createMock<RoomAuditLog>({
|
||||
async takedownRoom(...args: Parameters<RoomAuditLog["takedownRoom"]>) {
|
||||
auditLogItems.push(args);
|
||||
return Ok(undefined);
|
||||
},
|
||||
}),
|
||||
takedownCapabilityItems,
|
||||
takedownCapability: createMock<RoomTakedownCapability>({
|
||||
async takedownRoom(roomID: StringRoomID) {
|
||||
takedownCapabilityItems.push(roomID);
|
||||
return Ok({
|
||||
room_id: roomID,
|
||||
});
|
||||
},
|
||||
async isRoomTakendown(_roomID) {
|
||||
return Ok(false);
|
||||
},
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
describe("", function () {
|
||||
it("Test rooms are takendown when policies are added", async function () {
|
||||
const policyRoom = randomRoomID([]);
|
||||
const bannedRoom = randomRoomID([]);
|
||||
const policy = parsePolicyRule(
|
||||
describePolicyRule({
|
||||
room_id: policyRoom.toRoomIDOrAlias(),
|
||||
entity: bannedRoom.toRoomIDOrAlias(),
|
||||
reason: "spam",
|
||||
recommendation: Recommendation.Takedown,
|
||||
type: PolicyRuleType.Room,
|
||||
}) as never
|
||||
).expect("Should be able to parse the policy rule.");
|
||||
const {
|
||||
auditLog,
|
||||
takedownCapability,
|
||||
auditLogItems,
|
||||
takedownCapabilityItems,
|
||||
} = makeServiceMocks();
|
||||
const takedownService = new StandardRoomTakedown(
|
||||
auditLog,
|
||||
takedownCapability
|
||||
);
|
||||
// we give a blank revision because i haven't updated the describeProtectedRoomsSet code
|
||||
// to use hashed policies... although thinking about it we don't use them here either lol
|
||||
(
|
||||
await takedownService.handlePolicyChange(
|
||||
StandardPolicyListRevision.blankRevision(),
|
||||
[
|
||||
{
|
||||
changeType: PolicyRuleChangeType.Added,
|
||||
rule: policy,
|
||||
event: policy.sourceEvent,
|
||||
sender: policy.sourceEvent.sender,
|
||||
},
|
||||
]
|
||||
)
|
||||
).expect("Should have run just fine");
|
||||
expect(auditLogItems.length).toBe(1);
|
||||
expect(takedownCapabilityItems.length).toBe(1);
|
||||
});
|
||||
it("Test rooms are takendown at startup", async function () {
|
||||
const policyRoom = randomRoomID([]);
|
||||
const { protectedRoomsSet } = await describeProtectedRoomsSet({
|
||||
lists: [
|
||||
{
|
||||
room: policyRoom,
|
||||
policyDescriptions: [
|
||||
{
|
||||
entity: randomRoomID([]).toRoomIDOrAlias(),
|
||||
recommendation: Recommendation.Takedown,
|
||||
type: PolicyRuleType.Room,
|
||||
},
|
||||
{
|
||||
entity: randomRoomID([]).toRoomIDOrAlias(),
|
||||
recommendation: Recommendation.Takedown,
|
||||
type: PolicyRuleType.Room,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
const {
|
||||
auditLog,
|
||||
takedownCapability,
|
||||
auditLogItems,
|
||||
takedownCapabilityItems,
|
||||
} = makeServiceMocks();
|
||||
const takedownService = new StandardRoomTakedown(
|
||||
auditLog,
|
||||
takedownCapability
|
||||
);
|
||||
(
|
||||
await takedownService.checkAllRooms(
|
||||
protectedRoomsSet.watchedPolicyRooms.currentRevision
|
||||
)
|
||||
).expect("Should be able to check all rooms");
|
||||
expect(auditLogItems.length).toBe(2);
|
||||
expect(takedownCapabilityItems.length).toBe(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,227 @@
|
||||
// SPDX-FileCopyrightText: 2025 Gnuxie <Gnuxie@protonmail.com>
|
||||
//
|
||||
// SPDX-License-Identifier: AFL-3.0
|
||||
|
||||
import Database from "better-sqlite3";
|
||||
import { SqliteHashReversalStore } from "../../../src/backingstore/better-sqlite3/HashStore";
|
||||
import { BetterSqliteOptions } from "../../../src/backingstore/better-sqlite3/BetterSqliteStore";
|
||||
import {
|
||||
describeStateEvent,
|
||||
HashedLiteralPolicyRule,
|
||||
parsePolicyRule,
|
||||
PolicyRuleMatchType,
|
||||
PolicyRuleType,
|
||||
randomRoomID,
|
||||
randomUserID,
|
||||
Recommendation,
|
||||
} from "matrix-protection-suite";
|
||||
import { SHA256, enc } from "crypto-js";
|
||||
import expect from "expect";
|
||||
import {
|
||||
StringRoomID,
|
||||
StringServerName,
|
||||
StringUserID,
|
||||
} from "@the-draupnir-project/matrix-basic-types";
|
||||
import { createHash } from "crypto";
|
||||
|
||||
function makeStore(): SqliteHashReversalStore {
|
||||
const options = { path: ":memory:" } satisfies BetterSqliteOptions;
|
||||
const db = new Database(options.path);
|
||||
db.pragma("FOREIGN_KEYS = ON");
|
||||
return new SqliteHashReversalStore(db);
|
||||
}
|
||||
|
||||
function base64sha256(entity: string): string {
|
||||
return createHash("sha256").update(entity, "utf8").digest("base64");
|
||||
}
|
||||
|
||||
describe("meow", function () {
|
||||
it("Doesn't return weird nulls if there is nothing in the database", async function () {
|
||||
const store = makeStore();
|
||||
const bannedRoom = randomRoomID([]);
|
||||
const policyRoom = randomRoomID([]);
|
||||
const bannedRoomHash = enc.Base64.stringify(
|
||||
SHA256(bannedRoom.toRoomIDOrAlias())
|
||||
);
|
||||
const findResult = (await store.findRoomHash(bannedRoomHash)).expect(
|
||||
"Should be able to at least query this"
|
||||
);
|
||||
expect(findResult).toBe(undefined);
|
||||
// now for finding reversed hashed room policies:
|
||||
const ban = parsePolicyRule(
|
||||
describeStateEvent({
|
||||
room_id: policyRoom.toRoomIDOrAlias(),
|
||||
content: {
|
||||
recommendation: Recommendation.Takedown,
|
||||
hashes: {
|
||||
sha256: bannedRoomHash,
|
||||
},
|
||||
},
|
||||
sender: randomUserID(),
|
||||
state_key: bannedRoomHash,
|
||||
type: PolicyRuleType.Room,
|
||||
}) as never
|
||||
).expect("Should bea ble to parse the policy rule.");
|
||||
expect(ban.matchType).toBe(PolicyRuleMatchType.HashedLiteral);
|
||||
const reverserResult = (
|
||||
await store.reverseHashedPolicies([ban as HashedLiteralPolicyRule])
|
||||
).expect("Should be able to reverse policies that are unknown");
|
||||
expect(reverserResult.length).toBe(0);
|
||||
// now try storing the room and testing that the policy is there
|
||||
(await store.storeUndiscoveredRooms([bannedRoom.toRoomIDOrAlias()])).expect(
|
||||
"Should be able to discover rooms jsut fine"
|
||||
);
|
||||
const foundHash = (await store.findRoomHash(bannedRoomHash)).expect(
|
||||
"Should be able to findRoomHash"
|
||||
);
|
||||
expect(foundHash).toBe(bannedRoom.toRoomIDOrAlias());
|
||||
const reversedPolicies = (
|
||||
await store.reverseHashedPolicies([ban as HashedLiteralPolicyRule])
|
||||
).expect("Should be able to reverse meow");
|
||||
const reversedBan = reversedPolicies.at(0);
|
||||
if (reversedBan === undefined) {
|
||||
throw new TypeError("Reversed ban isn't here mare");
|
||||
}
|
||||
expect(reversedBan.entity).toBe(bannedRoom.toRoomIDOrAlias());
|
||||
});
|
||||
it("Can discover servers from stored rooms", async function () {
|
||||
const store = makeStore();
|
||||
const bannedServer = StringServerName("banned.example.com");
|
||||
const roomBannedViaServer = StringRoomID("!banned:banned.example.com");
|
||||
const policyRoom = randomRoomID([]);
|
||||
const bannedServerHash = base64sha256(bannedServer);
|
||||
const findResult = (await store.findServerHash(bannedServerHash)).expect(
|
||||
"Should be able to at least query this"
|
||||
);
|
||||
expect(findResult).toBe(undefined);
|
||||
// now for finding reversed hashed room policies:
|
||||
const ban = parsePolicyRule(
|
||||
describeStateEvent({
|
||||
room_id: policyRoom.toRoomIDOrAlias(),
|
||||
content: {
|
||||
recommendation: Recommendation.Takedown,
|
||||
hashes: {
|
||||
sha256: bannedServerHash,
|
||||
},
|
||||
},
|
||||
sender: randomUserID(),
|
||||
state_key: bannedServerHash,
|
||||
type: PolicyRuleType.Server,
|
||||
}) as never
|
||||
).expect("Should bea ble to parse the policy rule.");
|
||||
expect(ban.matchType).toBe(PolicyRuleMatchType.HashedLiteral);
|
||||
const reverserResult = (
|
||||
await store.reverseHashedPolicies([ban as HashedLiteralPolicyRule])
|
||||
).expect("Should be able to reverse policies that are unknown");
|
||||
expect(reverserResult.length).toBe(0);
|
||||
// now try storing the room and testing that the policy is there
|
||||
(await store.storeUndiscoveredRooms([roomBannedViaServer])).expect(
|
||||
"Should be able to discover rooms jsut fine"
|
||||
);
|
||||
const foundHash = (await store.findServerHash(bannedServerHash)).expect(
|
||||
"Should be able to now find the server hash from the room we discovered"
|
||||
);
|
||||
expect(foundHash).toBe(bannedServer);
|
||||
const reversedPolicies = (
|
||||
await store.reverseHashedPolicies([ban as HashedLiteralPolicyRule])
|
||||
).expect("Should be able to reverse meow");
|
||||
const reversedBan = reversedPolicies.at(0);
|
||||
if (reversedBan === undefined) {
|
||||
throw new TypeError("Reversed ban isn't here mare");
|
||||
}
|
||||
expect(reversedBan.entity).toBe(bannedServer);
|
||||
});
|
||||
it("Can reverse user policies", async function () {
|
||||
const store = makeStore();
|
||||
const bannedUser = StringUserID("@banned:banned.example.com");
|
||||
const policyRoom = randomRoomID([]);
|
||||
const bannedUserHash = base64sha256(bannedUser);
|
||||
const findResult = (await store.findUserHash(bannedUserHash)).expect(
|
||||
"Should be able to at least query this"
|
||||
);
|
||||
expect(findResult).toBe(undefined);
|
||||
// now for finding reversed hashed room policies:
|
||||
const ban = parsePolicyRule(
|
||||
describeStateEvent({
|
||||
room_id: policyRoom.toRoomIDOrAlias(),
|
||||
content: {
|
||||
recommendation: Recommendation.Takedown,
|
||||
hashes: {
|
||||
sha256: bannedUserHash,
|
||||
},
|
||||
},
|
||||
sender: randomUserID(),
|
||||
state_key: bannedUserHash,
|
||||
type: PolicyRuleType.User,
|
||||
}) as never
|
||||
).expect("Should bea ble to parse the policy rule.");
|
||||
expect(ban.matchType).toBe(PolicyRuleMatchType.HashedLiteral);
|
||||
const reverserResult = (
|
||||
await store.reverseHashedPolicies([ban as HashedLiteralPolicyRule])
|
||||
).expect("Should be able to reverse policies that are unknown");
|
||||
expect(reverserResult.length).toBe(0);
|
||||
// now try storing the room and testing that the policy is there
|
||||
(await store.storeUndiscoveredUsers([bannedUser])).expect(
|
||||
"Should be able to discover banned users just fine"
|
||||
);
|
||||
const foundHash = (await store.findUserHash(bannedUserHash)).expect(
|
||||
"Should be able to now find the server hash from the room we discovered"
|
||||
);
|
||||
expect(foundHash).toBe(bannedUser);
|
||||
const reversedPolicies = (
|
||||
await store.reverseHashedPolicies([ban as HashedLiteralPolicyRule])
|
||||
).expect("Should be able to reverse meow");
|
||||
const reversedBan = reversedPolicies.at(0);
|
||||
if (reversedBan === undefined) {
|
||||
throw new TypeError("Reversed ban isn't here mare");
|
||||
}
|
||||
expect(reversedBan.entity).toBe(bannedUser);
|
||||
});
|
||||
it("Can discover servers from stored users", async function () {
|
||||
const store = makeStore();
|
||||
const bannedServer = StringServerName("banned.example.com");
|
||||
const bannedUser = StringUserID("@banned:banned.example.com");
|
||||
const policyRoom = randomRoomID([]);
|
||||
const bannedServerHash = base64sha256(bannedServer);
|
||||
const findResult = (await store.findServerHash(bannedServerHash)).expect(
|
||||
"Should be able to at least query this"
|
||||
);
|
||||
expect(findResult).toBe(undefined);
|
||||
// now for finding reversed hashed room policies:
|
||||
const ban = parsePolicyRule(
|
||||
describeStateEvent({
|
||||
room_id: policyRoom.toRoomIDOrAlias(),
|
||||
content: {
|
||||
recommendation: Recommendation.Takedown,
|
||||
hashes: {
|
||||
sha256: bannedServerHash,
|
||||
},
|
||||
},
|
||||
sender: randomUserID(),
|
||||
state_key: bannedServerHash,
|
||||
type: PolicyRuleType.Server,
|
||||
}) as never
|
||||
).expect("Should bea ble to parse the policy rule.");
|
||||
expect(ban.matchType).toBe(PolicyRuleMatchType.HashedLiteral);
|
||||
const reverserResult = (
|
||||
await store.reverseHashedPolicies([ban as HashedLiteralPolicyRule])
|
||||
).expect("Should be able to reverse policies that are unknown");
|
||||
expect(reverserResult.length).toBe(0);
|
||||
// now try storing the room and testing that the policy is there
|
||||
(await store.storeUndiscoveredUsers([bannedUser])).expect(
|
||||
"Should be able to discover banned users just fine"
|
||||
);
|
||||
const foundHash = (await store.findServerHash(bannedServerHash)).expect(
|
||||
"Should be able to now find the server hash from the room we discovered"
|
||||
);
|
||||
expect(foundHash).toBe(bannedServer);
|
||||
const reversedPolicies = (
|
||||
await store.reverseHashedPolicies([ban as HashedLiteralPolicyRule])
|
||||
).expect("Should be able to reverse meow");
|
||||
const reversedBan = reversedPolicies.at(0);
|
||||
if (reversedBan === undefined) {
|
||||
throw new TypeError("Reversed ban isn't here mare");
|
||||
}
|
||||
expect(reversedBan.entity).toBe(bannedServer);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,62 @@
|
||||
// SPDX-FileCopyrightText: 2025 Gnuxie <Gnuxie@protonmail.com>
|
||||
//
|
||||
// SPDX-License-Identifier: AFL-3.0
|
||||
|
||||
import Database from "better-sqlite3";
|
||||
import { BetterSqliteOptions } from "../../../src/backingstore/better-sqlite3/BetterSqliteStore";
|
||||
import { SqliteRoomAuditLog } from "../../../src/protections/RoomTakedown/SqliteRoomAuditLog";
|
||||
import {
|
||||
describePolicyRule,
|
||||
LiteralPolicyRule,
|
||||
parsePolicyRule,
|
||||
PolicyRuleType,
|
||||
randomRoomID,
|
||||
Recommendation,
|
||||
} from "matrix-protection-suite";
|
||||
import expect from "expect";
|
||||
import { StringUserID } from "@the-draupnir-project/matrix-basic-types";
|
||||
|
||||
describe("RoomAuditLog test", function () {
|
||||
const options = { path: ":memory:" } satisfies BetterSqliteOptions;
|
||||
const db = new Database(options.path);
|
||||
db.pragma("FOREIGN_KEYS = ON");
|
||||
const store = new SqliteRoomAuditLog(options, db);
|
||||
it("Test that it doesn't return null or something", async function () {
|
||||
expect(store.isRoomTakendown(randomRoomID([]).toRoomIDOrAlias())).toBe(
|
||||
false
|
||||
);
|
||||
});
|
||||
it("Can logged takendown rooms", async function () {
|
||||
const bannedRoom = randomRoomID([]);
|
||||
const policyRoom = randomRoomID([]);
|
||||
const ban = parsePolicyRule(
|
||||
describePolicyRule({
|
||||
room_id: policyRoom.toRoomIDOrAlias(),
|
||||
entity: bannedRoom.toRoomIDOrAlias(),
|
||||
reason: "spam",
|
||||
recommendation: Recommendation.Takedown,
|
||||
type: PolicyRuleType.Room,
|
||||
}) as never
|
||||
).expect("Should bea ble to parse the policy rule.");
|
||||
(
|
||||
await store.takedownRoom(ban as LiteralPolicyRule, {
|
||||
room_id: bannedRoom.toRoomIDOrAlias(),
|
||||
name: "Spam name",
|
||||
topic: "Spam topic",
|
||||
joined_members: 30,
|
||||
creator: "@spam:example.com" as StringUserID,
|
||||
})
|
||||
).expect("Should be able to takedown a room");
|
||||
expect(store.isRoomTakendown(bannedRoom.toRoomIDOrAlias())).toBe(true);
|
||||
const takedownDetails = (
|
||||
await store.getTakedownDetails(bannedRoom.toRoomIDOrAlias())
|
||||
).expect("Should be able to get takedown details");
|
||||
expect(takedownDetails?.created_at).toBeDefined();
|
||||
expect(takedownDetails?.creator).toBe("@spam:example.com");
|
||||
expect(takedownDetails?.joined_members).toBe(30);
|
||||
expect(takedownDetails?.name).toBe("Spam name");
|
||||
expect(takedownDetails?.topic).toBe("Spam topic");
|
||||
expect(takedownDetails?.room_id).toBe(bannedRoom.toRoomIDOrAlias());
|
||||
expect(takedownDetails?.policy_id).toBeDefined();
|
||||
});
|
||||
});
|
||||
@@ -319,10 +319,10 @@
|
||||
"@gnuxie/super-cool-stream" "^0.2.1"
|
||||
"@gnuxie/typescript-result" "^1.0.0"
|
||||
|
||||
"@the-draupnir-project/matrix-basic-types@1.2.0":
|
||||
version "1.2.0"
|
||||
resolved "https://registry.yarnpkg.com/@the-draupnir-project/matrix-basic-types/-/matrix-basic-types-1.2.0.tgz#94d2a895aafd824a768942c34dec4683ca9449b5"
|
||||
integrity sha512-1v1VCezE17hR5s12ob05uywZKxPxr1FXC8G1RG9TQXB7BBOcZPnVkebOtv/+qLjSwwOyqeHp2nHx9NNQrioXEQ==
|
||||
"@the-draupnir-project/matrix-basic-types@1.3.0":
|
||||
version "1.3.0"
|
||||
resolved "https://registry.yarnpkg.com/@the-draupnir-project/matrix-basic-types/-/matrix-basic-types-1.3.0.tgz#02fa9bd75eeed778a57da0844447feca3a4c663c"
|
||||
integrity sha512-WkUD7cqs9qFr1NQZiJWxa+/1trWrhQqv8SbPxrMLRKv1LMm93ELeguROP470kHNWHrCkVcKmYQVc4uC2Yup+EQ==
|
||||
dependencies:
|
||||
"@gnuxie/typescript-result" "^1.0.0"
|
||||
glob-to-regexp "^0.4.1"
|
||||
@@ -2593,18 +2593,18 @@ matrix-appservice@^2.0.0:
|
||||
request-promise "^4.2.6"
|
||||
sanitize-html "^2.11.0"
|
||||
|
||||
"matrix-protection-suite-for-matrix-bot-sdk@npm:@gnuxie/matrix-protection-suite-for-matrix-bot-sdk@2.10.1":
|
||||
version "2.10.1"
|
||||
resolved "https://registry.yarnpkg.com/@gnuxie/matrix-protection-suite-for-matrix-bot-sdk/-/matrix-protection-suite-for-matrix-bot-sdk-2.10.1.tgz#63ee16db0bcbadd8f52e472a349dae0253662783"
|
||||
integrity sha512-vB0zhdZv8KTZ0FdyqG93CIW9TJHlyHUFCTBsfYsNFFtlVce5fzihL01GqPiJT1BMjLxwVZ84OAvGQa7I5a/DKw==
|
||||
"matrix-protection-suite-for-matrix-bot-sdk@npm:@gnuxie/matrix-protection-suite-for-matrix-bot-sdk@3.0.0":
|
||||
version "3.0.0"
|
||||
resolved "https://registry.yarnpkg.com/@gnuxie/matrix-protection-suite-for-matrix-bot-sdk/-/matrix-protection-suite-for-matrix-bot-sdk-3.0.0.tgz#eb4eaf07b040d223d18e949dfbe8d6712563974e"
|
||||
integrity sha512-Rv7Ouxc11z08RvJ6d+W1v8pTxWUsQoJztMR53YQaRV1Q2YBkRAPJxIm/xIAfAf15j32LYJR+2TKrs1HxwFMybQ==
|
||||
dependencies:
|
||||
"@gnuxie/typescript-result" "^1.0.0"
|
||||
await-lock "^2.2.2"
|
||||
|
||||
"matrix-protection-suite@npm:@gnuxie/matrix-protection-suite@2.10.0":
|
||||
version "2.10.0"
|
||||
resolved "https://registry.yarnpkg.com/@gnuxie/matrix-protection-suite/-/matrix-protection-suite-2.10.0.tgz#23016419cbed28b8a645fe9c82b5df2fe5cf4751"
|
||||
integrity sha512-Zwrm9RryTgJEqZ6Y9uPa39YZZwsRLUqfS5I5X0fXrre3M68VX7BNIYKQ9xKvjTBKsTI/jSSCJImARYt5fBT0Zg==
|
||||
"matrix-protection-suite@npm:@gnuxie/matrix-protection-suite@3.0.0":
|
||||
version "3.0.0"
|
||||
resolved "https://registry.yarnpkg.com/@gnuxie/matrix-protection-suite/-/matrix-protection-suite-3.0.0.tgz#6e679f1f0e3b9a16f046c095d1ed1416a078e376"
|
||||
integrity sha512-X06uzOKN/fwOW2Vm3AKL2HKLbjU1PrqhUsYTzZGGQnEQYkAvoFnuK/TyKF8QaVoMJ/PF4Ybkkj6t5GbZGsGajg==
|
||||
dependencies:
|
||||
"@gnuxie/typescript-result" "^1.0.0"
|
||||
await-lock "^2.2.2"
|
||||
|
||||
Reference in new issue
Block a user