mirror of
https://github.com/djdevin/recflare.git
synced 2026-10-25 23:18:51 -07:00
[match] fix issue where private room subroom join was not marked as private
This commit is contained in:
+43
-11
@@ -750,6 +750,17 @@ function persistenceVersionRefusal(
|
|||||||
*/
|
*/
|
||||||
const ROOM_IS_PRIVATE = MatchmakingErrorCode.RoomIsPrivate
|
const ROOM_IS_PRIVATE = MatchmakingErrorCode.RoomIsPrivate
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether a room is PUBLISHED — Public, or Unlisted (reachable by whoever holds the id,
|
||||||
|
* just not listed). Every other accessibility (Private, and the two Dev_ ones) is a room
|
||||||
|
* its owner hasn't released.
|
||||||
|
*/
|
||||||
|
function isPublished(room: Room): boolean {
|
||||||
|
return (
|
||||||
|
room.Accessibility === Accessibility.Public || room.Accessibility === Accessibility.Unlisted
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Whether a room MATCHMAKE may enter this room at all, before any instance is found or
|
* Whether a room MATCHMAKE may enter this room at all, before any instance is found or
|
||||||
* made. A PUBLISHED room — Public, or Unlisted (reachable by whoever holds the id, just
|
* made. A PUBLISHED room — Public, or Unlisted (reachable by whoever holds the id, just
|
||||||
@@ -768,12 +779,7 @@ const ROOM_IS_PRIVATE = MatchmakingErrorCode.RoomIsPrivate
|
|||||||
* their guests through the invite routes, not this one.
|
* their guests through the invite routes, not this one.
|
||||||
*/
|
*/
|
||||||
async function canEnterRoom(db: D1Database, room: Room, accountId: number): Promise<boolean> {
|
async function canEnterRoom(db: D1Database, room: Room, accountId: number): Promise<boolean> {
|
||||||
if (
|
if (isPublished(room)) return true
|
||||||
room.Accessibility === Accessibility.Public ||
|
|
||||||
room.Accessibility === Accessibility.Unlisted
|
|
||||||
) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
if (room.CreatorAccountId === accountId) return true
|
if (room.CreatorAccountId === accountId) return true
|
||||||
if (roomRoles(room).some((r) => r.AccountId === accountId && r.Role !== Role.None)) return true
|
if (roomRoles(room).some((r) => r.AccountId === accountId && r.Role !== Role.None)) return true
|
||||||
return hasRoomInviteTo(db, accountId, Number(room.RoomId))
|
return hasRoomInviteTo(db, accountId, Number(room.RoomId))
|
||||||
@@ -1468,6 +1474,16 @@ async function resolveRoomInstance(
|
|||||||
return { instance: null, errorCode: ROOM_IS_PRIVATE }
|
return { instance: null, errorCode: ROOM_IS_PRIVATE }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// An unpublished room has no PUBLIC instances, whatever `JoinMode` asked for. The gate
|
||||||
|
// above only decides who may START a session; a public one is then a door for everyone
|
||||||
|
// it never admitted — a friend follows the owner in, and `IsPrivate: false` tells every
|
||||||
|
// client the session is open. The owner walking through their own room's subroom door
|
||||||
|
// posts `JoinMode` 0, so this has to be decided here rather than trusted from the body.
|
||||||
|
// Private means a fresh instance each time, as `JoinMode` 2 always has: the room's
|
||||||
|
// people reach each other by invite. Room routes only, like the gate — an event or a
|
||||||
|
// clubhouse held in a private room is a session its own guest list shares.
|
||||||
|
const privateInstance = isPrivate || (gateOnAccessibility && !isPublished(room))
|
||||||
|
|
||||||
// The build this player is on, from their token. A 2023 client can't load a scene
|
// The build this player is on, from their token. A 2023 client can't load a scene
|
||||||
// saved at a newer persistence version, so it is refused the room outright (see
|
// saved at a newer persistence version, so it is refused the room outright (see
|
||||||
// persistenceVersionRefusal) before any instance is created or reused.
|
// persistenceVersionRefusal) before any instance is created or reused.
|
||||||
@@ -1489,7 +1505,7 @@ async function resolveRoomInstance(
|
|||||||
// search, which pushes them to another live instance if one exists or forces a
|
// search, which pushes them to another live instance if one exists or forces a
|
||||||
// fresh one below. (Only the public path reuses instances, so only it needs the
|
// fresh one below. (Only the public path reuses instances, so only it needs the
|
||||||
// read; a private matchmake always gets a fresh instance.)
|
// read; a private matchmake always gets a fresh instance.)
|
||||||
const currentInstanceId = isPrivate
|
const currentInstanceId = privateInstance
|
||||||
? undefined
|
? undefined
|
||||||
: (await getPresence<RoomInstance>(c.env.DB, ownerId))?.roomInstance?.roomInstanceId
|
: (await getPresence<RoomInstance>(c.env.DB, ownerId))?.roomInstance?.roomInstanceId
|
||||||
// The same build, with GAME_VERSION standing in for a token that names none. It scopes
|
// The same build, with GAME_VERSION standing in for a token that names none. It scopes
|
||||||
@@ -1501,7 +1517,7 @@ async function resolveRoomInstance(
|
|||||||
// another, and neither must a session running a different version of the room.
|
// another, and neither must a session running a different version of the room.
|
||||||
// Private matchmakes always get a fresh instance. Create one when there's nothing
|
// Private matchmakes always get a fresh instance. Create one when there's nothing
|
||||||
// to join.
|
// to join.
|
||||||
let instance = isPrivate
|
let instance = privateInstance
|
||||||
? null
|
? null
|
||||||
: await getJoinableInstance(c.env.DB, f.roomId, gameVersion, f.subRoomId, currentInstanceId)
|
: await getJoinableInstance(c.env.DB, f.roomId, gameVersion, f.subRoomId, currentInstanceId)
|
||||||
if (!instance) {
|
if (!instance) {
|
||||||
@@ -1514,7 +1530,7 @@ async function resolveRoomInstance(
|
|||||||
photonRoomId: crypto.randomUUID(),
|
photonRoomId: crypto.randomUUID(),
|
||||||
name: f.name,
|
name: f.name,
|
||||||
maxCapacity: f.maxCapacity,
|
maxCapacity: f.maxCapacity,
|
||||||
isPrivate: isPrivate || f.isDorm,
|
isPrivate: privateInstance || f.isDorm,
|
||||||
roomInstanceType: f.roomInstanceType,
|
roomInstanceType: f.roomInstanceType,
|
||||||
gameVersion,
|
gameVersion,
|
||||||
})
|
})
|
||||||
@@ -1523,7 +1539,7 @@ async function resolveRoomInstance(
|
|||||||
instance: roomInstanceFromRoom(
|
instance: roomInstanceFromRoom(
|
||||||
c.env,
|
c.env,
|
||||||
room,
|
room,
|
||||||
isPrivate,
|
privateInstance,
|
||||||
instance.roomInstanceId,
|
instance.roomInstanceId,
|
||||||
instance.photonRoomId,
|
instance.photonRoomId,
|
||||||
f.subRoomId
|
f.subRoomId
|
||||||
@@ -1542,7 +1558,8 @@ async function resolveRoomInstance(
|
|||||||
* The one gate that is these routes' alone is the room's ACCESSIBILITY (see
|
* The one gate that is these routes' alone is the room's ACCESSIBILITY (see
|
||||||
* {@link canEnterRoom}): a room that isn't published answers `RoomIsPrivate` (25) to anyone
|
* {@link canEnterRoom}): a room that isn't published answers `RoomIsPrivate` (25) to anyone
|
||||||
* but its creator, its role holders and its invitees, in either join mode — a private
|
* but its creator, its role holders and its invitees, in either join mode — a private
|
||||||
* instance of an unpublished room is still a way into it.
|
* instance of an unpublished room is still a way into it. Those it does admit always get
|
||||||
|
* a PRIVATE instance of it, whatever `JoinMode` they posted.
|
||||||
*
|
*
|
||||||
* `subRoomId` is optional: absent, `resolveRoomInstance` falls back to the room's first
|
* `subRoomId` is optional: absent, `resolveRoomInstance` falls back to the room's first
|
||||||
* subroom (its default entrance).
|
* subroom (its default entrance).
|
||||||
@@ -2317,6 +2334,21 @@ const app = new Hono<App>()
|
|||||||
return matchmakeResult(c, BANNED_FROM_ROOM, null)
|
return matchmakeResult(c, BANNED_FROM_ROOM, null)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Nor through the room's accessibility gate: friendship is not a key to a room its
|
||||||
|
// owner hasn't published, so the friend must be someone that room admits on their
|
||||||
|
// own account (canEnterRoom). The same opaque refusal as "not in a room" — a
|
||||||
|
// distinct code would tell the caller where their friend is. An instance naming no
|
||||||
|
// stored room (a dorm's synthetic one) has nothing to gate on.
|
||||||
|
const followedRoom = await getRoomById(c.env.DB, instance.roomId)
|
||||||
|
if (followedRoom && !(await canEnterRoom(c.env.DB, followedRoom, id))) {
|
||||||
|
logger.info('follow refused: friend is in an unpublished room', {
|
||||||
|
roomId: instance.roomId,
|
||||||
|
targetId,
|
||||||
|
id,
|
||||||
|
})
|
||||||
|
return matchmakeResult(c, NO_SUCH_ROOM, null)
|
||||||
|
}
|
||||||
|
|
||||||
// Nor does it go through the build scoping the room matchmakes get by
|
// Nor does it go through the build scoping the room matchmakes get by
|
||||||
// construction, so a player on another build could otherwise follow their way
|
// construction, so a player on another build could otherwise follow their way
|
||||||
// into a session that can't render them. Compared against the FRIEND's presence
|
// into a session that can't render them. Compared against the FRIEND's presence
|
||||||
|
|||||||
@@ -3032,6 +3032,105 @@ describe('auth-gated endpoints', () => {
|
|||||||
expect((await v2('/matchmake/v2/room/2', '9880', 2)).RoomInstance?.RoomId).toBe(2)
|
expect((await v2('/matchmake/v2/room/2', '9880', 2)).RoomInstance?.RoomId).toBe(2)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('an unpublished room’s instances are always PRIVATE, whatever JoinMode asked for', async () => {
|
||||||
|
type V2 = {
|
||||||
|
ErrorCode: number
|
||||||
|
RoomInstance: { RoomInstanceId: number; RoomId: number; IsPrivate: boolean } | null
|
||||||
|
}
|
||||||
|
const v2 = async (path: string, sub: string, joinMode: number) =>
|
||||||
|
(await (
|
||||||
|
await exports.default.fetch(`${ORIGIN}${path}`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { ...(await bearer(sub)), 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ JoinMode: joinMode }),
|
||||||
|
})
|
||||||
|
).json()) as V2
|
||||||
|
const stored = async (instanceId: number | undefined) =>
|
||||||
|
(
|
||||||
|
await env.DB.prepare('SELECT is_private FROM room_instance WHERE id = ?1')
|
||||||
|
.bind(instanceId ?? 0)
|
||||||
|
.first<{ is_private: number }>()
|
||||||
|
)?.is_private
|
||||||
|
|
||||||
|
// The creator walking through their own room's subroom door posts JoinMode 0 — the
|
||||||
|
// body the client was seen sending. Room 6 is Private, so the session is too: in the
|
||||||
|
// answer, and in the row every other route reads.
|
||||||
|
const own = await v2('/matchmake/v2/room/6/6', '42', 0)
|
||||||
|
expect(own.ErrorCode).toBe(0)
|
||||||
|
expect(own.RoomInstance).toMatchObject({ RoomId: 6, IsPrivate: true })
|
||||||
|
expect(await stored(own.RoomInstance?.RoomInstanceId)).toBe(1)
|
||||||
|
// The 2023 spelling, and JoinMode 1, are the same.
|
||||||
|
const v1 = (await (
|
||||||
|
await exports.default.fetch(`${ORIGIN}/matchmake/room/6`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { ...(await bearer('42')), 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||||
|
body: 'JoinMode=1',
|
||||||
|
})
|
||||||
|
).json()) as { roomInstance: { roomInstanceId: number; isPrivate: boolean } | null }
|
||||||
|
expect(v1.roomInstance?.isPrivate).toBe(true)
|
||||||
|
expect(await stored(v1.roomInstance?.roomInstanceId)).toBe(1)
|
||||||
|
|
||||||
|
// Nothing public to reuse: a role holder asking for a public session gets a private
|
||||||
|
// one of their own rather than joining the creator's.
|
||||||
|
const host = await v2('/matchmake/v2/room/6', '44', 0)
|
||||||
|
expect(host.RoomInstance?.IsPrivate).toBe(true)
|
||||||
|
expect(host.RoomInstance?.RoomInstanceId).not.toBe(v1.roomInstance?.roomInstanceId)
|
||||||
|
expect(
|
||||||
|
await env.DB.prepare(
|
||||||
|
'SELECT COUNT(*) AS n FROM room_instance WHERE room_id = 6 AND is_private = 0'
|
||||||
|
).first<{ n: number }>()
|
||||||
|
).toEqual({ n: 0 })
|
||||||
|
|
||||||
|
// A FRIEND of the creator is not one of the room's people: neither follow gets them
|
||||||
|
// in — the 2023 route by the room's gate, the v2 route because the session is private.
|
||||||
|
await env.DB.prepare(
|
||||||
|
'INSERT INTO relationship (requester_id, target_id, relationship_type) VALUES (42, 9885, 3)'
|
||||||
|
).run()
|
||||||
|
try {
|
||||||
|
const follow = (await (
|
||||||
|
await exports.default.fetch(`${ORIGIN}/matchmake/player/42`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: await bearer('9885'),
|
||||||
|
})
|
||||||
|
).json()) as { errorCode: number; roomInstance: unknown }
|
||||||
|
expect(follow).toEqual(refused(20))
|
||||||
|
const followV2 = (await (
|
||||||
|
await exports.default.fetch(`${ORIGIN}/matchmake/v2/player/42`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { ...(await bearer('9885')), 'Content-Type': 'application/json' },
|
||||||
|
body: '{}',
|
||||||
|
})
|
||||||
|
).json()) as V2
|
||||||
|
expect(followV2.ErrorCode).not.toBe(0)
|
||||||
|
expect(followV2.RoomInstance).toBeNull()
|
||||||
|
expect(
|
||||||
|
await env.DB.prepare('SELECT 1 AS hit FROM presence WHERE account_id = 9885').first()
|
||||||
|
).toBeNull()
|
||||||
|
|
||||||
|
// One of the room's own people who is also a friend may follow: the room admits
|
||||||
|
// them itself. Here the creator follows the Host into the Host's session.
|
||||||
|
await env.DB.prepare(
|
||||||
|
'INSERT INTO relationship (requester_id, target_id, relationship_type) VALUES (42, 44, 3)'
|
||||||
|
).run()
|
||||||
|
const ownFollow = (await (
|
||||||
|
await exports.default.fetch(`${ORIGIN}/matchmake/player/44`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: await bearer('42'),
|
||||||
|
})
|
||||||
|
).json()) as { errorCode: number; roomInstance: { roomInstanceId: number } | null }
|
||||||
|
expect(ownFollow.errorCode).toBe(0)
|
||||||
|
expect(ownFollow.roomInstance?.roomInstanceId).toBe(host.RoomInstance?.RoomInstanceId)
|
||||||
|
} finally {
|
||||||
|
await env.DB.prepare(
|
||||||
|
'DELETE FROM relationship WHERE requester_id = 42 AND target_id IN (9885, 44)'
|
||||||
|
).run()
|
||||||
|
}
|
||||||
|
|
||||||
|
// A published room still hands out a public session for the same request.
|
||||||
|
const open = await v2('/matchmake/v2/room/2', '9885', 0)
|
||||||
|
expect(open.RoomInstance?.IsPrivate).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
test('POST /matchmake/room/:roomId refuses a player banned from the room', async () => {
|
test('POST /matchmake/room/:roomId refuses a player banned from the room', async () => {
|
||||||
const matchmake = async (sub: string) =>
|
const matchmake = async (sub: string) =>
|
||||||
(await (
|
(await (
|
||||||
|
|||||||
Reference in New Issue
Block a user