diff --git a/docs/self-service-room-creation-rules.md b/docs/self-service-room-creation-rules.md index 70892fc..4156f26 100644 --- a/docs/self-service-room-creation-rules.md +++ b/docs/self-service-room-creation-rules.md @@ -69,3 +69,9 @@ So here's all the technical changes needed to support self-service in v3: - When bot is added through "self-service" web button, REPLACE INTO state 0. - Event dispatcher will only ensureRoom if the guild_active state is 1. - createRoom will only create other dependencies if the guild is autocreate. + +## Enough with your theory. How do rooms actually get bridged now? + +After clicking the easy mode button on web and adding the bot to a server, it will create new Matrix rooms on-demand when any invite features are used (web or command) OR just when any message is sent on Discord. + +Alternatively, pressing the self-service mode button and adding the bot to a server will prompt the web user to link it with a space. After doing so, they'll be on the standard guild management page where they can invite to the space and manually link rooms. Nothing will be autocreated. diff --git a/src/db/migrations/0019-add-invite.sql b/src/db/migrations/0019-add-invite.sql index 32f3c90..6ad03f9 100644 --- a/src/db/migrations/0019-add-invite.sql +++ b/src/db/migrations/0019-add-invite.sql @@ -4,6 +4,9 @@ CREATE TABLE "invite" ( "mxid" TEXT NOT NULL, "room_id" TEXT NOT NULL, "type" TEXT, + "name" TEXT, + "topic" TEXT, + "avatar" TEXT, PRIMARY KEY("mxid","room_id") ) WITHOUT ROWID; diff --git a/src/db/orm-defs.d.ts b/src/db/orm-defs.d.ts index cdb7ba1..bab3c80 100644 --- a/src/db/orm-defs.d.ts +++ b/src/db/orm-defs.d.ts @@ -44,6 +44,8 @@ export type Models = { mxid: string room_id: string type: string | null + name: string | null + avatar: string | null } lottie: { diff --git a/src/discord/register-interactions.js b/src/discord/register-interactions.js index 7b1e52d..0c5a0b0 100644 --- a/src/discord/register-interactions.js +++ b/src/discord/register-interactions.js @@ -64,7 +64,9 @@ discord.snow.interaction.bulkOverwriteApplicationCommands(id, [{ }] } ] -}]) +}]).catch(e => { + console.error(e) +}) async function dispatchInteraction(interaction) { const interactionId = interaction.data.custom_id || interaction.data.name diff --git a/src/m2d/event-dispatcher.js b/src/m2d/event-dispatcher.js index e959264..8aa38cf 100644 --- a/src/m2d/event-dispatcher.js +++ b/src/m2d/event-dispatcher.js @@ -208,6 +208,26 @@ async event => { await api.ackEvent(event) })) +function getFromInviteRoomState(inviteRoomState, nskey, key) { + if (!Array.isArray(inviteRoomState)) return null + for (const event of inviteRoomState) { + if (event.type === nskey && event.state_key === "") { + return event.content[key] + } + } + return null +} + +sync.addTemporaryListener(as, "type:m.space.child", guard("m.space.child", +/** + * @param {Ty.Event.StateOuter} event + */ +async event => { + if (Array.isArray(event.content.via) && event.content.via.length) { // space child is being added + await api.joinRoom(event.state_key).catch(() => {}) // try to join if able, it's okay if it doesn't want, bot will still respond to invites + } +})) + sync.addTemporaryListener(as, "type:m.room.member", guard("m.room.member", /** * @param {Ty.Event.StateOuter} event @@ -217,9 +237,14 @@ async event => { if (event.content.membership === "invite" && event.state_key === `@${reg.sender_localpart}:${reg.ooye.server_name}`) { // We were invited to a room. We should join, and register the invite details for future reference in web. + const name = getFromInviteRoomState(event.unsigned?.invite_room_state, "m.room.name", "name") + const topic = getFromInviteRoomState(event.unsigned?.invite_room_state, "m.room.topic", "topic") + const avatar = getFromInviteRoomState(event.unsigned?.invite_room_state, "m.room.avatar", "url") + const creationType = getFromInviteRoomState(event.unsigned?.invite_room_state, "m.room.create", "type") + if (!name) return await api.leaveRoomWithReason(event.room_id, "Please only invite me to rooms that have a name/avatar set. Update the room details and reinvite!") await api.joinRoom(event.room_id) - const creation = await api.getStateEvent(event.room_id, "m.room.create", "") - db.prepare("INSERT OR IGNORE INTO invite (mxid, room_id, type) VALUES (?, ?, ?)").run(event.sender, event.room_id, creation.type || null) + db.prepare("INSERT OR IGNORE INTO invite (mxid, room_id, type, name, topic, avatar) VALUES (?, ?, ?, ?, ?, ?)").run(event.sender, event.room_id, creationType, name, topic, avatar) + if (avatar) utils.getPublicUrlForMxc(avatar) // make sure it's available in the media_proxy allowed URLs } if (utils.eventSenderIsFromDiscord(event.state_key)) return diff --git a/src/matrix/api.js b/src/matrix/api.js index 4920b0c..d1d9516 100644 --- a/src/matrix/api.js +++ b/src/matrix/api.js @@ -82,6 +82,16 @@ async function leaveRoom(roomID, mxid) { await mreq.mreq("POST", path(`/client/v3/rooms/${roomID}/leave`, mxid), {}) } +/** + * @param {string} roomID + * @param {string} reason + * @param {string} [mxid] + */ +async function leaveRoomWithReason(roomID, reason, mxid) { + console.log(`[api] leave: ${roomID}: ${mxid}, because ${reason}`) + await mreq.mreq("POST", path(`/client/v3/rooms/${roomID}/leave`, mxid), {reason}) +} + /** * @param {string} roomID * @param {string} eventID @@ -404,6 +414,7 @@ module.exports.createRoom = createRoom module.exports.joinRoom = joinRoom module.exports.inviteToRoom = inviteToRoom module.exports.leaveRoom = leaveRoom +module.exports.leaveRoomWithReason = leaveRoomWithReason module.exports.getEvent = getEvent module.exports.getEventForTimestamp = getEventForTimestamp module.exports.getAllState = getAllState diff --git a/src/types.d.ts b/src/types.d.ts index cc33a4a..62adf27 100644 --- a/src/types.d.ts +++ b/src/types.d.ts @@ -281,6 +281,11 @@ export namespace Event { users_default?: number } + export type M_Space_Child = { + via?: string[] + suggested?: boolean + } + export type M_Reaction = { "m.relates_to": { rel_type: "m.annotation" diff --git a/src/web/pug/guild.pug b/src/web/pug/guild.pug index 050e2c1..5a0d2fc 100644 --- a/src/web/pug/guild.pug +++ b/src/web/pug/guild.pug @@ -71,23 +71,24 @@ block body div != svg - h2.mt48.fs-headline1 Matrix setup + if space_id + h2.mt48.fs-headline1 Matrix setup - h3.mt32.fs-category Linked channels + h3.mt32.fs-category Linked channels - .s-card.bs-sm.p0 - form.s-table-container(method="post" action="/api/unlink" hx-confirm="Do you want to unlink these channels?\nIt may take a moment to clean up Matrix resources.") - 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="/api/unlink" hx-trigger="click" hx-disabled-elt="this")!= icons.Icons.IconLinkSm - td: +matrix(row) - else - tr - td(colspan="3") - .s-empty-state No channels linked between Discord and Matrix yet... + .s-card.bs-sm.p0 + form.s-table-container(method="post" action="/api/unlink" hx-confirm="Do you want to unlink these channels?\nIt may take a moment to clean up Matrix resources.") + 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="/api/unlink" hx-trigger="click" hx-disabled-elt="this")!= icons.Icons.IconLinkSm + td: +matrix(row) + else + tr + td(colspan="3") + .s-empty-state No channels linked between Discord and Matrix yet... h3.mt32.fs-category Auto-create .s-card @@ -97,95 +98,96 @@ block body p.s-description If you want, OOYE can automatically create new Matrix rooms and link them when an unlinked Discord channel is spoken in. - let value = !!select("guild_active", "autocreate", {guild_id}).pluck().get() input(type="hidden" name="guild_id" value=guild_id) - input.s-toggle-switch.order-last#autocreate(name="autocreate" type="checkbox" hx-post="/api/autocreate" hx-indicator="#autocreate-loading" hx-disabled-elt="this" checked=value) + input.s-toggle-switch.order-last#autocreate(name="autocreate" type="checkbox" hx-post="/api/autocreate" hx-indicator="#autocreate-loading" hx-disabled-elt="this" checked=value autocomplete="off") #autocreate-loading - h3.mt32.fs-category Privacy level - .s-card - form(hx-post="/api/privacy-level" hx-trigger="change" hx-indicator="#privacy-level-loading" hx-disabled-elt="input") + if space_id + h3.mt32.fs-category Privacy level + .s-card + form(hx-post="/api/privacy-level" hx-trigger="change" hx-indicator="#privacy-level-loading" hx-disabled-elt="input") + input(type="hidden" name="guild_id" value=guild_id) + .d-flex.ai-center.mb4 + label.s-label.fl-grow1 + | How people can join on Matrix + span#privacy-level-loading + .s-toggle-switch.s-toggle-switch__multiple.s-toggle-switch__incremental.d-grid.gx16.ai-center(style="grid-template-columns: auto 1fr") + input(type="radio" name="level" value="directory" id="privacy-level-directory" checked=(privacy_level === 2)) + label.d-flex.gx8.jc-center.grid--row-start3(for="privacy-level-directory") + != icons.Icons.IconPlusSm + != icons.Icons.IconInternationalSm + .fl-grow1 Directory + + input(type="radio" name="level" value="link" id="privacy-level-link" checked=(privacy_level === 1)) + label.d-flex.gx8.jc-center.grid--row-start2(for="privacy-level-link") + != icons.Icons.IconPlusSm + != icons.Icons.IconLinkSm + .fl-grow1 Link + + input(type="radio" name="level" value="invite" id="privacy-level-invite" checked=(privacy_level === 0)) + label.d-flex.gx8.jc-center.grid--row-start1(for="privacy-level-invite") + svg.svg-icon(width="14" height="14" viewBox="0 0 14 14") + != icons.Icons.IconLockSm + .fl-grow1 Invite + + p.s-description.m0 In-app direct invite from another user + p.s-description.m0 Shareable invite links, like Discord + p.s-description.m0 Publicly listed in directory, like Discord server discovery + + h3.mt32.fs-category Manually link channels + form.d-flex.g16.ai-start(hx-post="/api/link" hx-trigger="submit" hx-disabled-elt="input, button" hx-indicator="#link-button") + .fl-grow2.s-btn-group.fd-column.w40 + each channel in unlinkedChannels + input.s-btn--radio(type="radio" name="discord" required id=channel.id value=channel.id) + label.s-btn.s-btn__muted.ta-left.truncate(for=channel.id) + +discord(channel, true, "Announcement") + else + .s-empty-state.p8 All Discord channels are linked. + .fl-grow1.s-btn-group.fd-column.w30 + each room in unlinkedRooms + input.s-btn--radio(type="radio" name="matrix" required id=room.room_id value=room.room_id) + label.s-btn.s-btn__muted.ta-left.truncate(for=room.room_id) + +matrix(room, true) + else + .s-empty-state.p8 All Matrix rooms are linked. input(type="hidden" name="guild_id" value=guild_id) - .d-flex.ai-center.mb4 - label.s-label.fl-grow1 - | How people can join on Matrix - span#privacy-level-loading - .s-toggle-switch.s-toggle-switch__multiple.s-toggle-switch__incremental.d-grid.gx16.ai-center(style="grid-template-columns: auto 1fr") - input(type="radio" name="level" value="directory" id="privacy-level-directory" checked=(privacy_level === 2)) - label.d-flex.gx8.jc-center.grid--row-start3(for="privacy-level-directory") - != icons.Icons.IconPlusSm - != icons.Icons.IconInternationalSm - .fl-grow1 Directory - - input(type="radio" name="level" value="link" id="privacy-level-link" checked=(privacy_level === 1)) - label.d-flex.gx8.jc-center.grid--row-start2(for="privacy-level-link") - != icons.Icons.IconPlusSm - != icons.Icons.IconLinkSm - .fl-grow1 Link - - input(type="radio" name="level" value="invite" id="privacy-level-invite" checked=(privacy_level === 0)) - label.d-flex.gx8.jc-center.grid--row-start1(for="privacy-level-invite") - svg.svg-icon(width="14" height="14" viewBox="0 0 14 14") - != icons.Icons.IconLockSm - .fl-grow1 Invite - - p.s-description.m0 In-app direct invite from another user - p.s-description.m0 Shareable invite links, like Discord - p.s-description.m0 Publicly listed in directory, like Discord server discovery - - h3.mt32.fs-category Manually link channels - form.d-flex.g16.ai-start(hx-post="/api/link" hx-trigger="submit" hx-disabled-elt="input, button" hx-indicator="#link-button") - .fl-grow2.s-btn-group.fd-column.w40 - each channel in unlinkedChannels - input.s-btn--radio(type="radio" name="discord" required id=channel.id value=channel.id) - label.s-btn.s-btn__muted.ta-left.truncate(for=channel.id) - +discord(channel, true, "Announcement") - else - .s-empty-state.p8 All Discord channels are linked. - .fl-grow1.s-btn-group.fd-column.w30 - each room in unlinkedRooms - input.s-btn--radio(type="radio" name="matrix" required id=room.room_id value=room.room_id) - label.s-btn.s-btn__muted.ta-left.truncate(for=room.room_id) - +matrix(room, true) - else - .s-empty-state.p8 All Matrix rooms are linked. - input(type="hidden" name="guild_id" value=guild_id) - div - button.s-btn.s-btn__icon.s-btn__filled#link-button - != icons.Icons.IconMerge - = ` Link` - - details.mt48 - summary Debug room list - .d-grid.grid__2.gx24 div - h3.mt24 Channels - p Channels are read from the channel_room table and then merged with the discord.channels memory cache to make the merged list. Anything in memory cache that's not in channel_room is considered unlinked. - div - h3.mt24 Rooms - p Rooms use the same merged list as channels, based on augmented channel_room data. Then, rooms are read from the space. Anything in the space that's not merged is considered unlinked. - div - h3.mt24 Unavailable channels: Deleted from Discord - .s-card.p0 - ul.my8.ml24 - each row in removedUncachedChannels - li: a(href=`https://discord.com/channels/${guild_id}/${row.channel_id}`)= row.nick || row.name - h3.mt24 Unavailable channels: Wrong type - .s-card.p0 - ul.my8.ml24 - each row in removedWrongTypeChannels - li: a(href=`https://discord.com/channels/${guild_id}/${row.channel_id}`) (#{row.type}) #{row.name} - div- // Rooms - h3.mt24 Unavailable rooms: Already linked - .s-card.p0 - ul.my8.ml24 - each row in removedLinkedRooms - li: a(href=`https://matrix.to/#/${row.room_id}`)= row.name - h3.mt24 Unavailable rooms: Wrong type - .s-card.p0 - ul.my8.ml24 - each row in removedWrongTypeRooms - li: a(href=`https://matrix.to/#/${row.room_id}`) (#{row.room_type}) #{row.name} - h3.mt24 Unavailable rooms: Archived thread - .s-card.p0 - ul.my8.ml24 - each row in removedArchivedThreadRooms - li: a(href=`https://matrix.to/#/${row.room_id}`)= row.name + button.s-btn.s-btn__icon.s-btn__filled#link-button + != icons.Icons.IconMerge + = ` Link` + + details.mt48 + summary Debug room list + .d-grid.grid__2.gx24 + div + h3.mt24 Channels + p Channels are read from the channel_room table and then merged with the discord.channels memory cache to make the merged list. Anything in memory cache that's not in channel_room is considered unlinked. + div + h3.mt24 Rooms + p Rooms use the same merged list as channels, based on augmented channel_room data. Then, rooms are read from the space. Anything in the space that's not merged is considered unlinked. + div + h3.mt24 Unavailable channels: Deleted from Discord + .s-card.p0 + ul.my8.ml24 + each row in removedUncachedChannels + li: a(href=`https://discord.com/channels/${guild_id}/${row.channel_id}`)= row.nick || row.name + h3.mt24 Unavailable channels: Wrong type + .s-card.p0 + ul.my8.ml24 + each row in removedWrongTypeChannels + li: a(href=`https://discord.com/channels/${guild_id}/${row.channel_id}`) (#{row.type}) #{row.name} + div- // Rooms + h3.mt24 Unavailable rooms: Already linked + .s-card.p0 + ul.my8.ml24 + each row in removedLinkedRooms + li: a(href=`https://matrix.to/#/${row.room_id}`)= row.name + h3.mt24 Unavailable rooms: Wrong type + .s-card.p0 + ul.my8.ml24 + each row in removedWrongTypeRooms + li: a(href=`https://matrix.to/#/${row.room_id}`) (#{row.room_type}) #{row.name} + h3.mt24 Unavailable rooms: Archived thread + .s-card.p0 + ul.my8.ml24 + each row in removedArchivedThreadRooms + li: a(href=`https://matrix.to/#/${row.room_id}`)= row.name diff --git a/src/web/pug/guild_access_denied.pug b/src/web/pug/guild_access_denied.pug index 6283b49..319d4de 100644 --- a/src/web/pug/guild_access_denied.pug +++ b/src/web/pug/guild_access_denied.pug @@ -1,7 +1,7 @@ extends includes/template.pug block body - if !managed + if !session.data.user_id .s-empty-state.wmx4.p48 != icons.Spots.SpotEmptyXL p You need to log in to manage your servers. diff --git a/src/web/pug/guild_not_linked.pug b/src/web/pug/guild_not_linked.pug new file mode 100644 index 0000000..2eade34 --- /dev/null +++ b/src/web/pug/guild_not_linked.pug @@ -0,0 +1,52 @@ +extends includes/template.pug + +mixin space(space) + .s-user-card.flex__1 + span.s-avatar.s-avatar__32.s-user-card--avatar + if space.avatar + img.s-avatar--image(src=mUtils.getPublicUrlForMxc(space.avatar)) + else + .s-avatar--letter.bg-silver-400.bar-md(aria-hidden="true")= space.name[0] + .s-user-card--info.ai-start + strong= space.name + if space.topic + ul.s-user-card--awards + li space.topic + +block body + .s-notice.s-notice__info.d-flex.g16 + div + != icons.Icons.IconInfo + div + strong You picked self-service mode + .mt4 To complete setup, you need to manually choose a Matrix space to link with #[strong= guild.name]. + + h3.mt32.fs-category Choose a space + + form.s-card.bs-sm.p0.s-table-container.bar-md(method="post" action="/api/link-space") + input(type="hidden" name="guild_id" value=guild_id) + table.s-table.s-table__bx-simple + each space in spaces + tr + td.p0: +space(space) + td: button.s-btn(name="space_id" value=space.room_id hx-post="/api/link-space" hx-trigger="click" hx-disabled-elt="this") Link with this space + else + if session.data.mxid + tr + - const self = `@${reg.sender_localpart}:${reg.ooye.server_name}` + td.p16 On Matrix, invite #[code.s-code-block: a.s-link(href=`https://matrix.to/#/${self}` target="_blank")= self] to a space. Then you can pick it from this list. + else + tr + td.d-flex.ai-center.pl16.g16 + | You need to log in with Matrix first. + a.s-btn.s-btn__matrix.s-btn__outlined(href=rel("/log-in-with-matrix")) Log in with Matrix + + h3.mt48.fs-category Auto-create + .s-card + form.d-flex.ai-center.g8(method="post" action="/api/autocreate" hx-post="/api/autocreate" hx-indicator="#easy-mode-button") + input(type="hidden" name="guild_id" value=guild_id) + input(type="hidden" name="autocreate" value="true") + label.s-label.fl-grow1 + | Changed your mind? + p.s-description If you want, OOYE can create and manage the Matrix space so you don't have to. + button.s-btn.s-btn__outlined#easy-mode-button Use easy mode diff --git a/src/web/routes/guild-settings.js b/src/web/routes/guild-settings.js index 3715266..31bd10f 100644 --- a/src/web/routes/guild-settings.js +++ b/src/web/routes/guild-settings.js @@ -2,9 +2,9 @@ const assert = require("assert/strict") const {z} = require("zod") -const {defineEventHandler, useSession, createError, readValidatedBody} = require("h3") +const {defineEventHandler, useSession, createError, readValidatedBody, getRequestHeader, setResponseHeader, sendRedirect} = require("h3") -const {as, db, sync} = require("../../passthrough") +const {as, db, sync, select} = require("../../passthrough") const {reg} = require("../../matrix/read-registration") /** @type {import("../../d2m/actions/create-space")} */ @@ -29,6 +29,17 @@ as.router.post("/api/autocreate", defineEventHandler(async event => { if (!(session.data.managedGuilds || []).concat(session.data.matrixGuilds || []).includes(parsedBody.guild_id)) throw createError({status: 403, message: "Forbidden", data: "Can't change settings for a guild you don't have Manage Server permissions in"}) db.prepare("UPDATE guild_active SET autocreate = ? WHERE guild_id = ?").run(+!!parsedBody.autocreate, parsedBody.guild_id) + + // If showing a partial page due to incomplete setup, need to refresh the whole page to show the alternate version + const spaceID = select("guild_space", "space_id", {guild_id: parsedBody.guild_id}).pluck().get() + if (!spaceID) { + if (getRequestHeader(event, "HX-Request")) { + setResponseHeader(event, "HX-Refresh", "true") + } else { + return sendRedirect(event, "", 302) + } + } + return null // 204 })) diff --git a/src/web/routes/guild.js b/src/web/routes/guild.js index 3d649c5..ff645a8 100644 --- a/src/web/routes/guild.js +++ b/src/web/routes/guild.js @@ -8,7 +8,7 @@ const {LRUCache} = require("lru-cache") const Ty = require("../../types") const uqr = require("uqr") -const {discord, as, sync, select} = require("../../passthrough") +const {discord, as, sync, select, from, db} = require("../../passthrough") /** @type {import("../pug-sync")} */ const pugSync = sync.require("../pug-sync") /** @type {import("../../d2m/actions/create-space")} */ @@ -109,15 +109,21 @@ function getChannelRoomsLinks(guildID, rooms) { as.router.get("/guild", defineEventHandler(async event => { const {guild_id} = await getValidatedQuery(event, schema.guild.parse) const session = await useSession(event, {password: reg.as_token}) - const row = select("guild_space", ["space_id", "privacy_level"], {guild_id}).get() + const row = from("guild_active").join("guild_space", "guild_id", "left").select("space_id", "privacy_level", "autocreate").where({guild_id}).get() // @ts-ignore const guild = discord.guilds.get(guild_id) // Permission problems - if (!guild_id || !guild || !(session.data.managedGuilds || []).concat(session.data.matrixGuilds || []).includes(guild_id)) { + if (!guild_id || !guild || !(session.data.managedGuilds || []).concat(session.data.matrixGuilds || []).includes(guild_id) || !row) { return pugSync.render(event, "guild_access_denied.pug", {guild_id}) } + // Self-service guild that hasn't been linked yet - needs a special page encouraging the link flow + if (!row.space_id && row.autocreate === 0) { + const spaces = db.prepare("SELECT room_id, type, name, avatar FROM invite LEFT JOIN guild_space ON invite.room_id = guild_space.space_id WHERE mxid = ? AND space_id IS NULL and type = 'm.space'").all(session.data.mxid) + return pugSync.render(event, "guild_not_linked.pug", {guild, guild_id, spaces}) + } + const nonce = randomUUID() validNonce.set(nonce, guild_id) @@ -128,10 +134,10 @@ as.router.get("/guild", defineEventHandler(async event => { const svg = generatedSvg.replace(/viewBox="0 0 ([0-9]+) ([0-9]+)"/, `data-nonce="${nonce}" width="$1" height="$2" $&`) assert.notEqual(svg, generatedSvg) - // Unlinked guild - if (!row) { + // Easy mode guild that hasn't been linked yet - need to remove elements that would require an existing space + if (!row.space_id) { const links = getChannelRoomsLinks(guild_id, []) - return pugSync.render(event, "guild.pug", {guild, guild_id, svg, ...links}) + return pugSync.render(event, "guild.pug", {guild, guild_id, svg, ...links, ...row}) } // Linked guild diff --git a/src/web/routes/guild.test.js b/src/web/routes/guild.test.js index a95cbea..3ef177e 100644 --- a/src/web/routes/guild.test.js +++ b/src/web/routes/guild.test.js @@ -18,6 +18,7 @@ test("web guild: access denied when not logged in", async t => { test("web guild: asks to select guild if not selected", async t => { const content = await router.test("get", "/guild", { sessionData: { + user_id: "1", managedGuilds: [] }, }) @@ -27,6 +28,7 @@ test("web guild: asks to select guild if not selected", async t => { test("web guild: access denied when guild id messed up", async t => { const content = await router.test("get", "/guild?guild_id=1", { sessionData: { + user_id: "1", managedGuilds: [] }, }) @@ -43,6 +45,7 @@ test("web invite: access denied with invalid nonce", async t => { test("web guild: can view unbridged guild", async t => { const content = await router.test("get", "/guild?guild_id=66192955777486848", { sessionData: { + user_id: "1", managedGuilds: ["66192955777486848"] }, api: { diff --git a/src/web/routes/link.js b/src/web/routes/link.js index 6f04e69..1e6a150 100644 --- a/src/web/routes/link.js +++ b/src/web/routes/link.js @@ -16,6 +16,10 @@ const {reg} = require("../../matrix/read-registration") const api = sync.require("../../matrix/api") const schema = { + linkSpace: z.object({ + guild_id: z.string(), + space_id: z.string() + }), link: z.object({ guild_id: z.string(), matrix: z.string(), @@ -27,6 +31,48 @@ const schema = { }) } +as.router.post("/api/link-space", defineEventHandler(async event => { + const parsedBody = await readValidatedBody(event, schema.linkSpace.parse) + const session = await useSession(event, {password: reg.as_token}) + + // Check guild ID + const guildID = parsedBody.guild_id + if (!(session.data.managedGuilds || []).concat(session.data.matrixGuilds || []).includes(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"}) + const spaceID = parsedBody.space_id + const inviteType = select("invite", "type", {mxid: session.data.mxid, room_id: spaceID}).pluck().get() + if (inviteType !== "m.space") throw createError({status: 403, message: "Forbidden", data: "No past invitations detected from your Matrix account for that space."}) + + // Check they are not already bridged + const existing = select("guild_space", "guild_id", {}, "WHERE guild_id = ? OR space_id = ?").get(guildID, spaceID) + if (existing) throw createError({status: 400, message: "Bad Request", data: `Guild ID ${guildID} or space ID ${spaceID} are already bridged and cannot be reused`}) + + // Check space exists and bridge is joined and bridge has PL 100 + const self = `@${reg.sender_localpart}:${reg.ooye.server_name}` + /** @type {Ty.Event.M_Room_Member} */ + const memberEvent = await api.getStateEvent(spaceID, "m.room.member", self) + if (memberEvent.membership !== "join") throw createError({status: 400, message: "Bad Request", data: "Matrix space does not exist"}) + /** @type {Ty.Event.M_Power_Levels} */ + const powerLevelsStateContent = await api.getStateEvent(spaceID, "m.room.power_levels", "") + const selfPowerLevel = powerLevelsStateContent.users?.[self] || powerLevelsStateContent.users_default || 0 + if (selfPowerLevel < (powerLevelsStateContent.state_default || 50) || selfPowerLevel < 100) throw createError({status: 400, message: "Bad Request", data: "OOYE needs power level 100 (admin) in the target Matrix space"}) + + // Check inviting user is a moderator in the space + const invitingPowerLevel = powerLevelsStateContent.users?.[session.data.mxid] || powerLevelsStateContent.users_default || 0 + if (invitingPowerLevel < (powerLevelsStateContent.state_default || 50)) throw createError({status: 403, message: "Forbidden", data: `You need to be at least power level 50 (moderator) in the target Matrix space to use OOYE, but you are currently power level ${invitingPowerLevel}.`}) + + // Insert database entry + db.transaction(() => { + db.prepare("INSERT INTO guild_space (guild_id, space_id) VALUES (?, ?)").run(guildID, spaceID) + db.prepare("DELETE FROM invite WHERE room_id = ?").run(spaceID) + })() + + setResponseHeader(event, "HX-Refresh", "true") + return null // 204 +})) + as.router.post("/api/link", defineEventHandler(async event => { const parsedBody = await readValidatedBody(event, schema.link.parse) const session = await useSession(event, {password: reg.as_token}) diff --git a/src/web/server.js b/src/web/server.js index 7199c88..6c2d087 100644 --- a/src/web/server.js +++ b/src/web/server.js @@ -7,15 +7,18 @@ const {defineEventHandler, defaultContentType, getRequestHeader, setResponseHead const icons = require("@stackoverflow/stacks-icons") const DiscordTypes = require("discord-api-types/v10") const dUtils = require("../discord/utils") +const reg = require("../matrix/read-registration") const {sync, discord, as, select} = require("../passthrough") /** @type {import("./pug-sync")} */ const pugSync = sync.require("./pug-sync") +/** @type {import("../m2d/converters/utils")} */ +const mUtils = sync.require("../m2d/converters/utils") const {id} = require("../../addbot") // Pug -pugSync.addGlobals({id, h3, discord, select, DiscordTypes, dUtils, icons}) +pugSync.addGlobals({id, h3, discord, select, DiscordTypes, dUtils, mUtils, icons, reg: reg.reg}) pugSync.createRoute(as.router, "/", "home.pug") pugSync.createRoute(as.router, "/ok", "ok.pug")