mirror of
https://github.com/djdevin/recflare.git
synced 2026-10-25 23:18:51 -07:00
[rooms] improve room ban/unban, history
[api] mod kick [match] allow banned users into dorm
This commit is contained in:
@@ -91,7 +91,15 @@ export type Env = SharedHonoEnv & {
|
||||
}
|
||||
|
||||
/** Variables can be extended */
|
||||
export type Variables = SharedHonoVariables
|
||||
export type Variables = SharedHonoVariables & {
|
||||
/**
|
||||
* Set by the `/matchmake/*` ban gate when it lets a BANNED account through to its own
|
||||
* dorm. Only `/matchmake/dorm` and `/matchmake/none` are let through, and `none` reads
|
||||
* this to answer the dorm rather than whatever instance the caller's presence names —
|
||||
* which for a stale row is a public room the ban must keep them out of.
|
||||
*/
|
||||
bannedToDorm?: boolean
|
||||
}
|
||||
|
||||
export interface App extends HonoApp {
|
||||
Bindings: Env
|
||||
|
||||
@@ -1527,6 +1527,23 @@ async function playerDormInstance(c: Context<App>, accountId: number): Promise<R
|
||||
return roomInstanceFromRoom(c.env, room, true, instance.roomInstanceId, instance.photonRoomId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a matchmake is one a BANNED account is still allowed: the ones that can only put
|
||||
* them in their own dorm.
|
||||
*
|
||||
* `/matchmake/dorm` resolves the caller's dorm and nothing else. `/matchmake/none` normally
|
||||
* answers whatever instance their presence names, falling back to the dorm — so it is allowed
|
||||
* only because the handler sends a banned caller to the dorm outright (see `bannedToDorm`).
|
||||
* Both are in the list because which one the client calls on load-in depends on the build, and
|
||||
* a banned player who cannot complete either never reaches the screen telling them they are
|
||||
* banned.
|
||||
*
|
||||
* Matched on the path rather than the route, because the check runs in middleware, before Hono
|
||||
* has picked one. A trailing slash is accepted — the client sends both spellings elsewhere —
|
||||
* and nothing else is: `/matchmake/dormitory` must not read as the dorm.
|
||||
*/
|
||||
const isDormMatchmake = (path: string): boolean => /^\/matchmake\/(dorm|none)\/?$/.test(path)
|
||||
|
||||
const app = new Hono<App>()
|
||||
.use(
|
||||
'*',
|
||||
@@ -1555,6 +1572,26 @@ const app = new Hono<App>()
|
||||
// only — it's the one refusal the client renders as "you are banned" instead of a room
|
||||
// that mysteriously fails to load, and it's what the enum offers.
|
||||
//
|
||||
// ONE exception: a BLOCKED player may still matchmake into their own DORM.
|
||||
// `auth` already issues them a token, because the client has to be signed in to reach
|
||||
// `api`'s moderationBlockDetails — the screen that tells them they are banned, for how long
|
||||
// and why. Refused every matchmake, the client never finishes loading in and never draws
|
||||
// that screen, so a banned player saw nothing but a game that would not start. Their dorm
|
||||
// is private to them and reachable by nobody else, so letting them sit in it costs nothing
|
||||
// and is where they read the ban. Everything else — every public room, club, event,
|
||||
// instance, invite and follow — is still refused, which is the ban.
|
||||
//
|
||||
// Only the two load-in calls, `/matchmake/dorm` and `/matchmake/none` — which one the
|
||||
// client makes depends on the build. `dorm` resolves the CALLER'S dorm and nothing else;
|
||||
// `none` would otherwise hand back whatever instance their presence names, so for a banned
|
||||
// caller its handler answers the dorm outright rather than trusting a stale row.
|
||||
//
|
||||
// Ban EVASION gets the dorm too. `auth` signs those accounts in like any other, so refusing
|
||||
// them the dorm would leave the client stuck on load-in — the exact failure this exception
|
||||
// exists to fix — and they are held to it by the same gate. They see no block screen (that
|
||||
// describes the caller's OWN ban, and they have none), but a player sitting in their dorm
|
||||
// unable to enter any room is where a ban leaves them either way.
|
||||
//
|
||||
// Unauthenticated requests fall through untouched: the route's own `authedId` answers
|
||||
// 401, which mustn't turn into "banned" just because the token was missing.
|
||||
.use('/matchmake/*', async (c, next) => {
|
||||
@@ -1564,7 +1601,7 @@ const app = new Hono<App>()
|
||||
identity: { ip: c.req.header('cf-connecting-ip') },
|
||||
arms: banEvasionMatch(c.env.BAN_EVASION_MATCH),
|
||||
})
|
||||
if (match) {
|
||||
if (match && !isDormMatchmake(c.req.path)) {
|
||||
logger.info('matchmake refused: player banned', {
|
||||
accountId: id,
|
||||
via: match.via,
|
||||
@@ -1573,6 +1610,16 @@ const app = new Hono<App>()
|
||||
})
|
||||
return matchmakeResult(c, BANNED_FROM_ROOM, null)
|
||||
}
|
||||
if (match) {
|
||||
// Tells `/matchmake/none` to answer the dorm rather than the caller's presence.
|
||||
c.set('bannedToDorm', true)
|
||||
logger.info('blocked player allowed into their own dorm', {
|
||||
accountId: id,
|
||||
via: match.via,
|
||||
reportId: match.ban.id,
|
||||
banExpires: match.ban.ban_expires,
|
||||
})
|
||||
}
|
||||
}
|
||||
await next()
|
||||
})
|
||||
@@ -2763,7 +2810,10 @@ const app = new Hono<App>()
|
||||
const id = await authedId(c)
|
||||
if (id === null) return unauthorized(c)
|
||||
|
||||
const presence = await getPresence<RoomInstance>(c.env.DB, id)
|
||||
// A BANNED caller gets their dorm whatever their presence says. The ban gate let them
|
||||
// this far so the client can finish loading in and draw the block screen; answering
|
||||
// the instance a stale presence row names would walk them back into a public room.
|
||||
const presence = c.get('bannedToDorm') ? null : await getPresence<RoomInstance>(c.env.DB, id)
|
||||
const current = presence?.roomInstance ?? (await playerDormInstance(c, id))
|
||||
await enterRoom(c, id, current)
|
||||
return matchmakeResult(c, 0, current)
|
||||
|
||||
@@ -3349,14 +3349,13 @@ describe('account bans', () => {
|
||||
headers: await bearer(player),
|
||||
})
|
||||
|
||||
test('every matchmake route is refused for a banned account', async () => {
|
||||
test('every matchmake route but the load-in ones is refused for a banned account', async () => {
|
||||
await banAccount(6001)
|
||||
// One live instance of room 2 and one club membership, so each route would
|
||||
// otherwise have somewhere to put them.
|
||||
for (const path of [
|
||||
'/matchmake/room/2',
|
||||
'/matchmake/room/77/34',
|
||||
'/matchmake/dorm',
|
||||
'/matchmake/club/4',
|
||||
'/matchmake/player/9701',
|
||||
'/matchmake/instance/1',
|
||||
@@ -3367,6 +3366,94 @@ describe('account bans', () => {
|
||||
}
|
||||
})
|
||||
|
||||
// The client has to finish loading in to draw the block screen that tells the player they
|
||||
// are banned — `auth` issues them a token for exactly that reason. Refused their own dorm
|
||||
// too, they never get that far and just see a game that will not start.
|
||||
test('a banned account may still matchmake into its own dorm', async () => {
|
||||
await banAccount(6008)
|
||||
const res = await matchmake('/matchmake/dorm', '6008')
|
||||
expect(res.status).toBe(200)
|
||||
const body = (await res.json()) as {
|
||||
errorCode: number
|
||||
roomInstance: { roomInstanceId: number; roomId: number } | null
|
||||
}
|
||||
expect(body.errorCode).toBe(0)
|
||||
expect(body.roomInstance).not.toBeNull()
|
||||
|
||||
// Their OWN dorm, the same one every time — not a room anyone else can reach.
|
||||
const again = (await (await matchmake('/matchmake/dorm', '6008')).json()) as {
|
||||
roomInstance: { roomInstanceId: number } | null
|
||||
}
|
||||
expect(again.roomInstance?.roomInstanceId).toBe(body.roomInstance!.roomInstanceId)
|
||||
|
||||
// And it is only the dorm — the ban is otherwise untouched.
|
||||
expect(await (await matchmake('/matchmake/room/2', '6008')).json()).toEqual(refused(55))
|
||||
})
|
||||
|
||||
// `/matchmake/none` is the other load-in call, and which one the client makes depends on
|
||||
// the build. It normally answers the instance the caller's presence names — so for a banned
|
||||
// caller it must answer the DORM instead, or a stale presence row walks them straight back
|
||||
// into the public room they were standing in.
|
||||
test('a banned account’s /matchmake/none answers the dorm, not their stale presence', async () => {
|
||||
await banAccount(6011)
|
||||
// Standing in a live instance of room 2, the way a player banned mid-session is.
|
||||
const joined = (await (await matchmake('/matchmake/room/2', '9701')).json()) as {
|
||||
roomInstance: { roomInstanceId: number; roomId: number }
|
||||
}
|
||||
await env.DB.prepare('INSERT OR REPLACE INTO presence (data) VALUES (?1)')
|
||||
.bind(
|
||||
JSON.stringify({
|
||||
accountId: 6011,
|
||||
roomInstance: joined.roomInstance,
|
||||
expiresAt: Math.floor(Date.now() / 1000) + 900,
|
||||
})
|
||||
)
|
||||
.run()
|
||||
|
||||
const body = (await (await matchmake('/matchmake/none', '6011')).json()) as {
|
||||
errorCode: number
|
||||
roomInstance: { roomInstanceId: number; roomId: number } | null
|
||||
}
|
||||
expect(body.errorCode).toBe(0)
|
||||
expect(body.roomInstance).not.toBeNull()
|
||||
// NOT the room they were standing in.
|
||||
expect(body.roomInstance?.roomInstanceId).not.toBe(joined.roomInstance.roomInstanceId)
|
||||
expect(body.roomInstance?.roomId).not.toBe(joined.roomInstance.roomId)
|
||||
// It is their dorm — the same instance `/matchmake/dorm` answers.
|
||||
const dorm = (await (await matchmake('/matchmake/dorm', '6011')).json()) as {
|
||||
roomInstance: { roomInstanceId: number } | null
|
||||
}
|
||||
expect(dorm.roomInstance?.roomInstanceId).toBe(body.roomInstance?.roomInstanceId)
|
||||
})
|
||||
|
||||
// An evader signs in like anyone else (`auth` issues them a token), so they must be able to
|
||||
// finish loading in — otherwise the client hangs on a failed login, which is the failure the
|
||||
// dorm exception exists to fix. They are held to the dorm by the same gate.
|
||||
test('an account caught by ban evasion gets the dorm and nothing else', async () => {
|
||||
const linkTo = async (id: number, platformId: string) =>
|
||||
env.DB.prepare(
|
||||
`INSERT OR IGNORE INTO platform_account (account_id, platform, platform_id, linked_at)
|
||||
VALUES (?1, 0, ?2, ?3)`
|
||||
)
|
||||
.bind(id, platformId, new Date().toISOString())
|
||||
.run()
|
||||
await linkTo(6009, 'steam-dorm-evader')
|
||||
await banAccount(6009)
|
||||
// The replacement account: different id, same headset.
|
||||
await linkTo(6010, 'steam-dorm-evader')
|
||||
|
||||
const dorm = (await (await matchmake('/matchmake/dorm', '6010')).json()) as {
|
||||
errorCode: number
|
||||
roomInstance: unknown
|
||||
}
|
||||
expect(dorm.errorCode).toBe(0)
|
||||
expect(dorm.roomInstance).not.toBeNull()
|
||||
|
||||
// Every room is still closed to them — which is the ban.
|
||||
expect(await (await matchmake('/matchmake/room/2', '6010')).json()).toEqual(refused(55))
|
||||
expect(await (await matchmake('/matchmake/club/4', '6010')).json()).toEqual(refused(55))
|
||||
})
|
||||
|
||||
// The refusal is the ban's, not the room's: nothing is entered, so no presence is
|
||||
// written and the player stays where they were (nowhere).
|
||||
test('a refused matchmake leaves no presence behind', async () => {
|
||||
|
||||
Reference in New Issue
Block a user