mirror of
https://github.com/djdevin/recflare.git
synced 2026-10-27 07:58:55 -07:00
[match] dont allow invites into unpublished rooms
This commit is contained in:
@@ -31,6 +31,7 @@ import {
|
|||||||
getRoomInstanceSummariesByRoom,
|
getRoomInstanceSummariesByRoom,
|
||||||
getRoomInvite,
|
getRoomInvite,
|
||||||
getStoredRoomInstance,
|
getStoredRoomInstance,
|
||||||
|
hasRoomInviteTo,
|
||||||
InviteMode,
|
InviteMode,
|
||||||
isClubMember,
|
isClubMember,
|
||||||
isPlayerBannedFromRoom,
|
isPlayerBannedFromRoom,
|
||||||
@@ -40,7 +41,9 @@ import {
|
|||||||
recordRoomVisit,
|
recordRoomVisit,
|
||||||
recordStat,
|
recordStat,
|
||||||
refreshInstanceFullness,
|
refreshInstanceFullness,
|
||||||
|
Role,
|
||||||
RoomInstanceType,
|
RoomInstanceType,
|
||||||
|
roomRoles,
|
||||||
setPresence,
|
setPresence,
|
||||||
setRoomInstanceInProgress,
|
setRoomInstanceInProgress,
|
||||||
setRoomInstancePrivate,
|
setRoomInstancePrivate,
|
||||||
@@ -739,6 +742,43 @@ function persistenceVersionRefusal(
|
|||||||
return tooNew ? MatchmakingErrorCode.UpdateRequired : null
|
return tooNew ? MatchmakingErrorCode.UpdateRequired : null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* "This room is private" — the refusal on a room that isn't published, asked for by id by
|
||||||
|
* someone it doesn't admit (see {@link canEnterRoom}). Told plainly, as the private-event
|
||||||
|
* refusal below is: whoever reaches this holds the room id already, and the only thing an
|
||||||
|
* opaque NoSuchRoom would buy is a room that fails to load for no visible reason.
|
||||||
|
*/
|
||||||
|
const ROOM_IS_PRIVATE = MatchmakingErrorCode.RoomIsPrivate
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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
|
||||||
|
* not listed) — admits anyone; every other accessibility (Private, and the two Dev_ ones)
|
||||||
|
* is a room its owner hasn't released, and admits only:
|
||||||
|
*
|
||||||
|
* - its creator, and anyone holding a role on it (Host and up: everyone named in `Roles`
|
||||||
|
* is working on the room, whether or not it is published), or
|
||||||
|
* - a player holding a live `room_invite` into it — an invite is how a private room's
|
||||||
|
* people bring someone in.
|
||||||
|
*
|
||||||
|
* Without this, a private INSTANCE was the only thing "private" meant to the room routes:
|
||||||
|
* `JoinMode` 2 spawned a fresh instance of any room whose id you had, published or not,
|
||||||
|
* and the unpublished builds a player's visit history named were open to anyone who
|
||||||
|
* could read it. Dorms are not gated here — they are Unlisted by construction and reach
|
||||||
|
* their guests through the invite routes, not this one.
|
||||||
|
*/
|
||||||
|
async function canEnterRoom(db: D1Database, room: Room, accountId: number): Promise<boolean> {
|
||||||
|
if (
|
||||||
|
room.Accessibility === Accessibility.Public ||
|
||||||
|
room.Accessibility === Accessibility.Unlisted
|
||||||
|
) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if (room.CreatorAccountId === accountId) return true
|
||||||
|
if (roomRoles(room).some((r) => r.AccountId === accountId && r.Role !== Role.None)) return true
|
||||||
|
return hasRoomInviteTo(db, accountId, Number(room.RoomId))
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* "This event isn't open to you" — the refusal on a private event the caller wasn't
|
* "This event isn't open to you" — the refusal on a private event the caller wasn't
|
||||||
* invited to. Told plainly rather than hidden behind the opaque NoSuchRoom: a player
|
* invited to. Told plainly rather than hidden behind the opaque NoSuchRoom: a player
|
||||||
@@ -1392,7 +1432,8 @@ async function resolveRoomInstance(
|
|||||||
roomKey: string,
|
roomKey: string,
|
||||||
isPrivate: boolean,
|
isPrivate: boolean,
|
||||||
ownerId: number,
|
ownerId: number,
|
||||||
requestedSubRoomId?: number
|
requestedSubRoomId?: number,
|
||||||
|
gateOnAccessibility = false
|
||||||
): Promise<ResolvedInstance> {
|
): Promise<ResolvedInstance> {
|
||||||
const id = Number.parseInt(roomKey, 10)
|
const id = Number.parseInt(roomKey, 10)
|
||||||
const requested = Number.isNaN(id)
|
const requested = Number.isNaN(id)
|
||||||
@@ -1413,6 +1454,20 @@ async function resolveRoomInstance(
|
|||||||
return { instance: null, errorCode: BANNED_FROM_ROOM }
|
return { instance: null, errorCode: BANNED_FROM_ROOM }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// An unpublished room admits only its people (see canEnterRoom). Only the ROOM routes
|
||||||
|
// ask for this: an event or a clubhouse matchmake has already decided the caller
|
||||||
|
// belongs (invited to the event, a member of the club) and its room may well be private
|
||||||
|
// — that's what holding an event in your own room looks like. Checked against the room
|
||||||
|
// actually entered, after any substitution, and before an instance exists.
|
||||||
|
if (gateOnAccessibility && !(await canEnterRoom(c.env.DB, room, ownerId))) {
|
||||||
|
logger.info('matchmake refused: room is not published and player is not admitted', {
|
||||||
|
roomId: f.roomId,
|
||||||
|
ownerId,
|
||||||
|
accessibility: room.Accessibility,
|
||||||
|
})
|
||||||
|
return { instance: null, errorCode: ROOM_IS_PRIVATE }
|
||||||
|
}
|
||||||
|
|
||||||
// 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.
|
||||||
@@ -1484,6 +1539,11 @@ async function resolveRoomInstance(
|
|||||||
* reads the same envelope back — so they are the same handler under a second path rather
|
* reads the same envelope back — so they are the same handler under a second path rather
|
||||||
* than a copy that can drift.
|
* than a copy that can drift.
|
||||||
*
|
*
|
||||||
|
* 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
|
||||||
|
* 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.
|
||||||
|
*
|
||||||
* `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).
|
||||||
*/
|
*/
|
||||||
@@ -1498,7 +1558,10 @@ async function matchmakeIntoRoom(c: Context<App>) {
|
|||||||
c.req.param('roomId') ?? '',
|
c.req.param('roomId') ?? '',
|
||||||
joinMode === 2,
|
joinMode === 2,
|
||||||
id,
|
id,
|
||||||
subRoomId
|
subRoomId,
|
||||||
|
// Named by id, so the room's own accessibility is the gate (canEnterRoom): an
|
||||||
|
// unpublished room refuses everyone but its people and its invitees with 25.
|
||||||
|
true
|
||||||
)
|
)
|
||||||
if (!instance) return matchmakeResult(c, errorCode, null)
|
if (!instance) return matchmakeResult(c, errorCode, null)
|
||||||
await enterRoom(c, id, instance)
|
await enterRoom(c, id, instance)
|
||||||
@@ -2728,7 +2791,7 @@ const app = new Hono<App>()
|
|||||||
responses: {
|
responses: {
|
||||||
200: json(
|
200: json(
|
||||||
MatchmakeResponse,
|
MatchmakeResponse,
|
||||||
'The instance (or a null instance with errorCode 20 on an unknown room, 55 when banned)'
|
'The instance (or a null instance with errorCode 20 on an unknown room, 55 when banned, 25 on an unpublished room the caller is not admitted to)'
|
||||||
),
|
),
|
||||||
401: UNAUTHORIZED_RESPONSE,
|
401: UNAUTHORIZED_RESPONSE,
|
||||||
},
|
},
|
||||||
@@ -2753,7 +2816,7 @@ const app = new Hono<App>()
|
|||||||
responses: {
|
responses: {
|
||||||
200: json(
|
200: json(
|
||||||
MatchmakeResponse,
|
MatchmakeResponse,
|
||||||
'The instance (or a null instance with errorCode 20 on an unknown room, 55 when banned)'
|
'The instance (or a null instance with errorCode 20 on an unknown room, 55 when banned, 25 on an unpublished room the caller is not admitted to)'
|
||||||
),
|
),
|
||||||
401: UNAUTHORIZED_RESPONSE,
|
401: UNAUTHORIZED_RESPONSE,
|
||||||
},
|
},
|
||||||
@@ -2803,7 +2866,7 @@ const app = new Hono<App>()
|
|||||||
responses: {
|
responses: {
|
||||||
200: json(
|
200: json(
|
||||||
MatchmakeV2Response,
|
MatchmakeV2Response,
|
||||||
'The instance (or a null RoomInstance with ErrorCode 20 on an unknown room, 55 when banned)'
|
'The instance (or a null RoomInstance with ErrorCode 20 on an unknown room, 55 when banned, 25 on an unpublished room the caller is not admitted to)'
|
||||||
),
|
),
|
||||||
401: UNAUTHORIZED_RESPONSE,
|
401: UNAUTHORIZED_RESPONSE,
|
||||||
},
|
},
|
||||||
@@ -2826,7 +2889,7 @@ const app = new Hono<App>()
|
|||||||
responses: {
|
responses: {
|
||||||
200: json(
|
200: json(
|
||||||
MatchmakeV2Response,
|
MatchmakeV2Response,
|
||||||
'The instance (or a null RoomInstance with ErrorCode 20 on an unknown room, 55 when banned)'
|
'The instance (or a null RoomInstance with ErrorCode 20 on an unknown room, 55 when banned, 25 on an unpublished room the caller is not admitted to)'
|
||||||
),
|
),
|
||||||
401: UNAUTHORIZED_RESPONSE,
|
401: UNAUTHORIZED_RESPONSE,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -88,6 +88,31 @@ const TEST_ROOMS = [
|
|||||||
Roles: [{ AccountId: 43, Role: 30, LastChangedByAccountId: null, InvitedRole: 0 }],
|
Roles: [{ AccountId: 43, Role: 30, LastChangedByAccountId: null, InvitedRole: 0 }],
|
||||||
SubRooms: [{ SubRoomId: 3, UnitySceneId: RECCENTER_SCENE, MaxPlayers: 8 }],
|
SubRooms: [{ SubRoomId: 3, UnitySceneId: RECCENTER_SCENE, MaxPlayers: 8 }],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
// UNPUBLISHED (Private): admits its creator, its role holders and its invitees only.
|
||||||
|
RoomId: 6,
|
||||||
|
Name: 'UnpublishedRoom',
|
||||||
|
IsDorm: false,
|
||||||
|
Accessibility: 0,
|
||||||
|
CreatorAccountId: 42,
|
||||||
|
Roles: [
|
||||||
|
{ AccountId: 42, Role: 255, LastChangedByAccountId: null, InvitedRole: 0 },
|
||||||
|
// A host — the lowest tier, and still somebody working on the room.
|
||||||
|
{ AccountId: 44, Role: 10, LastChangedByAccountId: null, InvitedRole: 0 },
|
||||||
|
// Invited to a role but holding none yet: not admitted by the role.
|
||||||
|
{ AccountId: 46, Role: 0, LastChangedByAccountId: 42, InvitedRole: 30 },
|
||||||
|
],
|
||||||
|
SubRooms: [{ SubRoomId: 6, UnitySceneId: RECCENTER_SCENE, MaxPlayers: 8 }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// Unlisted: published, just not listed — anyone holding the id may enter.
|
||||||
|
RoomId: 7,
|
||||||
|
Name: 'UnlistedRoom',
|
||||||
|
IsDorm: false,
|
||||||
|
Accessibility: 2,
|
||||||
|
CreatorAccountId: 42,
|
||||||
|
SubRooms: [{ SubRoomId: 7, UnitySceneId: RECCENTER_SCENE, MaxPlayers: 8 }],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
// A single-seat room so one player fills its instance (fullness tests).
|
// A single-seat room so one player fills its instance (fullness tests).
|
||||||
RoomId: 5,
|
RoomId: 5,
|
||||||
@@ -2924,6 +2949,74 @@ describe('auth-gated endpoints', () => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('POST /matchmake/room/:roomId refuses an unpublished room to anyone it does not admit', async () => {
|
||||||
|
// Every spelling of the room matchmake, in both join modes — a PRIVATE instance
|
||||||
|
// of an unpublished room is still a way into it, so JoinMode 2 is gated too.
|
||||||
|
const v1 = async (path: string, sub: string, joinMode: string) =>
|
||||||
|
(await (
|
||||||
|
await exports.default.fetch(`${ORIGIN}${path}`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { ...(await bearer(sub)), 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||||
|
body: new URLSearchParams({ JoinMode: joinMode }).toString(),
|
||||||
|
})
|
||||||
|
).json()) as { errorCode: number; roomInstance: { roomId: number } | 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 { ErrorCode: number; RoomInstance: { RoomId: number } | null }
|
||||||
|
|
||||||
|
// A stranger: RoomIsPrivate (25) — told plainly, since they hold the id already —
|
||||||
|
// with no instance, on every route and in either join mode.
|
||||||
|
for (const joinMode of ['1', '2']) {
|
||||||
|
expect(await v1('/matchmake/room/6', '9880', joinMode)).toEqual(refused(25))
|
||||||
|
expect(await v1('/matchmake/room/6/6', '9880', joinMode)).toEqual(refused(25))
|
||||||
|
}
|
||||||
|
for (const joinMode of [1, 2]) {
|
||||||
|
expect(await v2('/matchmake/v2/room/6', '9880', joinMode)).toMatchObject({
|
||||||
|
ErrorCode: 25,
|
||||||
|
RoomInstance: null,
|
||||||
|
})
|
||||||
|
expect(await v2('/matchmake/v2/room/6/6', '9880', joinMode)).toMatchObject({
|
||||||
|
ErrorCode: 25,
|
||||||
|
RoomInstance: null,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
// Refused before any instance or presence exists for them.
|
||||||
|
expect(
|
||||||
|
await env.DB.prepare('SELECT 1 AS hit FROM presence WHERE account_id = 9880').first()
|
||||||
|
).toBeNull()
|
||||||
|
expect(
|
||||||
|
await env.DB.prepare('SELECT 1 AS hit FROM room_instance WHERE room_id = 6').first()
|
||||||
|
).toBeNull()
|
||||||
|
|
||||||
|
// The creator and a role holder (a Host, the lowest tier) get in; a pending role
|
||||||
|
// invite (Role 0) is not a role.
|
||||||
|
expect((await v1('/matchmake/room/6', '42', '2')).roomInstance?.roomId).toBe(6)
|
||||||
|
expect((await v2('/matchmake/v2/room/6', '44', 2)).RoomInstance?.RoomId).toBe(6)
|
||||||
|
expect(await v1('/matchmake/room/6', '46', '2')).toEqual(refused(25))
|
||||||
|
|
||||||
|
// A live room invite INTO THAT ROOM admits the invitee — and is not consumed, so a
|
||||||
|
// retry works. An invite to some other room is no key to this one.
|
||||||
|
await env.DB.prepare(
|
||||||
|
`INSERT INTO room_invite (from_player_id, to_player_id, room_id, created_at)
|
||||||
|
VALUES (42, 9881, 2, ?1), (42, 9880, 6, ?1)`
|
||||||
|
)
|
||||||
|
.bind(Math.floor(Date.now() / 1000))
|
||||||
|
.run()
|
||||||
|
expect(await v1('/matchmake/room/6', '9881', '2')).toEqual(refused(25))
|
||||||
|
expect((await v1('/matchmake/room/6', '9880', '2')).roomInstance?.roomId).toBe(6)
|
||||||
|
expect((await v2('/matchmake/v2/room/6', '9880', 2)).RoomInstance?.RoomId).toBe(6)
|
||||||
|
await env.DB.prepare('DELETE FROM room_invite WHERE to_player_id IN (9880, 9881)').run()
|
||||||
|
|
||||||
|
// Published rooms are untouched: Unlisted is reachable by whoever holds the id.
|
||||||
|
expect((await v1('/matchmake/room/7', '9880', '1')).roomInstance?.roomId).toBe(7)
|
||||||
|
expect((await v2('/matchmake/v2/room/2', '9880', 2)).RoomInstance?.RoomId).toBe(2)
|
||||||
|
})
|
||||||
|
|
||||||
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 (
|
||||||
@@ -3388,8 +3481,9 @@ describe('auth-gated endpoints', () => {
|
|||||||
|
|
||||||
// A friend lands in the public instance with no invite row at all…
|
// A friend lands in the public instance with no invite row at all…
|
||||||
expect(
|
expect(
|
||||||
await env.DB.prepare('SELECT COUNT(*) AS n FROM room_invite WHERE from_player_id = 8821')
|
await env.DB.prepare(
|
||||||
.first<{ n: number }>()
|
'SELECT COUNT(*) AS n FROM room_invite WHERE from_player_id = 8821'
|
||||||
|
).first<{ n: number }>()
|
||||||
).toMatchObject({ n: 0 })
|
).toMatchObject({ n: 0 })
|
||||||
expect(await join(8821, '8822')).toMatchObject({
|
expect(await join(8821, '8822')).toMatchObject({
|
||||||
ErrorCode: 0,
|
ErrorCode: 0,
|
||||||
|
|||||||
@@ -127,6 +127,28 @@ export async function getLatestRoomInviteBetween(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether ANY live invite stands to `toPlayerId` for the room with this id, from anyone.
|
||||||
|
*
|
||||||
|
* This is the key that opens an unpublished room to a room MATCHMAKE
|
||||||
|
* (`POST /matchmake[/v2]/room/:roomId` on `match`): a room that isn't public admits its
|
||||||
|
* creator, its role holders, and whoever has been invited into it — and "invited" is a row
|
||||||
|
* here whose `room_id` is that room. Any row is enough and none is consumed: the caller may
|
||||||
|
* be retrying a join that failed downstream, and the row expires with the sweep anyway.
|
||||||
|
* A miss is, as everywhere in this module, "never invited" and "already swept" alike.
|
||||||
|
*/
|
||||||
|
export async function hasRoomInviteTo(
|
||||||
|
db: D1Database,
|
||||||
|
toPlayerId: number,
|
||||||
|
roomId: number
|
||||||
|
): Promise<boolean> {
|
||||||
|
const row = await db
|
||||||
|
.prepare(`SELECT 1 AS hit FROM room_invite WHERE to_player_id = ?1 AND room_id = ?2 LIMIT 1`)
|
||||||
|
.bind(toPlayerId, roomId)
|
||||||
|
.first<{ hit: number }>()
|
||||||
|
return row !== null
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Record an invite from `fromPlayerId` to `toPlayerId` for a room, returning it as the
|
* Record an invite from `fromPlayerId` to `toPlayerId` for a room, returning it as the
|
||||||
* client reads it back. `roomId` is null when the caller's room instance didn't resolve.
|
* client reads it back. `roomId` is null when the caller's room instance didn't resolve.
|
||||||
|
|||||||
Reference in New Issue
Block a user