diff --git a/index.html b/index.html index fdf6d046e..1b81cdd16 100644 --- a/index.html +++ b/index.html @@ -26,6 +26,11 @@ padding-left: env(safe-area-inset-left); } + /* Prevent Playwire bottom rail from affecting flex layout */ + [id^="pw-oop-bottom_rail"] { + position: fixed !important; + } + /* Ensure full viewport height on iOS */ html, body { @@ -261,9 +266,7 @@ > -
-
diff --git a/src/client/FlagInputModal.ts b/src/client/FlagInputModal.ts index 7a4e0a390..9da509207 100644 --- a/src/client/FlagInputModal.ts +++ b/src/client/FlagInputModal.ts @@ -150,7 +150,7 @@ export class FlagInputModal extends BaseModal {
`; } } diff --git a/src/core/ApiSchemas.ts b/src/core/ApiSchemas.ts index 568c87304..f0e2a02e0 100644 --- a/src/core/ApiSchemas.ts +++ b/src/core/ApiSchemas.ts @@ -43,6 +43,11 @@ export const TokenPayloadSchema = z.object({ iss: z.string(), aud: z.string(), exp: z.number(), + role: z + .enum(["root", "admin", "mod", "flagged", "banned"]) + // In case new roles are added in the future. + .or(z.string()) + .optional(), }); export type TokenPayload = z.infer; diff --git a/src/server/Privilege.ts b/src/server/Privilege.ts index d3d9148ee..8bd49c4a7 100644 --- a/src/server/Privilege.ts +++ b/src/server/Privilege.ts @@ -116,15 +116,29 @@ function censorWithMatcher( matcher: RegExpMatcher, ): { username: string; clanTag: string | null } { const usernameIsProfane = matcher.hasMatch(username); - const censoredName = usernameIsProfane - ? shadowNames[simpleHash(username) % shadowNames.length] - : username; - const clanTagIsProfane = clanTag ? matcher.hasMatch(clanTag) || clanTag.toLowerCase() === "ss" : false; + // Catch slurs split across clan tag and username (e.g. clanTag="HIT", username="LER") + // by looking for a match that spans the clan/name boundary. + const combinedSlurAcrossBoundary = clanTag + ? matcher.getAllMatches(clanTag + username).some( + (match) => + // Match must start in the clan and extend into the name — otherwise + // it's already handled by the clan-only or name-only checks above. + match.startIndex < clanTag.length && match.endIndex >= clanTag.length, + ) + : false; + + const censoredName = + usernameIsProfane || combinedSlurAcrossBoundary + ? shadowNames[simpleHash(username) % shadowNames.length] + : username; + const censoredClanTag = - clanTag && !clanTagIsProfane ? clanTag.toUpperCase() : null; + clanTag && !clanTagIsProfane && !combinedSlurAcrossBoundary + ? clanTag.toUpperCase() + : null; return { username: censoredName, clanTag: censoredClanTag }; } diff --git a/src/server/Worker.ts b/src/server/Worker.ts index 17202ed4c..3beacb6e8 100644 --- a/src/server/Worker.ts +++ b/src/server/Worker.ts @@ -357,6 +357,11 @@ export async function startWorker() { } const { persistentId, claims } = result; + if (claims?.role === "banned") { + ws.close(1002, "Account Banned"); + return; + } + if (clientMsg.type === "rejoin") { log.info("rejoining game", { gameID: clientMsg.gameID, diff --git a/tests/Privilege.test.ts b/tests/Privilege.test.ts index 8a9e0dc17..4e8e644d5 100644 --- a/tests/Privilege.test.ts +++ b/tests/Privilege.test.ts @@ -230,6 +230,44 @@ describe("UsernameCensor", () => { expect(result.clanTag).toBeNull(); expect(shadowNames).toContain(result.username); }); + + describe("clan tag + username combined forms a slur", () => { + test("censors when clan+name combined forms hitler", () => { + const result = checker.censor("LER", "HIT"); + expect(shadowNames).toContain(result.username); + expect(result.clanTag).toBeNull(); + }); + + test("censors when clan+name combined forms hitler (split differently)", () => { + const result = checker.censor("TLER", "HI"); + expect(shadowNames).toContain(result.username); + expect(result.clanTag).toBeNull(); + }); + + test("censors when clan+name combined forms adolf", () => { + const result = checker.censor("OLF", "AD"); + expect(shadowNames).toContain(result.username); + expect(result.clanTag).toBeNull(); + }); + + test("censors when clan+name combined forms nigger", () => { + const result = checker.censor("ger", "NIG"); + expect(shadowNames).toContain(result.username); + expect(result.clanTag).toBeNull(); + }); + + test("censors when clan+name combined forms nigger (clean parts)", () => { + const result = checker.censor("gger", "NI"); + expect(shadowNames).toContain(result.username); + expect(result.clanTag).toBeNull(); + }); + + test("censors leet speak combined across clan and name", () => { + const result = checker.censor("g3r", "N1G"); + expect(shadowNames).toContain(result.username); + expect(result.clanTag).toBeNull(); + }); + }); }); test("returns deterministic shadow name for same input", () => {