From 978de797bad98c3273a1e40a84f942e7f6ad5b15 Mon Sep 17 00:00:00 2001 From: Elliu Date: Sat, 6 Sep 2025 19:28:42 +0900 Subject: [PATCH 1/3] 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 c5f404eb..54ff133d 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"}) @@ -108,18 +134,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 @@ -183,15 +203,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 4c4e17e520eae8acbbf3094b0b75e9f9bc437e89 Mon Sep 17 00:00:00 2001 From: Elliu Date: Sat, 6 Sep 2025 19:59:44 +0900 Subject: [PATCH 2/3] 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 9e0e0023f07a5ffd0d309aec071dfb8cf97937d9 Mon Sep 17 00:00:00 2001 From: Elliu Date: Sat, 6 Sep 2025 20:05:18 +0900 Subject: [PATCH 3/3] Add /api/unlink-space implementation --- src/web/routes/link.js | 36 ++++++++++++++++++++++++++++++++++-- 1 file changed, 34 insertions(+), 2 deletions(-) diff --git a/src/web/routes/link.js b/src/web/routes/link.js index c7def59b..930a6601 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,31 @@ 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) + + // TODO: check valid spaceID etc. + const spaceID = select("guild_space", "space_id", {guild_id: guild_id}).pluck().get() + const channelIDs = discord.guildChannelMap.get(guild_id) + const linkedChannels = select("channel_room", ["channel_id", "room_id", "name", "nick"], {channel_id: channelIDs}).all() + + linkedChannels.forEach(async channel => { + // FIXME: not sure about how to create a proper createRoom. + // Can we tinker with event to add channel_id information? + const createRoom = getCreateRoom(event) + await doRoomUnlink(createRoom, channel.channel_id, guild_id) + }); + + // TODO: add a check that no remaining bridged rooms are in the space + + 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) + + setResponseHeader(event, "HX-Refresh", "true") + return null // 204 +}))