Compare commits

..

6 commits

8 changed files with 191 additions and 41 deletions

6
.editorconfig Normal file
View file

@ -0,0 +1,6 @@
[*]
indent_style = tab
[*.pug]
indent_style = space
indent_size = 2

View file

@ -146,7 +146,7 @@ async function syncUser(messageID, author, roomID, shouldActuallySync) {
try { try {
// API lookup // API lookup
var pkMessage = await fetchMessage(messageID) var pkMessage = await fetchMessage(messageID)
db.prepare("REPLACE INTO sim_proxy (user_id, proxy_owner_id, displayname) VALUES (?, ?, ?)").run(pkMessage.member.uuid, pkMessage.sender, author.username) db.prepare("INSERT OR IGNORE INTO sim_proxy (user_id, proxy_owner_id, displayname) VALUES (?, ?, ?)").run(pkMessage.member.uuid, pkMessage.sender, author.username)
} catch (e) { } catch (e) {
// Fall back to offline cache // Fall back to offline cache
const senderMxid = from("sim_proxy").join("sim", "user_id").join("sim_member", "mxid").where({displayname: author.username, room_id: roomID}).pluck("mxid").get() const senderMxid = from("sim_proxy").join("sim", "user_id").join("sim_member", "mxid").where({displayname: author.username, room_id: roomID}).pluck("mxid").get()

View file

@ -33,10 +33,9 @@ function getDiscordParseCallbacks(message, guild, useHTML) {
user: node => { user: node => {
const mxid = select("sim", "mxid", {user_id: node.id}).pluck().get() const mxid = select("sim", "mxid", {user_id: node.id}).pluck().get()
const interaction = message.interaction_metadata || message.interaction const interaction = message.interaction_metadata || message.interaction
const username = message.mentions?.find(ment => ment.id === node.id)?.username const username = message.mentions.find(ment => ment.id === node.id)?.username
|| message.referenced_message?.mentions?.find(ment => ment.id === node.id)?.username || message.referenced_message?.mentions.find(ment => ment.id === node.id)?.username
|| (interaction?.user.id === node.id ? interaction.user.username : null) || (interaction?.user.id === node.id ? interaction.user.username : null)
|| (message.author.id === node.id ? message.author.username : null)
|| node.id || node.id
if (mxid && useHTML) { if (mxid && useHTML) {
return `<a href="https://matrix.to/#/${mxid}">@${username}</a>` return `<a href="https://matrix.to/#/${mxid}">@${username}</a>`

View file

@ -24,3 +24,7 @@ test("api path: real world mxid", t => {
test("api path: extras number works", 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") 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")
})

View file

@ -54,6 +54,10 @@ block body
.s-page-title.mb24 .s-page-title.mb24
h1.s-page-title--header= guild.name 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") .d-flex.g16(class="sm:fw-wrap")
.fl-grow1 .fl-grow1
h2.fs-headline1 Invite a Matrix user h2.fs-headline1 Invite a Matrix user

View file

@ -12,6 +12,8 @@ const auth = sync.require("../auth")
const mreq = sync.require("../../matrix/mreq") const mreq = sync.require("../../matrix/mreq")
const {reg} = require("../../matrix/read-registration") const {reg} = require("../../matrix/read-registration")
const me = `@${reg.sender_localpart}:${reg.ooye.server_name}`
/** /**
* @param {H3Event} event * @param {H3Event} event
* @returns {import("../../matrix/api")} * @returns {import("../../matrix/api")}
@ -39,6 +41,60 @@ function getCreateSpace(event) {
return event.context.createSpace || sync.require("../../d2m/actions/create-space") 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<DiscordTypes.APIGuild & {members: DiscordTypes.APIGuildMember[]}>}
*/
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
* @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 = { const schema = {
linkSpace: z.object({ linkSpace: z.object({
guild_id: z.string(), guild_id: z.string(),
@ -52,18 +108,20 @@ const schema = {
unlink: z.object({ unlink: z.object({
guild_id: z.string(), guild_id: z.string(),
channel_id: z.string() channel_id: z.string()
}) }),
unlinkSpace: z.object({
guild_id: z.string(),
}),
} }
as.router.post("/api/link-space", defineEventHandler(async event => { as.router.post("/api/link-space", defineEventHandler(async event => {
const parsedBody = await readValidatedBody(event, schema.linkSpace.parse) const parsedBody = await readValidatedBody(event, schema.linkSpace.parse)
const session = await auth.useSession(event) const session = await auth.useSession(event)
const managed = await auth.getManagedGuilds(event)
const api = getAPI(event) const api = getAPI(event)
// Check guild ID // Check guild ID
const guildID = parsedBody.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 // 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"}) 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"})
@ -83,7 +141,6 @@ as.router.post("/api/link-space", defineEventHandler(async event => {
} }
// Check bridge has PL 100 // Check bridge has PL 100
const me = `@${reg.sender_localpart}:${reg.ooye.server_name}`
/** @type {Ty.Event.M_Power_Levels?} */ /** @type {Ty.Event.M_Power_Levels?} */
let powerLevelsStateContent = null let powerLevelsStateContent = null
try { try {
@ -108,18 +165,12 @@ as.router.post("/api/link-space", defineEventHandler(async event => {
as.router.post("/api/link", defineEventHandler(async event => { as.router.post("/api/link", defineEventHandler(async event => {
const parsedBody = await readValidatedBody(event, schema.link.parse) const parsedBody = await readValidatedBody(event, schema.link.parse)
const managed = await auth.getManagedGuilds(event)
const api = getAPI(event) const api = getAPI(event)
const createRoom = getCreateRoom(event) const createRoom = getCreateRoom(event)
const createSpace = getCreateSpace(event) const createSpace = getCreateSpace(event)
// Check guild ID or nonce
const guildID = parsedBody.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"}) const guild = await validateGuildAccess(event, guildID)
// 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) const spaceID = await createSpace.ensureSpace(guild)
// Check channel exists // Check channel exists
@ -183,33 +234,44 @@ as.router.post("/api/link", defineEventHandler(async event => {
as.router.post("/api/unlink", defineEventHandler(async event => { as.router.post("/api/unlink", defineEventHandler(async event => {
const {channel_id, guild_id} = await readValidatedBody(event, schema.unlink.parse) const {channel_id, guild_id} = await readValidatedBody(event, schema.unlink.parse)
const managed = await auth.getManagedGuilds(event) await validateGuildAccess(event, guild_id)
const createRoom = getCreateRoom(event)
// Check guild ID or nonce await doRoomUnlink(event, channel_id, guild_id)
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"})
setResponseHeader(event, "HX-Refresh", "true")
// Check guild exists return null // 204
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"})
as.router.post("/api/unlink-space", defineEventHandler(async event => {
// Check that the channel (if it exists) is part of this guild const {guild_id} = await readValidatedBody(event, schema.unlinkSpace.parse)
/** @type {any} */ const api = getAPI(event)
let channel = discord.channels.get(channel_id) await validateGuildAccess(event, guild_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}`}) const spaceID = select("guild_space", "space_id", {guild_id: guild_id}).pluck().get()
} else { if (!spaceID)
// Otherwise, if the channel isn't cached, it must have been deleted. throw createError({status: 400, message: "Bad Request", data: "Matrix space does not exist or bot has not linked it"})
// 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} const linkedChannels = select("channel_room", ["channel_id", "room_id", "name", "nick"], {guild_id: guild_id}).all()
}
for (const channel of linkedChannels) {
// Check channel is currently bridged await doRoomUnlink(event, channel.channel_id, guild_id)
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`})
const remainingLinkedChannels = select("channel_room", ["channel_id", "room_id", "name", "nick"], {guild_id: guild_id}).all()
// Do it if (remainingLinkedChannels.length !== 0)
await createRoom.unbridgeDeletedChannel(channel, guild_id) throw createError({status: 400, message: "Bad Request", 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") setResponseHeader(event, "HX-Refresh", "true")
return null // 204 return null // 204

View file

@ -630,3 +630,76 @@ test("web unlink room: checks that the channel is bridged", async t => {
})) }))
t.equal(error.data, "Channel ID 665310973967597573 is not currently bridged") t.equal(error.data, "Channel ID 665310973967597573 is not currently bridged")
}) })
// *****
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", 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: successfully calls unbridgeDeletedChannel on linked channels in space", async t => {
// Need to re-link the room to check it is properly unlinked by the unlink-space
await router.test("post", "/api/link", {
sessionData: {
managedGuilds: ["665289423482519565"]
},
body: {
discord: "665310973967597573",
matrix: "!NDbIqNpJyPvfKRnNcr:cadence.moe",
guild_id: "665289423482519565"
},
})
let called = 0
await router.test("post", "/api/unlink-space", {
sessionData: {
managedGuilds: ["665289423482519565"]
},
body: {
guild_id: "665289423482519565",
},
createRoom: {
async unbridgeDeletedChannel(channel) {
called++
t.equal(channel.id, "665310973967597573")
}
}
})
t.equal(called, 1)
})

View file

@ -17,6 +17,8 @@ const {reg} = require("../src/matrix/read-registration")
reg.ooye.discord_token = "Njg0MjgwMTkyNTUzODQ0NzQ3.Xl3zlw.baby" 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_origin = "https://matrix.cadence.moe" // so that tests will pass even when hard-coded
reg.ooye.server_name = "cadence.moe" reg.ooye.server_name = "cadence.moe"
reg.ooye.namespace_prefix = "_ooye_"
reg.sender_localpart = "_ooye_bot"
reg.id = "baby" reg.id = "baby"
reg.as_token = "don't actually take authenticated actions on the server" reg.as_token = "don't actually take authenticated actions on the server"
reg.hs_token = "don't actually take authenticated actions on the server" reg.hs_token = "don't actually take authenticated actions on the server"