From 013260e970a79ef8362f21a6be6f2ef392e5d1e7 Mon Sep 17 00:00:00 2001 From: Elliu Date: Sat, 6 Sep 2025 19:59:44 +0900 Subject: [PATCH 1/4] Extract /api/unlink code to its own function --- .editorconfig | 6 ++++++ src/web/routes/link.js | 47 +++++++++++++++++++++++++----------------- 2 files changed, 34 insertions(+), 19 deletions(-) create mode 100644 .editorconfig diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 00000000..089c28f8 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,6 @@ +[*] +indent_style = tab + +[*.pug] +indent_style = space +indent_size = 2 diff --git a/src/web/routes/link.js b/src/web/routes/link.js index 54ff133d..c7def59b 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(), @@ -203,27 +230,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 2f830daab487ab1f00554fbfda72c273cad87e02 Mon Sep 17 00:00:00 2001 From: Elliu Date: Sat, 6 Sep 2025 20:05:18 +0900 Subject: [PATCH 2/4] 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 c7def59b..9ca62b7d 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 => { @@ -136,7 +141,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 { @@ -237,3 +241,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 1413e76ed6c8e508473dc7652f33a14f9fcb4628 Mon Sep 17 00:00:00 2001 From: Cadence Ember Date: Sat, 23 Aug 2025 23:46:51 +1200 Subject: [PATCH 3/4] Fill in more of reg for other people to test with --- src/matrix/api.test.js | 4 ++++ test/test.js | 2 ++ 2 files changed, 6 insertions(+) diff --git a/src/matrix/api.test.js b/src/matrix/api.test.js index 82565ebe..da923858 100644 --- a/src/matrix/api.test.js +++ b/src/matrix/api.test.js @@ -24,3 +24,7 @@ test("api path: real world mxid", t => { test("api path: extras number works", t => { t.equal(path(`/client/v3/rooms/!example/timestamp_to_event`, null, {ts: 1687324651120}), "/client/v3/rooms/!example/timestamp_to_event?ts=1687324651120") }) + +test("api path: multiple via params", t => { + t.equal(path(`/client/v3/rooms/!example/join`, null, {via: ["cadence.moe", "matrix.org"], ts: 1687324651120}), "/client/v3/rooms/!example/join?via=cadence.moe&via=matrix.org&ts=1687324651120") +}) diff --git a/test/test.js b/test/test.js index 233fd940..b01f0ce2 100644 --- a/test/test.js +++ b/test/test.js @@ -17,6 +17,8 @@ const {reg} = require("../src/matrix/read-registration") reg.ooye.discord_token = "Njg0MjgwMTkyNTUzODQ0NzQ3.Xl3zlw.baby" reg.ooye.server_origin = "https://matrix.cadence.moe" // so that tests will pass even when hard-coded reg.ooye.server_name = "cadence.moe" +reg.ooye.namespace_prefix = "_ooye_" +reg.sender_localpart = "_ooye_bot" reg.id = "baby" reg.as_token = "don't actually take authenticated actions on the server" reg.hs_token = "don't actually take authenticated actions on the server" From 6ecc8167b2c46dd9dd29a36ee53dc8251fce2f1e Mon Sep 17 00:00:00 2001 From: Elliu Date: Sat, 1 Nov 2025 22:24:51 +0900 Subject: [PATCH 4/4] 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 0d8d366d..65f8b11d 100644 --- a/src/web/routes/link.test.js +++ b/src/web/routes/link.test.js @@ -618,7 +618,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"] @@ -629,4 +631,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) })