From 05d788e26394106d9be24cef8b38f6c6f1e4c984 Mon Sep 17 00:00:00 2001 From: Elliu Date: Sat, 6 Sep 2025 18:23:01 +0900 Subject: [PATCH 01/12] Add button to unlink a space --- src/web/pug/guild.pug | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/web/pug/guild.pug b/src/web/pug/guild.pug index 92ffa1b..cedc32a 100644 --- a/src/web/pug/guild.pug +++ b/src/web/pug/guild.pug @@ -54,6 +54,10 @@ block body .s-page-title.mb24 h1.s-page-title--header= guild.name + form(method="post" action=rel("/api/unlink-space") hx-confirm="Do you want to unlink this server?\nThis will unlink every channels listed below.\nIt may take a moment to clean up Matrix resources.") + input(type="hidden" name="guild_id" value=guild.id) + button.s-btn.s-btn__muted.s-btn__xs(hx-post=rel("/api/unlink-space") hx-trigger="click" hx-disabled-elt="this")!= icons.Icons.IconLinkSm + .d-flex.g16(class="sm:fw-wrap") .fl-grow1 h2.fs-headline1 Invite a Matrix user From 3597a3b5ce9dde3a9ddfe0853253bfda91a38335 Mon Sep 17 00:00:00 2001 From: Elliu Date: Sat, 6 Sep 2025 19:28:42 +0900 Subject: [PATCH 02/12] Factorize some of the space link/unlink sanity checks --- src/web/routes/link.js | 46 +++++++++++++++++++++++++++--------------- 1 file changed, 30 insertions(+), 16 deletions(-) diff --git a/src/web/routes/link.js b/src/web/routes/link.js index 0afbc49..2a970bb 100644 --- a/src/web/routes/link.js +++ b/src/web/routes/link.js @@ -39,6 +39,33 @@ function getCreateSpace(event) { return event.context.createSpace || sync.require("../../d2m/actions/create-space") } +/** + * @param {H3Event} event + * @param {string} guild_id + */ +async function validateUserHaveRightsOnGuild(event, guild_id) { + const managed = await auth.getManagedGuilds(event) + if (!managed.has(guild_id)) + throw createError({status: 403, message: "Forbidden", data: "Can't edit a guild you don't have Manage Server permissions in"}) +} + +/** + * @param {H3Event} event + * @param {string} guild_id + * @returns {Promise} + */ +async function validateGuildAccess(event, guild_id) { + // Check guild ID or nonce + await validateUserHaveRightsOnGuild(event, guild_id) + + // Check guild exists + const guild = discord.guilds.get(guild_id) + if (!guild) + throw createError({status: 400, message: "Bad Request", data: "Discord guild does not exist or bot has not joined it"}) + + return guild +} + const schema = { linkSpace: z.object({ guild_id: z.string(), @@ -58,12 +85,11 @@ const schema = { as.router.post("/api/link-space", defineEventHandler(async event => { const parsedBody = await readValidatedBody(event, schema.linkSpace.parse) const session = await auth.useSession(event) - const managed = await auth.getManagedGuilds(event) const api = getAPI(event) // Check guild ID const guildID = parsedBody.guild_id - if (!managed.has(guildID)) throw createError({status: 403, message: "Forbidden", data: "Can't edit a guild you don't have Manage Server permissions in"}) + await validateUserHaveRightsOnGuild(event, guildID) // Check space ID if (!session.data.mxid) throw createError({status: 403, message: "Forbidden", data: "Can't link with your Matrix space if you aren't logged in to Matrix"}) @@ -112,18 +138,12 @@ as.router.post("/api/link-space", defineEventHandler(async event => { as.router.post("/api/link", defineEventHandler(async event => { const parsedBody = await readValidatedBody(event, schema.link.parse) - const managed = await auth.getManagedGuilds(event) const api = getAPI(event) const createRoom = getCreateRoom(event) const createSpace = getCreateSpace(event) - // Check guild ID or nonce const guildID = parsedBody.guild_id - if (!managed.has(guildID)) throw createError({status: 403, message: "Forbidden", data: "Can't edit a guild you don't have Manage Server permissions in"}) - - // Check guild is bridged - const guild = discord.guilds.get(guildID) - if (!guild) throw createError({status: 400, message: "Bad Request", data: "Discord guild does not exist or bot has not joined it"}) + const guild = await validateGuildAccess(event, guildID) const spaceID = await createSpace.ensureSpace(guild) // Check channel exists @@ -201,15 +221,9 @@ as.router.post("/api/link", defineEventHandler(async event => { as.router.post("/api/unlink", defineEventHandler(async event => { const {channel_id, guild_id} = await readValidatedBody(event, schema.unlink.parse) - const managed = await auth.getManagedGuilds(event) const createRoom = getCreateRoom(event) - // Check guild ID or nonce - if (!managed.has(guild_id)) throw createError({status: 403, message: "Forbidden", data: "Can't edit a guild you don't have Manage Server permissions in"}) - - // Check guild exists - const guild = discord.guilds.get(guild_id) - if (!guild) throw createError({status: 400, message: "Bad Request", data: "Discord guild does not exist or bot has not joined it"}) + await validateGuildAccess(event, guild_id) // Check that the channel (if it exists) is part of this guild /** @type {any} */ From dfc61594f68db4b52b3553ac7d3561ae9ce13b49 Mon Sep 17 00:00:00 2001 From: Elliu Date: Sat, 6 Sep 2025 19:59:44 +0900 Subject: [PATCH 03/12] Extract /api/unlink code to its own function --- src/web/routes/link.js | 47 +++++++++++++++++++++++++----------------- 1 file changed, 28 insertions(+), 19 deletions(-) diff --git a/src/web/routes/link.js b/src/web/routes/link.js index 2a970bb..7e7df5a 100644 --- a/src/web/routes/link.js +++ b/src/web/routes/link.js @@ -66,6 +66,33 @@ async function validateGuildAccess(event, guild_id) { return guild } +/** + * @param {H3Event} event + * @param {string} channel_id + * @param {string} guild_id + */ +async function doRoomUnlink(event, channel_id, guild_id) { + const createRoom = getCreateRoom(event) + + // Check that the channel (if it exists) is part of this guild + /** @type {any} */ + let channel = discord.channels.get(channel_id) + if (channel) { + if (!("guild_id" in channel) || channel.guild_id !== guild_id) throw createError({status: 400, message: "Bad Request", data: `Channel ID ${channel_id} is not part of guild ${guild_id}`}) + } else { + // Otherwise, if the channel isn't cached, it must have been deleted. + // There's no other authentication here - it's okay for anyone to unlink a deleted channel just by knowing its ID. + channel = {id: channel_id} + } + + // Check channel is currently bridged + const row = select("channel_room", "channel_id", {channel_id: channel_id}).get() + if (!row) throw createError({status: 400, message: "Bad Request", data: `Channel ID ${channel_id} is not currently bridged`}) + + // Do it + await createRoom.unbridgeDeletedChannel(channel, guild_id) +} + const schema = { linkSpace: z.object({ guild_id: z.string(), @@ -221,27 +248,9 @@ as.router.post("/api/link", defineEventHandler(async event => { as.router.post("/api/unlink", defineEventHandler(async event => { const {channel_id, guild_id} = await readValidatedBody(event, schema.unlink.parse) - const createRoom = getCreateRoom(event) - await validateGuildAccess(event, guild_id) - // Check that the channel (if it exists) is part of this guild - /** @type {any} */ - let channel = discord.channels.get(channel_id) - if (channel) { - if (!("guild_id" in channel) || channel.guild_id !== guild_id) throw createError({status: 400, message: "Bad Request", data: `Channel ID ${channel_id} is not part of guild ${guild_id}`}) - } else { - // Otherwise, if the channel isn't cached, it must have been deleted. - // There's no other authentication here - it's okay for anyone to unlink a deleted channel just by knowing its ID. - channel = {id: channel_id} - } - - // Check channel is currently bridged - const row = select("channel_room", "channel_id", {channel_id: channel_id}).get() - if (!row) throw createError({status: 400, message: "Bad Request", data: `Channel ID ${channel_id} is not currently bridged`}) - - // Do it - await createRoom.unbridgeDeletedChannel(channel, guild_id) + await doRoomUnlink(event, channel_id, guild_id) setResponseHeader(event, "HX-Refresh", "true") return null // 204 From 5aff6f9048330a86eda3b2d1862f42df8d2bad84 Mon Sep 17 00:00:00 2001 From: Elliu Date: Sat, 6 Sep 2025 20:05:18 +0900 Subject: [PATCH 04/12] Add /api/unlink-space implementation --- src/web/routes/link.js | 43 ++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 41 insertions(+), 2 deletions(-) diff --git a/src/web/routes/link.js b/src/web/routes/link.js index 7e7df5a..4698dfe 100644 --- a/src/web/routes/link.js +++ b/src/web/routes/link.js @@ -12,6 +12,8 @@ const auth = sync.require("../auth") const mreq = sync.require("../../matrix/mreq") const {reg} = require("../../matrix/read-registration") +const me = `@${reg.sender_localpart}:${reg.ooye.server_name}` + /** * @param {H3Event} event * @returns {import("../../matrix/api")} @@ -106,7 +108,10 @@ const schema = { unlink: z.object({ guild_id: z.string(), channel_id: z.string() - }) + }), + unlinkSpace: z.object({ + guild_id: z.string(), + }), } as.router.post("/api/link-space", defineEventHandler(async event => { @@ -140,7 +145,6 @@ as.router.post("/api/link-space", defineEventHandler(async event => { } // Check bridge has PL 100 - const me = `@${reg.sender_localpart}:${reg.ooye.server_name}` /** @type {Ty.Event.M_Power_Levels?} */ let powerLevelsStateContent = null try { @@ -255,3 +259,38 @@ as.router.post("/api/unlink", defineEventHandler(async event => { setResponseHeader(event, "HX-Refresh", "true") return null // 204 })) + +as.router.post("/api/unlink-space", defineEventHandler(async event => { + const {guild_id} = await readValidatedBody(event, schema.unlinkSpace.parse) + const api = getAPI(event) + await validateGuildAccess(event, guild_id) + + const spaceID = select("guild_space", "space_id", {guild_id: guild_id}).pluck().get() + if (!spaceID) + throw createError({status: 400, message: "Bad Request", data: "Matrix space does not exist or bot has not linked it"}) + + const linkedChannels = select("channel_room", ["channel_id", "room_id", "name", "nick"], {guild_id: guild_id}).all() + + for (const channel of linkedChannels) { + await doRoomUnlink(event, channel.channel_id, guild_id) + } + + const remainingLinkedChannels = select("channel_room", ["channel_id", "room_id", "name", "nick"], {guild_id: guild_id}).all() + if (remainingLinkedChannels.length !== 0) + throw createError({status: 500, message: "Internal Server Error", data: "Some linked room still exists after trying to unlink all of them. Aborting the space unlinking..."}) + + await api.setUserPower(spaceID, me, 0) + await api.leaveRoom(spaceID) + + db.prepare("DELETE FROM guild_space WHERE guild_id=? AND space_id=?").run(guild_id, spaceID) + + // NOTE: not deleting from guild_active as this can lead to inconsistent state: + // if we only delete from DB, the guild is still displayed on the top-right dropdown, + // but when selected we get the "Please add the bot to your server using the buttons on the home page." page + // + // So either keep as-is, or delete from guild_active, but also leave the discord guild? Not sure if we want that or not + // db.prepare("DELETE FROM guild_active WHERE guild_id=?").run(guild_id) + + setResponseHeader(event, "HX-Refresh", "true") + return null // 204 +})) From 16309f26b3dd72927e05454cee8c63504b447b7f Mon Sep 17 00:00:00 2001 From: Elliu Date: Sat, 1 Nov 2025 22:24:51 +0900 Subject: [PATCH 05/12] add tests from /unlink-space endpoint --- src/web/routes/link.test.js | 147 ++++++++++++++++++++++++++++++++++++ 1 file changed, 147 insertions(+) diff --git a/src/web/routes/link.test.js b/src/web/routes/link.test.js index ffe4e5e..4b29891 100644 --- a/src/web/routes/link.test.js +++ b/src/web/routes/link.test.js @@ -666,7 +666,9 @@ test("web unlink room: successfully calls unbridgeDeletedChannel when the channe }) test("web unlink room: checks that the channel is bridged", async t => { + const row = db.prepare("SELECT * FROM channel_room WHERE channel_id = '665310973967597573'").get() db.prepare("DELETE FROM channel_room WHERE channel_id = '665310973967597573'").run() + const [error] = await tryToCatch(() => router.test("post", "/api/unlink", { sessionData: { managedGuilds: ["665289423482519565"] @@ -677,4 +679,149 @@ test("web unlink room: checks that the channel is bridged", async t => { } })) t.equal(error.data, "Channel ID 665310973967597573 is not currently bridged") + + db.prepare("INSERT INTO channel_room (channel_id, room_id, name, nick, thread_parent, custom_avatar, last_bridged_pin_timestamp, speedbump_id, speedbump_checked, speedbump_webhook_id, guild_id, custom_topic) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)").run(row.channel_id, row.room_id, row.name, row.nick, row.thread_parent, row.custom_avatar, row.last_bridged_pin_timestamp, row.speedbump_id, row.speedbump_checked, row.speedbump_webhook_id, row.guild_id, row.custom_topic) + const new_row = db.prepare("SELECT * FROM channel_room WHERE channel_id = '665310973967597573'").get() + t.deepEqual(row, new_row) +}) + +// ***** + +test("web unlink space: access denied if not logged in to Discord", async t => { + const [error] = await tryToCatch(() => router.test("post", "/api/unlink-space", { + body: { + guild_id: "665289423482519565" + } + })) + t.equal(error.data, "Can't edit a guild you don't have Manage Server permissions in") +}) + +test("web unlink space: checks that guild exists", async t => { + const [error] = await tryToCatch(() => router.test("post", "/api/unlink-space", { + sessionData: { + managedGuilds: ["2"] + }, + body: { + guild_id: "2" + } + })) + t.equal(error.data, "Discord guild does not exist or bot has not joined it") +}) + +test("web unlink space: checks that a space is linked to the guild before trying to unlink the space", async t => { + const row = db.prepare("SELECT * FROM guild_space WHERE guild_id = '665289423482519565'").get() + db.prepare("DELETE FROM guild_space WHERE guild_id = '665289423482519565'").run() + + const [error] = await tryToCatch(() => router.test("post", "/api/unlink-space", { + sessionData: { + managedGuilds: ["665289423482519565"] + }, + body: { + guild_id: "665289423482519565" + } + })) + t.equal(error.data, "Matrix space does not exist or bot has not linked it") + + db.prepare("INSERT INTO guild_space (guild_id, space_id, privacy_level, presence, url_preview) VALUES (?, ?, ?, ?, ?)").run(row.guild_id, row.space_id, row.privacy_level, row.presence, row.url_preview) + const new_row = db.prepare("SELECT * FROM guild_space WHERE guild_id = '665289423482519565'").get() + t.deepEqual(row, new_row) +}) + +test("web unlink space: correctly abort unlinking if some linked channels remain after trying to unlink them all", async t => { + let unbridgedChannel = false + + const [error] = await tryToCatch(() => router.test("post", "/api/unlink-space", { + sessionData: { + managedGuilds: ["665289423482519565"] + }, + body: { + guild_id: "665289423482519565", + }, + createRoom: { + async unbridgeDeletedChannel(channel, guildID) { + unbridgedChannel = true + t.equal(channel.id, "665310973967597573") + t.equal(guildID, "665289423482519565") + // Do not actually delete the link from DB, should trigger error later in check + } + }, + api: { + async *generateFullHierarchy(spaceID) { + t.equal(spaceID, "!zTMspHVUBhFLLSdmnS:cadence.moe") + yield { + room_id: "!NDbIqNpJyPvfKRnNcr:cadence.moe", + children_state: {}, + guest_can_join: false, + num_joined_members: 2 + } + /* c8 ignore next */ + }, + } + })) + + t.equal(error.data, "Some linked room still exists after trying to unlink all of them. Aborting the space unlinking...") + t.equal(unbridgedChannel, true) +}) + +test("web unlink space: successfully calls unbridgeDeletedChannel on linked channels in space, self-downgrade power level, leave space, and delete link from DB", async t => { + const {reg} = require("../../matrix/read-registration") + const me = `@${reg.sender_localpart}:${reg.ooye.server_name}` + + const getLinkRowQuery = "SELECT * FROM guild_space WHERE guild_id = '665289423482519565'" + + const row = db.prepare(getLinkRowQuery).get() + t.equal(row.space_id, "!zTMspHVUBhFLLSdmnS:cadence.moe") + + let unbridgedChannel = false + let downgradedPowerLevel = false + let leftRoom = false + await router.test("post", "/api/unlink-space", { + sessionData: { + managedGuilds: ["665289423482519565"] + }, + body: { + guild_id: "665289423482519565", + }, + createRoom: { + async unbridgeDeletedChannel(channel, guildID) { + unbridgedChannel = true + t.equal(channel.id, "665310973967597573") + t.equal(guildID, "665289423482519565") + + // In order to not simulate channel deletion and not trigger the post unlink channels, pre-unlink space check + db.prepare("DELETE FROM channel_room WHERE guild_id = '665289423482519565' AND channel_id = '665310973967597573'").run() + } + }, + api: { + async *generateFullHierarchy(spaceID) { + t.equal(spaceID, "!zTMspHVUBhFLLSdmnS:cadence.moe") + yield { + room_id: "!NDbIqNpJyPvfKRnNcr:cadence.moe", + children_state: {}, + guest_can_join: false, + num_joined_members: 2 + } + /* c8 ignore next */ + }, + + async setUserPower(spaceID, targetUser, powerLevel) { + downgradedPowerLevel = true + t.equal(spaceID, "!zTMspHVUBhFLLSdmnS:cadence.moe") + t.equal(targetUser, me) + t.equal(powerLevel, 0) + }, + + async leaveRoom(spaceID) { + leftRoom = true + t.equal(spaceID, "!zTMspHVUBhFLLSdmnS:cadence.moe") + }, + } + }) + + t.equal(unbridgedChannel, true) + t.equal(downgradedPowerLevel, true) + t.equal(leftRoom, true) + + const missed_row = db.prepare(getLinkRowQuery).get() + t.equal(missed_row, undefined) }) From 5f0ec3b2c861cc8b9edc51389d6176c7a22a1135 Mon Sep 17 00:00:00 2001 From: Cadence Ember Date: Sun, 2 Nov 2025 22:31:14 +1300 Subject: [PATCH 06/12] Improve HTML to a state I'm happy with --- src/web/pug/guild.pug | 22 +++++++++++++++------- src/web/pug/includes/template.pug | 7 +++++++ src/web/routes/link.test.js | 4 ++-- 3 files changed, 24 insertions(+), 9 deletions(-) diff --git a/src/web/pug/guild.pug b/src/web/pug/guild.pug index cedc32a..68e53a8 100644 --- a/src/web/pug/guild.pug +++ b/src/web/pug/guild.pug @@ -54,10 +54,6 @@ block body .s-page-title.mb24 h1.s-page-title--header= guild.name - form(method="post" action=rel("/api/unlink-space") hx-confirm="Do you want to unlink this server?\nThis will unlink every channels listed below.\nIt may take a moment to clean up Matrix resources.") - input(type="hidden" name="guild_id" value=guild.id) - button.s-btn.s-btn__muted.s-btn__xs(hx-post=rel("/api/unlink-space") hx-trigger="click" hx-disabled-elt="this")!= icons.Icons.IconLinkSm - .d-flex.g16(class="sm:fw-wrap") .fl-grow1 h2.fs-headline1 Invite a Matrix user @@ -133,13 +129,13 @@ block body h3.mt32.fs-category Linked channels .s-card.bs-sm.p0 - form.s-table-container(method="post" action=rel("/api/unlink") hx-confirm="Do you want to unlink these channels?\nIt may take a moment to clean up Matrix resources.") + form.s-table-container(method="post" action=rel("/api/unlink")) input(type="hidden" name="guild_id" value=guild_id) table.s-table.s-table__bx-simple each row in linkedChannelsWithDetails tr td.w40: +discord(row.channel) - td.p2: button.s-btn.s-btn__muted.s-btn__xs(name="channel_id" value=row.channel.id hx-post=rel("/api/unlink") hx-trigger="click" hx-disabled-elt="this")!= icons.Icons.IconLinkSm + td.p2: button.s-btn.s-btn__muted.s-btn__xs(name="channel_id" cx-prevent-default hx-post=rel("/api/unlink") hx-confirm="Do you want to unlink these channels?\nIt may take a moment to clean up Matrix resources." value=row.channel.id hx-indicator="this" hx-disabled-elt="this")!= icons.Icons.IconLinkSm td: +matrix(row) else tr @@ -180,6 +176,18 @@ block body != icons.Icons.IconMerge = ` Link` + h3.mt32.fs-category Unlink server + form.s-card.d-flex.fd-row-reverse.gx24.pl24.ai-center(method="post" action=rel("/api/unlink-space")) + input(type="hidden" name="guild_id" value=guild.id) + .fl-grow1.s-prose.s-prose__sm.lh-xl + p. + Sick of this bridge, or just made a mistake? You can unlink the whole server and all its channels.#[br] + This may take a minute to process. Please be patient and wait until the page refreshes. + div + button.s-btn.s-btn__icon.s-btn__danger.s-btn__outlined(cx-prevent-default hx-post=rel("/api/unlink-space") hx-confirm="Do you want to unlink this server and all its channels?\nIt may take a minute to clean up Matrix resources." hx-indicator="this" hx-disabled-elt="this") + != icons.Icons.IconUnsync + span.ml4= ` Unlink` + details.mt48 summary Debug room list .d-grid.grid__2.gx24 @@ -200,7 +208,7 @@ block body ul.my8.ml24 each row in removedWrongTypeChannels li: a(href=`https://discord.com/channels/${guild_id}/${row.id}`) (#{row.type}) #{row.name} - h3.mt24 Unavailable channels: Bridge can't access + h3.mt24 Unavailable channels: Discord bot can't access .s-card.p0 ul.my8.ml24 each row in removedPrivateChannels diff --git a/src/web/pug/includes/template.pug b/src/web/pug/includes/template.pug index d9f1c30..4d424c2 100644 --- a/src/web/pug/includes/template.pug +++ b/src/web/pug/includes/template.pug @@ -129,6 +129,13 @@ html(lang="en") document.styleSheets[0].insertRule(t, document.styleSheets[0].cssRules.length) }) }) + //- Prevent default + script. + document.querySelectorAll("[cx-prevent-default]").forEach(e => { + e.addEventListener("click", event => { + event.preventDefault() + }) + }) script(src=rel("/static/htmx.js")) //- Error dialog aside.s-modal#server-error(aria-hidden="true") diff --git a/src/web/routes/link.test.js b/src/web/routes/link.test.js index 4b29891..721808e 100644 --- a/src/web/routes/link.test.js +++ b/src/web/routes/link.test.js @@ -750,7 +750,7 @@ test("web unlink space: correctly abort unlinking if some linked channels remain t.equal(spaceID, "!zTMspHVUBhFLLSdmnS:cadence.moe") yield { room_id: "!NDbIqNpJyPvfKRnNcr:cadence.moe", - children_state: {}, + children_state: [], guest_can_join: false, num_joined_members: 2 } @@ -797,7 +797,7 @@ test("web unlink space: successfully calls unbridgeDeletedChannel on linked chan t.equal(spaceID, "!zTMspHVUBhFLLSdmnS:cadence.moe") yield { room_id: "!NDbIqNpJyPvfKRnNcr:cadence.moe", - children_state: {}, + children_state: [], guest_can_join: false, num_joined_members: 2 } From f4c1ea7c7f7d5a265b84ce464cd8e9e26d934a32 Mon Sep 17 00:00:00 2001 From: Elliu Date: Tue, 4 Nov 2025 23:54:41 +0900 Subject: [PATCH 07/12] /unlink-space: properly leave guild and clean DB --- src/web/routes/link.js | 29 ++++++++++++++++++----------- 1 file changed, 18 insertions(+), 11 deletions(-) diff --git a/src/web/routes/link.js b/src/web/routes/link.js index 4698dfe..e3da17f 100644 --- a/src/web/routes/link.js +++ b/src/web/routes/link.js @@ -266,31 +266,38 @@ as.router.post("/api/unlink-space", defineEventHandler(async event => { await validateGuildAccess(event, guild_id) const spaceID = select("guild_space", "space_id", {guild_id: guild_id}).pluck().get() - if (!spaceID) + if (!spaceID) { throw createError({status: 400, message: "Bad Request", data: "Matrix space does not exist or bot has not linked it"}) + } const linkedChannels = select("channel_room", ["channel_id", "room_id", "name", "nick"], {guild_id: guild_id}).all() for (const channel of linkedChannels) { await doRoomUnlink(event, channel.channel_id, guild_id) + + // FIXME: probably fix the underlying issue instead: + // If not waiting for ~1s, then the room is half unbridged: + // the resources in the room is not properly cleaned up, meaning that the sim users + // and the bridge user are not power demoted nor leave the room + // The entry from the channel_room table is not deleted + // After that, writing in the discord channel does nothing, + // and writing in the matrix channel spawns an error for not finding guild_id + await new Promise(r => setTimeout(r, 5000)); } const remainingLinkedChannels = select("channel_room", ["channel_id", "room_id", "name", "nick"], {guild_id: guild_id}).all() - if (remainingLinkedChannels.length !== 0) + if (remainingLinkedChannels.length !== 0) { throw createError({status: 500, message: "Internal Server Error", data: "Some linked room still exists after trying to unlink all of them. Aborting the space unlinking..."}) + } await api.setUserPower(spaceID, me, 0) await api.leaveRoom(spaceID) db.prepare("DELETE FROM guild_space WHERE guild_id=? AND space_id=?").run(guild_id, spaceID) + db.prepare("DELETE FROM guild_active WHERE guild_id=?").run(guild_id) + await discord.snow.user.leaveGuild(guild_id) + db.prepare("DELETE FROM invite WHERE room_id=?").run(spaceID) - // NOTE: not deleting from guild_active as this can lead to inconsistent state: - // if we only delete from DB, the guild is still displayed on the top-right dropdown, - // but when selected we get the "Please add the bot to your server using the buttons on the home page." page - // - // So either keep as-is, or delete from guild_active, but also leave the discord guild? Not sure if we want that or not - // db.prepare("DELETE FROM guild_active WHERE guild_id=?").run(guild_id) - - setResponseHeader(event, "HX-Refresh", "true") - return null // 204 + setResponseHeader(event, "HX-Redirect", "/") + return null })) From ccc10564f1e33ab277bc15f360b8c65f2d0ea867 Mon Sep 17 00:00:00 2001 From: Elliu Date: Wed, 5 Nov 2025 00:04:13 +0900 Subject: [PATCH 08/12] fix matrix / db resource cleanup on space unlink --- src/d2m/actions/create-room.js | 6 +++--- src/web/routes/link.js | 15 +++------------ 2 files changed, 6 insertions(+), 15 deletions(-) diff --git a/src/d2m/actions/create-room.js b/src/d2m/actions/create-room.js index ff5782d..7395abb 100644 --- a/src/d2m/actions/create-room.js +++ b/src/d2m/actions/create-room.js @@ -459,12 +459,12 @@ async function unbridgeDeletedChannel(channel, guildID) { const webhook = select("webhook", ["webhook_id", "webhook_token"], {channel_id: channel.id}).get() if (webhook) { await discord.snow.webhook.deleteWebhook(webhook.webhook_id, webhook.webhook_token) - db.prepare("DELETE FROM webhook WHERE channel_id = ?").run(channel.id) + await db.prepare("DELETE FROM webhook WHERE channel_id = ?").run(channel.id) } // delete room from database - db.prepare("DELETE FROM member_cache WHERE room_id = ?").run(roomID) - db.prepare("DELETE FROM channel_room WHERE room_id = ? AND channel_id = ?").run(roomID, channel.id) // cascades to most other tables, like messages + await db.prepare("DELETE FROM member_cache WHERE room_id = ?").run(roomID) + await db.prepare("DELETE FROM channel_room WHERE room_id = ? AND channel_id = ?").run(roomID, channel.id) // cascades to most other tables, like messages if (!botInRoom) return diff --git a/src/web/routes/link.js b/src/web/routes/link.js index e3da17f..8f844ab 100644 --- a/src/web/routes/link.js +++ b/src/web/routes/link.js @@ -274,15 +274,6 @@ as.router.post("/api/unlink-space", defineEventHandler(async event => { for (const channel of linkedChannels) { await doRoomUnlink(event, channel.channel_id, guild_id) - - // FIXME: probably fix the underlying issue instead: - // If not waiting for ~1s, then the room is half unbridged: - // the resources in the room is not properly cleaned up, meaning that the sim users - // and the bridge user are not power demoted nor leave the room - // The entry from the channel_room table is not deleted - // After that, writing in the discord channel does nothing, - // and writing in the matrix channel spawns an error for not finding guild_id - await new Promise(r => setTimeout(r, 5000)); } const remainingLinkedChannels = select("channel_room", ["channel_id", "room_id", "name", "nick"], {guild_id: guild_id}).all() @@ -293,10 +284,10 @@ as.router.post("/api/unlink-space", defineEventHandler(async event => { await api.setUserPower(spaceID, me, 0) await api.leaveRoom(spaceID) - db.prepare("DELETE FROM guild_space WHERE guild_id=? AND space_id=?").run(guild_id, spaceID) - db.prepare("DELETE FROM guild_active WHERE guild_id=?").run(guild_id) + await db.prepare("DELETE FROM guild_space WHERE guild_id=? AND space_id=?").run(guild_id, spaceID) + await db.prepare("DELETE FROM guild_active WHERE guild_id=?").run(guild_id) await discord.snow.user.leaveGuild(guild_id) - db.prepare("DELETE FROM invite WHERE room_id=?").run(spaceID) + await db.prepare("DELETE FROM invite WHERE room_id=?").run(spaceID) setResponseHeader(event, "HX-Redirect", "/") return null From 0f2e575df21bf940e4780c30d2701da989f62471 Mon Sep 17 00:00:00 2001 From: Elliu Date: Wed, 5 Nov 2025 00:04:38 +0900 Subject: [PATCH 09/12] on unbriding room, also demote powel level of bridge user in matrix room --- src/d2m/actions/create-room.js | 1 + 1 file changed, 1 insertion(+) diff --git a/src/d2m/actions/create-room.js b/src/d2m/actions/create-room.js index 7395abb..da2462d 100644 --- a/src/d2m/actions/create-room.js +++ b/src/d2m/actions/create-room.js @@ -507,6 +507,7 @@ async function unbridgeDeletedChannel(channel, guildID) { } // leave room + await api.setUserPower(roomID, bot, 0) await api.leaveRoom(roomID) } From b45eeb150e0702c201b8f710a3bdaa8e9f7d90be Mon Sep 17 00:00:00 2001 From: Elliu Date: Wed, 5 Nov 2025 00:20:20 +0900 Subject: [PATCH 10/12] manually revert 3597a3b: "Factorize some of the space link/unlink sanity checks" --- src/web/routes/link.js | 56 +++++++++++++++++++----------------------- 1 file changed, 25 insertions(+), 31 deletions(-) diff --git a/src/web/routes/link.js b/src/web/routes/link.js index 8f844ab..054f60e 100644 --- a/src/web/routes/link.js +++ b/src/web/routes/link.js @@ -41,33 +41,6 @@ function getCreateSpace(event) { return event.context.createSpace || sync.require("../../d2m/actions/create-space") } -/** - * @param {H3Event} event - * @param {string} guild_id - */ -async function validateUserHaveRightsOnGuild(event, guild_id) { - const managed = await auth.getManagedGuilds(event) - if (!managed.has(guild_id)) - throw createError({status: 403, message: "Forbidden", data: "Can't edit a guild you don't have Manage Server permissions in"}) -} - -/** - * @param {H3Event} event - * @param {string} guild_id - * @returns {Promise} - */ -async function validateGuildAccess(event, guild_id) { - // Check guild ID or nonce - await validateUserHaveRightsOnGuild(event, guild_id) - - // Check guild exists - const guild = discord.guilds.get(guild_id) - if (!guild) - throw createError({status: 400, message: "Bad Request", data: "Discord guild does not exist or bot has not joined it"}) - - return guild -} - /** * @param {H3Event} event * @param {string} channel_id @@ -117,11 +90,12 @@ const schema = { as.router.post("/api/link-space", defineEventHandler(async event => { const parsedBody = await readValidatedBody(event, schema.linkSpace.parse) const session = await auth.useSession(event) + const managed = await auth.getManagedGuilds(event) const api = getAPI(event) // Check guild ID const guildID = parsedBody.guild_id - await validateUserHaveRightsOnGuild(event, guildID) + if (!managed.has(guildID)) throw createError({status: 403, message: "Forbidden", data: "Can't edit a guild you don't have Manage Server permissions in"}) // Check space ID if (!session.data.mxid) throw createError({status: 403, message: "Forbidden", data: "Can't link with your Matrix space if you aren't logged in to Matrix"}) @@ -169,12 +143,18 @@ as.router.post("/api/link-space", defineEventHandler(async event => { as.router.post("/api/link", defineEventHandler(async event => { const parsedBody = await readValidatedBody(event, schema.link.parse) + const managed = await auth.getManagedGuilds(event) const api = getAPI(event) const createRoom = getCreateRoom(event) const createSpace = getCreateSpace(event) + // Check guild ID or nonce const guildID = parsedBody.guild_id - const guild = await validateGuildAccess(event, guildID) + if (!managed.has(guildID)) throw createError({status: 403, message: "Forbidden", data: "Can't edit a guild you don't have Manage Server permissions in"}) + + // Check guild is bridged + const guild = discord.guilds.get(guildID) + if (!guild) throw createError({status: 400, message: "Bad Request", data: "Discord guild does not exist or bot has not joined it"}) const spaceID = await createSpace.ensureSpace(guild) // Check channel exists @@ -252,7 +232,14 @@ as.router.post("/api/link", defineEventHandler(async event => { as.router.post("/api/unlink", defineEventHandler(async event => { const {channel_id, guild_id} = await readValidatedBody(event, schema.unlink.parse) - await validateGuildAccess(event, guild_id) + const managed = await auth.getManagedGuilds(event) + + // Check guild ID or nonce + if (!managed.has(guild_id)) throw createError({status: 403, message: "Forbidden", data: "Can't edit a guild you don't have Manage Server permissions in"}) + + // Check guild exists + const guild = discord.guilds.get(guild_id) + if (!guild) throw createError({status: 400, message: "Bad Request", data: "Discord guild does not exist or bot has not joined it"}) await doRoomUnlink(event, channel_id, guild_id) @@ -262,8 +249,15 @@ as.router.post("/api/unlink", defineEventHandler(async event => { as.router.post("/api/unlink-space", defineEventHandler(async event => { const {guild_id} = await readValidatedBody(event, schema.unlinkSpace.parse) + const managed = await auth.getManagedGuilds(event) const api = getAPI(event) - await validateGuildAccess(event, guild_id) + + // Check guild ID or nonce + if (!managed.has(guild_id)) throw createError({status: 403, message: "Forbidden", data: "Can't edit a guild you don't have Manage Server permissions in"}) + + // Check guild exists + const guild = discord.guilds.get(guild_id) + if (!guild) throw createError({status: 400, message: "Bad Request", data: "Discord guild does not exist or bot has not joined it"}) const spaceID = select("guild_space", "space_id", {guild_id: guild_id}).pluck().get() if (!spaceID) { From eec559293861305394770343d501389905fe1650 Mon Sep 17 00:00:00 2001 From: Cadence Ember Date: Sat, 8 Nov 2025 13:01:59 +1300 Subject: [PATCH 11/12] Dependency inject snow for testing --- src/web/routes/link.js | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/src/web/routes/link.js b/src/web/routes/link.js index 054f60e..c62ffad 100644 --- a/src/web/routes/link.js +++ b/src/web/routes/link.js @@ -41,6 +41,15 @@ function getCreateSpace(event) { return event.context.createSpace || sync.require("../../d2m/actions/create-space") } +/** + * @param {H3Event} event + * @returns {import("snowtransfer").SnowTransfer} + */ +function getSnow(event) { + /* c8 ignore next */ + return event.context.snow || discord.snow +} + /** * @param {H3Event} event * @param {string} channel_id @@ -251,6 +260,7 @@ as.router.post("/api/unlink-space", defineEventHandler(async event => { const {guild_id} = await readValidatedBody(event, schema.unlinkSpace.parse) const managed = await auth.getManagedGuilds(event) const api = getAPI(event) + const snow = getSnow(event) // Check guild ID or nonce if (!managed.has(guild_id)) throw createError({status: 403, message: "Forbidden", data: "Can't edit a guild you don't have Manage Server permissions in"}) @@ -280,7 +290,7 @@ as.router.post("/api/unlink-space", defineEventHandler(async event => { await db.prepare("DELETE FROM guild_space WHERE guild_id=? AND space_id=?").run(guild_id, spaceID) await db.prepare("DELETE FROM guild_active WHERE guild_id=?").run(guild_id) - await discord.snow.user.leaveGuild(guild_id) + await snow.user.leaveGuild(guild_id) await db.prepare("DELETE FROM invite WHERE room_id=?").run(spaceID) setResponseHeader(event, "HX-Redirect", "/") From bd9fd5cd3cf3f1301df18074c997ec537a81b4f5 Mon Sep 17 00:00:00 2001 From: Elliu Date: Sat, 15 Nov 2025 15:32:18 +0900 Subject: [PATCH 12/12] Revert "fix matrix / db resource cleanup on space unlink" This reverts commit ccc10564f1e33ab277bc15f360b8c65f2d0ea867. --- src/d2m/actions/create-room.js | 6 +++--- src/web/routes/link.js | 15 ++++++++++++--- 2 files changed, 15 insertions(+), 6 deletions(-) diff --git a/src/d2m/actions/create-room.js b/src/d2m/actions/create-room.js index da2462d..0c1d96a 100644 --- a/src/d2m/actions/create-room.js +++ b/src/d2m/actions/create-room.js @@ -459,12 +459,12 @@ async function unbridgeDeletedChannel(channel, guildID) { const webhook = select("webhook", ["webhook_id", "webhook_token"], {channel_id: channel.id}).get() if (webhook) { await discord.snow.webhook.deleteWebhook(webhook.webhook_id, webhook.webhook_token) - await db.prepare("DELETE FROM webhook WHERE channel_id = ?").run(channel.id) + db.prepare("DELETE FROM webhook WHERE channel_id = ?").run(channel.id) } // delete room from database - await db.prepare("DELETE FROM member_cache WHERE room_id = ?").run(roomID) - await db.prepare("DELETE FROM channel_room WHERE room_id = ? AND channel_id = ?").run(roomID, channel.id) // cascades to most other tables, like messages + db.prepare("DELETE FROM member_cache WHERE room_id = ?").run(roomID) + db.prepare("DELETE FROM channel_room WHERE room_id = ? AND channel_id = ?").run(roomID, channel.id) // cascades to most other tables, like messages if (!botInRoom) return diff --git a/src/web/routes/link.js b/src/web/routes/link.js index c62ffad..1fa63e1 100644 --- a/src/web/routes/link.js +++ b/src/web/routes/link.js @@ -278,6 +278,15 @@ as.router.post("/api/unlink-space", defineEventHandler(async event => { for (const channel of linkedChannels) { await doRoomUnlink(event, channel.channel_id, guild_id) + + // FIXME: probably fix the underlying issue instead: + // If not waiting for ~1s, then the room is half unbridged: + // the resources in the room is not properly cleaned up, meaning that the sim users + // and the bridge user are not power demoted nor leave the room + // The entry from the channel_room table is not deleted + // After that, writing in the discord channel does nothing, + // and writing in the matrix channel spawns an error for not finding guild_id + await new Promise(r => setTimeout(r, 5000)); } const remainingLinkedChannels = select("channel_room", ["channel_id", "room_id", "name", "nick"], {guild_id: guild_id}).all() @@ -288,10 +297,10 @@ as.router.post("/api/unlink-space", defineEventHandler(async event => { await api.setUserPower(spaceID, me, 0) await api.leaveRoom(spaceID) - await db.prepare("DELETE FROM guild_space WHERE guild_id=? AND space_id=?").run(guild_id, spaceID) - await db.prepare("DELETE FROM guild_active WHERE guild_id=?").run(guild_id) + db.prepare("DELETE FROM guild_space WHERE guild_id=? AND space_id=?").run(guild_id, spaceID) + db.prepare("DELETE FROM guild_active WHERE guild_id=?").run(guild_id) await snow.user.leaveGuild(guild_id) - await db.prepare("DELETE FROM invite WHERE room_id=?").run(spaceID) + db.prepare("DELETE FROM invite WHERE room_id=?").run(spaceID) setResponseHeader(event, "HX-Redirect", "/") return null