Skip to content

Commit 4ca8d23

Browse files
authored
Merge pull request #758 from the-draupnir-project/gnuxie/synapse-http-antispam
synapse-http-antispam support
2 parents 2e33e65 + 4e9d2a0 commit 4ca8d23

File tree

14 files changed

+595
-8
lines changed

14 files changed

+595
-8
lines changed

config/default.yaml

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -273,6 +273,18 @@ web:
273273
abuseReporting:
274274
# Whether to enable this feature.
275275
enabled: false
276+
# Whether to setup a endpoints for synapse-http-antispam
277+
# https://github.com/maunium/synapse-http-antispam
278+
# this is required for some features of Draupnir,
279+
# such as support for room takedown policies.
280+
#
281+
# Please FOLLOW the instructions here:
282+
# https://the-draupnir-project.github.io/draupnir-documentation/bot/synapse-http-antispam
283+
synapseHTTPAntispam:
284+
enabled: false
285+
# This is a secret that you must place into your synapse module config
286+
# https://github.com/maunium/synapse-http-antispam?tab=readme-ov-file#configuration
287+
authorization: REPLACE_ME
276288

277289
# Whether or not to actively poll synapse for abuse reports, to be used
278290
# instead of intercepting client calls to synapse's abuse endpoint, when that

config/harness.yaml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -186,3 +186,6 @@ web:
186186
abuseReporting:
187187
# Whether to enable this feature.
188188
enabled: true
189+
synapseHTTPAntispam:
190+
enabled: true
191+
authorization: DEFAULT

mx-tester.yml

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -37,12 +37,23 @@ down:
3737
- docker stop mjolnir-test-reverse-proxy || true
3838

3939
modules:
40-
- name: mjolnir
40+
- name: HTTPAntispam
4141
build:
42-
- cp -r synapse_antispam $MX_TEST_MODULE_DIR/
42+
- git clone https://github.com/maunium/synapse-http-antispam.git
43+
$MX_TEST_MODULE_DIR/
4344
config:
44-
module: mjolnir.Module
45-
config: {}
45+
module: synapse_http_antispam.HTTPAntispam
46+
config:
47+
base_url: http://host.docker.internal:8082/api/1/spam_check
48+
authorization: DEFAULT
49+
enabled_callbacks:
50+
- user_may_invite
51+
- user_may_join_room
52+
- check_event_for_spam
53+
fail_open:
54+
user_may_invite: true
55+
user_may_join_room: true
56+
check_event_for_spam: true
4657

4758
homeserver:
4859
# Basic configuration.

src/Draupnir.ts

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,7 @@ import {
8181
COMMAND_CONFIRMATION_LISTENER,
8282
makeConfirmationPromptListener,
8383
} from "./commands/interface-manager/MatrixPromptForConfirmation";
84+
import { SynapseHttpAntispam } from "./webapis/SynapseHTTPAntispam/SynapseHttpAntispam";
8485
const log = new Logger("Draupnir");
8586

8687
// webAPIS should not be included on the Draupnir class.
@@ -144,7 +145,8 @@ export class Draupnir implements Client, MatrixAdaptorContext {
144145
public readonly acceptInvitesFromRoom: MatrixRoomID,
145146
public readonly acceptInvitesFromRoomIssuer: RoomMembershipRevisionIssuer,
146147
public readonly safeModeToggle: SafeModeToggle,
147-
public readonly synapseAdminClient?: SynapseAdminClient
148+
public readonly synapseAdminClient: SynapseAdminClient | undefined,
149+
public readonly synapseHTTPAntispam: SynapseHttpAntispam | undefined
148150
) {
149151
this.managementRoomOutput = new ManagementRoomOutput(
150152
this.managementRoomDetail,
@@ -209,7 +211,8 @@ export class Draupnir implements Client, MatrixAdaptorContext {
209211
roomMembershipManager: RoomMembershipManager,
210212
config: IConfig,
211213
loggableConfigTracker: LoggableConfigTracker,
212-
safeModeToggle: SafeModeToggle
214+
safeModeToggle: SafeModeToggle,
215+
synapseHTTPAntispam: SynapseHttpAntispam | undefined
213216
): Promise<ActionResult<Draupnir>> {
214217
const acceptInvitesFromRoom = await (async () => {
215218
if (config.autojoinOnlyIfManager) {
@@ -267,7 +270,8 @@ export class Draupnir implements Client, MatrixAdaptorContext {
267270
acceptInvitesFromRoom.ok,
268271
acceptInvitesFromRoomIssuer.ok,
269272
safeModeToggle,
270-
new SynapseAdminClient(client, clientUserID)
273+
new SynapseAdminClient(client, clientUserID),
274+
synapseHTTPAntispam
271275
);
272276
const loadResult = await protectedRoomsSet.protections.loadProtections(
273277
protectedRoomsSet,

src/DraupnirBotMode.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ import { SafeModeDraupnir } from "./safemode/DraupnirSafeMode";
4848
import { ResultError } from "@gnuxie/typescript-result";
4949
import { SafeModeCause, SafeModeReason } from "./safemode/SafeModeCause";
5050
import { SafeModeBootOption } from "./safemode/BootOption";
51+
import { SynapseHttpAntispam } from "./webapis/SynapseHTTPAntispam/SynapseHttpAntispam";
5152

5253
const log = new Logger("DraupnirBotMode");
5354

@@ -73,13 +74,20 @@ interface BotModeTogle extends SafeModeToggle {
7374
error: ResultError,
7475
options?: SafeModeToggleOptions
7576
): Promise<Result<SafeModeDraupnir>>;
77+
// The SynapseHTTPAntispam listeners, if available.
78+
// Which they won't be for some bot mode and all application service users.
79+
readonly synapseHTTPAntispam: SynapseHttpAntispam | undefined;
7680
}
7781

7882
export class DraupnirBotModeToggle implements BotModeTogle {
7983
private draupnir: Draupnir | null = null;
8084
private safeModeDraupnir: SafeModeDraupnir | null = null;
8185
private webAPIs: WebAPIs | null = null;
8286

87+
public get synapseHTTPAntispam() {
88+
return this.webAPIs?.synapseHTTPAntispam ?? undefined;
89+
}
90+
8391
private constructor(
8492
private readonly clientUserID: StringUserID,
8593
private readonly managementRoom: MatrixRoomID,

src/config.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,17 @@ export function getNonDefaultConfigProperties(
4040
) {
4141
nonDefault.pantalaimon.password = "REDACTED";
4242
}
43+
if (
44+
"web" in nonDefault &&
45+
typeof nonDefault["web"] === "object" &&
46+
nonDefault["web"] !== null &&
47+
"synapseHTTPAntispam" in nonDefault["web"] &&
48+
typeof nonDefault["web"]["synapseHTTPAntispam"] === "object"
49+
) {
50+
if (nonDefault["web"]["synapseHTTPAntispam"] !== null) {
51+
nonDefault["web"]["synapseHTTPAntispam"].authorization = "REDACTED";
52+
}
53+
}
4354
return nonDefault;
4455
}
4556

@@ -147,6 +158,10 @@ export interface IConfig {
147158
abuseReporting: {
148159
enabled: boolean;
149160
};
161+
synapseHTTPAntispam: {
162+
enabled: boolean;
163+
authorization: string;
164+
};
150165
};
151166
// Store room state using sqlite to improve startup time when Synapse responds
152167
// slowly to requests for `/state`.
@@ -242,6 +257,10 @@ const defaultConfig: IConfig = {
242257
abuseReporting: {
243258
enabled: false,
244259
},
260+
synapseHTTPAntispam: {
261+
enabled: false,
262+
authorization: "DEFAULT",
263+
},
245264
},
246265
roomStateBackingStore: {
247266
enabled: true,

src/draupnirfactory/DraupnirFactory.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ import { SafeModeDraupnir } from "../safemode/DraupnirSafeMode";
2929
import { SafeModeCause } from "../safemode/SafeModeCause";
3030
import { SafeModeToggle } from "../safemode/SafeModeToggle";
3131
import { StandardManagementRoomDetail } from "../managementroom/ManagementRoomDetail";
32+
import { DraupnirBotModeToggle } from "../DraupnirBotMode";
3233

3334
const log = new Logger("DraupnirFactory");
3435

@@ -141,7 +142,11 @@ export class DraupnirFactory {
141142
roomMembershipManager,
142143
config,
143144
configLogTracker,
144-
toggle
145+
toggle,
146+
// synapseHTTPAntispam is only available in bot mode.
147+
toggle instanceof DraupnirBotModeToggle
148+
? toggle.synapseHTTPAntispam
149+
: undefined
145150
);
146151
}
147152

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
// SPDX-FileCopyrightText: 2025 Gnuxie <[email protected]>
2+
//
3+
// SPDX-License-Identifier: AFL-3.0
4+
5+
import { Type } from "@sinclair/typebox";
6+
import {
7+
EDStatic,
8+
isError,
9+
Logger,
10+
RoomEvent,
11+
Task,
12+
Value,
13+
} from "matrix-protection-suite";
14+
import { SpamCheckEndpointPluginManager } from "./SpamCheckEndpointPluginManager";
15+
import { Request, Response } from "express";
16+
17+
const log = new Logger("CheckEventForSpamEndpoint");
18+
19+
export type CheckEventForSpamListenerArguments = Parameters<
20+
(details: CheckEventForSpamRequestBody) => void
21+
>;
22+
23+
type CheckEventForSpamRequestBody = EDStatic<
24+
typeof CheckEventForSpamRequestBody
25+
>;
26+
const CheckEventForSpamRequestBody = Type.Object({
27+
event: RoomEvent(Type.Unknown()),
28+
});
29+
30+
export class CheckEventForSpamEndpoint {
31+
public constructor(
32+
private readonly pluginManager: SpamCheckEndpointPluginManager<CheckEventForSpamListenerArguments>
33+
) {
34+
// nothing to do.
35+
}
36+
37+
private async handleCheckEventForSpamAsync(
38+
request: Request,
39+
response: Response,
40+
isResponded: boolean
41+
): Promise<void> {
42+
const decodedBody = Value.Decode(
43+
CheckEventForSpamRequestBody,
44+
request.body
45+
);
46+
if (isError(decodedBody)) {
47+
log.error("Error decoding request body:", decodedBody.error);
48+
if (!isResponded && this.pluginManager.isBlocking()) {
49+
response
50+
.status(400)
51+
.send({ errcode: "M_INVALID_PARAM", error: "Error handling event" });
52+
}
53+
return;
54+
}
55+
if (!isResponded && this.pluginManager.isBlocking()) {
56+
const blockingResult = await this.pluginManager.callBlockingHandles(
57+
decodedBody.ok
58+
);
59+
if (blockingResult === "NOT_SPAM") {
60+
response.status(200);
61+
response.send({});
62+
} else {
63+
response.status(400);
64+
response.send(blockingResult);
65+
}
66+
} else if (!isResponded) {
67+
response.status(200);
68+
response.send({});
69+
}
70+
this.pluginManager.callNonBlockingHandlesInTask(decodedBody.ok);
71+
}
72+
73+
public handleCheckEventForSpam(request: Request, response: Response): void {
74+
if (!this.pluginManager.isBlocking()) {
75+
response.status(200);
76+
response.send({});
77+
}
78+
void Task(
79+
this.handleCheckEventForSpamAsync(
80+
request,
81+
response,
82+
!this.pluginManager.isBlocking()
83+
)
84+
);
85+
}
86+
}
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
// SPDX-FileCopyrightText: 2025 Gnuxie <[email protected]>
2+
//
3+
// SPDX-License-Identifier: AFL-3.0
4+
5+
import { Logger, Task } from "matrix-protection-suite";
6+
7+
type BlockingResponse =
8+
| "NOT_SPAM"
9+
| {
10+
errcode: string;
11+
error: string;
12+
};
13+
14+
const log = new Logger("SpamCheckEndpointPluginManager");
15+
16+
export type BlockingCallback<CBArguments extends unknown[]> = (
17+
...args: CBArguments
18+
) => Promise<BlockingResponse>;
19+
export type NonBlockingCallback<CBArguments extends unknown[]> = (
20+
...args: CBArguments
21+
) => void;
22+
23+
export class SpamCheckEndpointPluginManager<CBArguments extends unknown[]> {
24+
private readonly blockingHandles = new Set<BlockingCallback<CBArguments>>();
25+
private readonly nonBlockingHandles = new Set<
26+
NonBlockingCallback<CBArguments>
27+
>();
28+
29+
public registerBlockingHandle(handle: BlockingCallback<CBArguments>): void {
30+
this.blockingHandles.add(handle);
31+
}
32+
33+
public registerNonBlockingHandle(
34+
handle: NonBlockingCallback<CBArguments>
35+
): void {
36+
this.nonBlockingHandles.add(handle);
37+
}
38+
39+
public unregisterHandle(
40+
handle: BlockingCallback<CBArguments> | NonBlockingCallback<CBArguments>
41+
): void {
42+
this.blockingHandles.delete(handle as BlockingCallback<CBArguments>);
43+
this.nonBlockingHandles.delete(handle as NonBlockingCallback<CBArguments>);
44+
}
45+
46+
public unregisterListeners(): void {
47+
this.blockingHandles.clear();
48+
this.nonBlockingHandles.clear();
49+
}
50+
51+
public isBlocking(): boolean {
52+
return this.blockingHandles.size > 0;
53+
}
54+
55+
public async callBlockingHandles(
56+
...args: CBArguments
57+
): ReturnType<BlockingCallback<CBArguments>> {
58+
const results = await Promise.allSettled(
59+
[...this.blockingHandles.values()].map((handle) => handle(...args))
60+
);
61+
for (const result of results) {
62+
if (result.status === "rejected") {
63+
log.error(
64+
"Error processing a blocking spam check callback:",
65+
result.reason
66+
);
67+
} else {
68+
if (result.value !== "NOT_SPAM") {
69+
return result.value;
70+
}
71+
}
72+
}
73+
return "NOT_SPAM";
74+
}
75+
76+
public callNonBlockingHandles(...args: CBArguments): void {
77+
for (const handle of this.nonBlockingHandles) {
78+
try {
79+
handle(...args);
80+
} catch (e) {
81+
log.error("Error processing a non blocking spam check callback:", e);
82+
}
83+
}
84+
}
85+
86+
public callNonBlockingHandlesInTask(...args: CBArguments): void {
87+
void Task(
88+
(async () => {
89+
this.callNonBlockingHandles(...args);
90+
})()
91+
);
92+
}
93+
}

0 commit comments

Comments
 (0)