diff --git a/config/config.sample.yaml b/config/config.sample.yaml index e884b4888bb14602a09ef3d46a136ec6e4f9d7c2..edb0b2e6994a21f6a89afdd6996173cfd7512a19 100644 --- a/config/config.sample.yaml +++ b/config/config.sample.yaml @@ -23,6 +23,9 @@ bridge: disableTypingNotifications: false # Disable deleting messages on Discord if a message is redacted on Matrix. disableDeletionForwarding: false + # Disable portal bridging, where Matrix users can search for unbridged Discord + # rooms on their Matrix server. + disablePortalBridging: false # Enable users to bridge rooms using !discord commands. See # https://t2bot.io/discord for instructions. enableSelfServiceBridging: false @@ -99,6 +102,8 @@ limits: # echos = (Copies of a sent message may arrive from discord before we've # fininished handling it, causing us to echo it back to the room) discordSendDelay: 1500 + # Set a maximum of rooms to be bridged. + # roomCount: 20 ghosts: # Pattern for the ghosts nick, available is :nick, :username, :tag and :id nickPattern: ":nick" diff --git a/config/config.schema.yaml b/config/config.schema.yaml index cff80713089f0405c5c84dd611cfbe04338516c4..112c85019cbc7968a3b09a5b6b0cf3d2698d70be 100644 --- a/config/config.schema.yaml +++ b/config/config.schema.yaml @@ -20,6 +20,8 @@ properties: type: "boolean" disableDeletionForwarding: type: "boolean" + disablePortalBridging: + type: "boolean" enableSelfServiceBridging: type: "boolean" disableReadReceipts: @@ -95,6 +97,8 @@ properties: type: "number" discordSendDelay: type: "number" + roomCount: + type: "number" channel: type: "object" properties: diff --git a/src/config.ts b/src/config.ts index 525fad298efd43be8b47797413e143fb64f30e83..256189dc161687838922d5936ba4ec074c0db0ed 100644 --- a/src/config.ts +++ b/src/config.ts @@ -89,6 +89,7 @@ class DiscordBridgeConfigBridge { public disableDiscordMentions: boolean; public disableDeletionForwarding: boolean; public enableSelfServiceBridging: boolean; + public disablePortalBridging: boolean; public disableReadReceipts: boolean; public disableEveryoneMention: boolean = false; public disableHereMention: boolean = false; @@ -140,6 +141,7 @@ export class DiscordBridgeConfigChannelDeleteOptions { class DiscordBridgeConfigLimits { public roomGhostJoinDelay: number = 6000; public discordSendDelay: number = 1500; + public roomCount: number = -1; } export class LoggingFile { diff --git a/src/db/roomstore.ts b/src/db/roomstore.ts index 4b526c7c392a7eef5e0d0f43a7903faf52f16060..401e29e7451cc5aef67efea5dcf4da0794776246 100644 --- a/src/db/roomstore.ts +++ b/src/db/roomstore.ts @@ -98,6 +98,29 @@ export class DbRoomStore { this.entriesMatrixIdCache = new TimedCache(ENTRY_CACHE_LIMETIME); } + /** + * Returns the number of bridged room pairs. Every connection between a + * Matrix room and a remote room counts as one pair. + * @returns {number} The amount of room pairs as an integer + */ + public async countEntries(): Promise<number> { + const row = (await this.db.Get("SELECT COUNT(*) AS count FROM room_entries WHERE matrix_id IS NOT NULL AND remote_id IS NOT NULL")) || {}; + + // Our Sqlite wrapper returns a number – which is what we want. + let count = row.count; + // Our PostgreSQL wrapper returns a string. + if (typeof count === 'string') { + count = Number.parseInt(count); + } + + if (typeof count !== "number") { + log.error("Failed to count room entries"); + throw Error(`Failed to count room entries ${JSON.stringify(row)} AND ${typeof count}`); + } + + return count; + } + public async upsertEntry(entry: IRoomStoreEntry) { const promises: Promise<void>[] = []; diff --git a/src/discordas.ts b/src/discordas.ts index 930e20349bc729bc424c4a14fe6cdf58bfaa176e..c8267cb4844dcbd9fc4f2d6e81e3ce75a2fbb035 100644 --- a/src/discordas.ts +++ b/src/discordas.ts @@ -187,17 +187,19 @@ async function run(): Promise<void> { appservice.expressAppInstance.get("/health", (_, res: Response) => { res.status(200).send(""); }); - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - appservice.on("query.room", async (roomAlias: string, createRoom: (opts: any) => Promise<void>) => { - try { - const createRoomOpts = await roomhandler.OnAliasQuery(roomAlias); - await createRoom(createRoomOpts); - await roomhandler.OnAliasQueried(roomAlias, createRoomOpts.__roomId); - } catch (err) { - log.error("Exception thrown while handling \"query.room\" event", err); - } - }); + + if (config.bridge.disablePortalBridging !== true) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + appservice.on("query.room", async (roomAlias: string, createRoom: (opts: any) => Promise<void>) => { + try { + const createRoomOpts = await roomhandler.OnAliasQuery(roomAlias); + await createRoom(createRoomOpts); + await roomhandler.OnAliasQueried(roomAlias, createRoomOpts.__roomId); + } catch (err) { + log.error("Exception thrown while handling \"query.room\" event", err); + } + }); + } appservice.on("room.event", async (roomId: string, event: IMatrixEvent) => { try { diff --git a/src/matrixcommandhandler.ts b/src/matrixcommandhandler.ts index 08bcb9fb8c8d5a4fa676faa3f9bf8c6c73378cf9..5dfc306e7c506c27030c1239d8a62af0304d9103 100644 --- a/src/matrixcommandhandler.ts +++ b/src/matrixcommandhandler.ts @@ -82,6 +82,10 @@ export class MatrixCommandHandler { if (!guildId || !channelId) { return "Invalid syntax. For more information try `!discord help bridge`"; } + if (await this.provisioner.RoomCountLimitReached(this.config.limits.roomCount)) { + log.info(`Room count limit (value: ${this.config.limits.roomCount}) reached: Rejecting command to bridge new matrix room ${event.room_id} to ${guildId}/${channelId}`); + return `This bridge has reached its room limit of ${this.config.limits.roomCount}. Unbridge another room to allow for new connections.`; + } try { const discordResult = await this.discord.LookupRoom(guildId, channelId); const channel = discordResult.channel as Discord.TextChannel; diff --git a/src/provisioner.ts b/src/provisioner.ts index 633c4c908e27cceb5ec3eee254792fc707ca8821..c1568af96f75c279cd8db257979a2c87e91932dd 100644 --- a/src/provisioner.ts +++ b/src/provisioner.ts @@ -40,6 +40,15 @@ export class Provisioner { return this.roomStore.linkRooms(local, remote); } + /** + * Returns if the room count limit has been reached. + * This can be set by the bridge admin and prevents new rooms from being bridged. + * @returns Has the limit been reached? + */ + public async RoomCountLimitReached(limit: number): Promise<boolean> { + return limit >= 0 && await this.roomStore.countEntries() >= limit; + } + public async UnbridgeChannel(channel: Discord.TextChannel, rId?: string) { const roomsRes = await this.roomStore.getEntriesByRemoteRoomData({ discord_channel: channel.id, diff --git a/test/db/test_roomstore.ts b/test/db/test_roomstore.ts index f9b0b0fe08c4820578070eb616ebfb64cd28416f..9263391972bdb845341c920b431669dcf325ac2b 100644 --- a/test/db/test_roomstore.ts +++ b/test/db/test_roomstore.ts @@ -24,7 +24,7 @@ import { RemoteStoreRoom, MatrixStoreRoom } from "../../src/db/roomstore"; let store: DiscordStore; describe("RoomStore", () => { - before(async () => { + beforeEach(async () => { store = new DiscordStore(":memory:"); await store.init(); }); @@ -189,4 +189,55 @@ describe("RoomStore", () => { expect(entries).to.be.empty; }); }); + describe("countEntries", () => { + it("returns 0 when no entry has been upserted", async () => { + expect(await store.roomStore.countEntries()).to.equal(0); + }); + it("returns 1 when one entry has been upserted", async () => { + await store.roomStore.upsertEntry({ + id: "test", + matrix: new MatrixStoreRoom("test_m"), + remote: new RemoteStoreRoom("test_r", { discord_guild: "find", discord_channel: "this" }), + }); + expect(await store.roomStore.countEntries()).to.equal(1); + }); + it("returns 2 when two entries have been upserted", async () => { + await store.roomStore.upsertEntry({ + id: "test1", + matrix: new MatrixStoreRoom("test1_m"), + remote: new RemoteStoreRoom("test1_r", { discord_guild: "find", discord_channel: "this" }), + }); + await store.roomStore.upsertEntry({ + id: "test2", + matrix: new MatrixStoreRoom("test2_m"), + remote: new RemoteStoreRoom("test2_r", { discord_guild: "find", discord_channel: "this" }), + }); + expect(await store.roomStore.countEntries()).to.equal(2); + }); + it("does not count entries with no matrix_id", async () => { + await store.roomStore.upsertEntry({ + id: "test", + matrix: null, + remote: new RemoteStoreRoom("test_r", { discord_guild: "find", discord_channel: "this" }), + }); + expect(await store.roomStore.countEntries()).to.equal(0); + }); + it("does not count entries with no remote_id", async () => { + await store.roomStore.upsertEntry({ + id: "test", + matrix: new MatrixStoreRoom("test_m"), + remote: null, + }); + expect(await store.roomStore.countEntries()).to.equal(0); + }); + it("returns 0 when one entry has been upserted and removed", async () => { + await store.roomStore.upsertEntry({ + id: "test", + matrix: new MatrixStoreRoom("test_m"), + remote: new RemoteStoreRoom("test_r", { discord_guild: "find", discord_channel: "this" }), + }); + await store.roomStore.removeEntriesByRemoteRoomId("test_r"); + expect(await store.roomStore.countEntries()).to.equal(0); + }); + }); }); diff --git a/test/test_matrixcommandhandler.ts b/test/test_matrixcommandhandler.ts index 7f487f0a78d32b8c9225aef3b86f08aa8c3d74f5..2b74a8300756c39c1fa5beb341df38685262dee4 100644 --- a/test/test_matrixcommandhandler.ts +++ b/test/test_matrixcommandhandler.ts @@ -48,6 +48,9 @@ function createCH(opts: any = {}, shouldBeJoined = true) { throw new Error("Test failed matrix bridge"); } }, + RoomCountLimitReached: async () => { + return !!opts.roomCountLimitReached; + }, UnbridgeChannel: async () => { if (opts.failUnbridge) { throw new Error("Test failed unbridge"); diff --git a/test/test_provisioner.ts b/test/test_provisioner.ts index 9d09939a0f25b7797d83413cfd1db7ec8d3f9ad3..e8d38167db74c5621b38867c08e857fa5c2ca719 100644 --- a/test/test_provisioner.ts +++ b/test/test_provisioner.ts @@ -71,4 +71,30 @@ describe("Provisioner", () => { expect(await promise).to.eq("Approved"); }); }); + describe("RoomCountLimitReached", () => { + it("should return false if no limit is defined", async () => { + const p = new Provisioner({ + countEntries: async () => 7, + } as any, {} as any); + expect(await p.RoomCountLimitReached(-1)).to.equal(false); + }); + it("should return false if less rooms exist than the limit", async () => { + const p = new Provisioner({ + countEntries: async () => 7, + } as any, {} as any); + expect(await p.RoomCountLimitReached(10)).to.equal(false); + }); + it("should return true if more rooms exist than the limit", async () => { + const p = new Provisioner({ + countEntries: async () => 7, + } as any, {} as any); + expect(await p.RoomCountLimitReached(5)).to.equal(true); + }); + it("should return true if there are as many rooms as the limit allows", async () => { + const p = new Provisioner({ + countEntries: async () => 7, + } as any, {} as any); + expect(await p.RoomCountLimitReached(7)).to.equal(true); + }); + }); });