Compare commits

...

5 commits

Author SHA1 Message Date
48e2080b94 Fill in more of reg for other people to test with 2025-08-23 23:46:51 +12:00
6734c15ca2 Fix /api/link-space joinRoom() for remote spaces 2025-08-23 00:10:35 +09:00
4ae0e239da Update tests for new types and code path 2025-08-22 21:42:13 +12:00
743fec6e69 Cleanup 2025-08-22 21:26:47 +12:00
131d4b5a21 Fix matrix api joinRoom() for remote rooms
When using self-service mode and trying to link with a remote matrix
room (room not in the same HS as the bridge user), then we need to add
the "via" HSs to join the room with, or else it fails.

We get it from the "m.space.child" in the "children_state" of the space
hierarchy.

It seems like the "via" information can also be stored in the
"m.space.parent" in the states of the room, but hopefully this shouldn't
be needed in sane implementations
2025-08-21 22:11:12 +09:00
6 changed files with 116 additions and 16 deletions

View file

@ -22,7 +22,11 @@ function path(p, mxid, otherParams = {}) {
const u = new URL(p, "http://localhost")
if (mxid) u.searchParams.set("user_id", mxid)
for (const entry of Object.entries(otherParams)) {
if (entry[1] != undefined) {
if (Array.isArray(entry[1])) {
for (const element of entry[1]) {
u.searchParams.append(entry[0], element)
}
} else if (entry[1] != undefined) {
u.searchParams.set(entry[0], entry[1])
}
}
@ -62,11 +66,14 @@ async function createRoom(content) {
}
/**
* @param {string} roomIDOrAlias
* @param {string?} [mxid]
* @param {string[]?} [via]
* @returns {Promise<string>} room ID
*/
async function joinRoom(roomIDOrAlias, mxid) {
async function joinRoom(roomIDOrAlias, mxid, via) {
/** @type {Ty.R.RoomJoined} */
const root = await mreq.mreq("POST", path(`/client/v3/join/${roomIDOrAlias}`, mxid), {})
const root = await mreq.mreq("POST", path(`/client/v3/join/${roomIDOrAlias}`, mxid, {via}), {})
return root.room_id
}

View file

@ -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")
})

10
src/types.d.ts vendored
View file

@ -148,6 +148,14 @@ export namespace Event {
prev_content?: any
}
export type Outer_StrippedChildStateEvent = {
type: string
state_key: string
sender: string
origin_server_ts: number
content: any
}
export type M_Room_Message = {
msgtype: "m.text" | "m.emote"
body: string
@ -344,7 +352,7 @@ export namespace R {
export type Hierarchy = {
avatar_url?: string
canonical_alias?: string
children_state: {}
children_state: Event.Outer_StrippedChildStateEvent[]
guest_can_join: boolean
join_rule?: string
name?: string

View file

@ -12,6 +12,20 @@ const auth = sync.require("../auth")
const mreq = sync.require("../../matrix/mreq")
const {reg} = require("../../matrix/read-registration")
/**
* @param {string} UserID
* @returns {string} the HS of the user, or "" if the user ID is malformed
*/
function getHSOfUser(user) {
domainStartIndex = user.indexOf(":");
if (domainStartIndex >= 1) {
return user.slice(domainStartIndex + 1)
}
return ""
}
/**
* @param {H3Event} event
* @returns {import("../../matrix/api")}
@ -75,9 +89,12 @@ as.router.post("/api/link-space", defineEventHandler(async event => {
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`})
const inviteSender = select("invite", "mxid", {mxid: session.data.mxid, room_id: spaceID}).pluck().get()
via = [ getHSOfUser(inviteSender) ]
// Check space exists and bridge is joined
try {
await api.joinRoom(parsedBody.space_id)
await api.joinRoom(parsedBody.space_id, null, via)
} catch (e) {
throw createError({status: 403, message: e.errcode, data: `${e.errcode} - ${e.message}`})
}
@ -134,19 +151,33 @@ as.router.post("/api/link", defineEventHandler(async event => {
if (row) throw createError({status: 400, message: "Bad Request", data: `Channel ID ${row.channel_id} or room ID ${parsedBody.matrix} are already bridged and cannot be reused`})
// Check room is part of the guild's space
let found = false
let foundRoom = false
/** @type {string[]?} */
let foundVia = null
for await (const room of api.generateFullHierarchy(spaceID)) {
// When finding a space during iteration, look at space's children state, because we need a `via` to join the room (when we find it later)
for (const state of room.children_state) {
if (state.type === "m.space.child" && state.state_key === parsedBody.matrix) {
foundVia = state.content.via
}
}
// When finding a room during iteration, see if it was the requested room (to confirm that the room is in the space)
if (room.room_id === parsedBody.matrix && !room.room_type) {
found = true
break
foundRoom = true
}
if (foundRoom && foundVia) break
}
if (!found) throw createError({status: 400, message: "Bad Request", data: "Matrix room needs to be part of the bridged space"})
if (!foundRoom) throw createError({status: 400, message: "Bad Request", data: "Matrix room needs to be part of the bridged space"})
// Check room exists and bridge is joined
try {
await api.joinRoom(parsedBody.matrix)
await api.joinRoom(parsedBody.matrix, null, foundVia)
} catch (e) {
if (!foundVia) {
throw createError({status: 403, message: "Unable To Join", data: `Unable to join the requested Matrix room. Please invite the bridge to the room and try again. (Server said: ${e.errcode} - ${e.message})`})
}
throw createError({status: 403, message: e.errcode, data: `${e.errcode} - ${e.message}`})
}

View file

@ -360,7 +360,7 @@ test("web link room: check that room is part of space (not in hierarchy)", async
t.equal(called, 1)
})
test("web link room: check that bridge can join room", async t => {
test("web link room: check that bridge can join room (notices lack of via and asks for invite instead)", async t => {
let called = 0
const [error] = await tryToCatch(() => router.test("post", "/api/link", {
sessionData: {
@ -381,7 +381,55 @@ test("web link room: check that bridge can join room", async t => {
t.equal(spaceID, "!zTMspHVUBhFLLSdmnS:cadence.moe")
yield {
room_id: "!NDbIqNpJyPvfKRnNcr:cadence.moe",
children_state: {},
children_state: [],
guest_can_join: false,
num_joined_members: 2
}
/* c8 ignore next */
}
}
}))
t.equal(error.data, "Unable to join the requested Matrix room. Please invite the bridge to the room and try again. (Server said: M_FORBIDDEN - not allowed to join I guess)")
t.equal(called, 2)
})
test("web link room: check that bridge can join room (uses via for join attempt)", async t => {
let called = 0
const [error] = await tryToCatch(() => router.test("post", "/api/link", {
sessionData: {
managedGuilds: ["665289423482519565"]
},
body: {
discord: "665310973967597573",
matrix: "!NDbIqNpJyPvfKRnNcr:cadence.moe",
guild_id: "665289423482519565"
},
api: {
async joinRoom(roomID, _, via) {
called++
t.deepEqual(via, ["cadence.moe", "hashi.re"])
throw new MatrixServerError({errcode: "M_FORBIDDEN", error: "not allowed to join I guess"})
},
async *generateFullHierarchy(spaceID) {
called++
t.equal(spaceID, "!zTMspHVUBhFLLSdmnS:cadence.moe")
yield {
room_id: "!NDbIqNpJyPvfKRnNcr:cadence.moe",
children_state: [],
guest_can_join: false,
num_joined_members: 2
}
yield {
room_id: "!zTMspHVUBhFLLSdmnS:cadence.moe",
children_state: [{
type: "m.space.child",
state_key: "!NDbIqNpJyPvfKRnNcr:cadence.moe",
sender: "@elliu:hashi.re",
content: {
via: ["cadence.moe", "hashi.re"]
},
origin_server_ts: 0
}],
guest_can_join: false,
num_joined_members: 2
}
@ -414,7 +462,7 @@ test("web link room: check that bridge has PL 100 in target room (event missing)
t.equal(spaceID, "!zTMspHVUBhFLLSdmnS:cadence.moe")
yield {
room_id: "!NDbIqNpJyPvfKRnNcr:cadence.moe",
children_state: {},
children_state: [],
guest_can_join: false,
num_joined_members: 2
}
@ -454,7 +502,7 @@ test("web link room: check that bridge has PL 100 in target room (users default)
t.equal(spaceID, "!zTMspHVUBhFLLSdmnS:cadence.moe")
yield {
room_id: "!NDbIqNpJyPvfKRnNcr:cadence.moe",
children_state: {},
children_state: [],
guest_can_join: false,
num_joined_members: 2
}
@ -494,7 +542,7 @@ test("web link room: successfully calls createRoom", async t => {
t.equal(spaceID, "!zTMspHVUBhFLLSdmnS:cadence.moe")
yield {
room_id: "!NDbIqNpJyPvfKRnNcr:cadence.moe",
children_state: {},
children_state: [],
guest_can_join: false,
num_joined_members: 2
}

View file

@ -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"