diff --git a/jest.config.ts b/jest.config.ts index b69aa5338..b7f770614 100644 --- a/jest.config.ts +++ b/jest.config.ts @@ -15,7 +15,7 @@ export default { "^.+\\.js$": ["@swc/jest"], }, transformIgnorePatterns: [ - "node_modules/(?!(nanoid|@jsep|fastpriorityqueue|@datastructures-js)/)", + "node_modules/(?!(nanoid|@jsep|fastpriorityqueue|@datastructures-js|jose)/)", ], collectCoverageFrom: ["src/**/*.ts", "!src/**/*.d.ts"], coverageThreshold: { diff --git a/src/core/GameRunner.ts b/src/core/GameRunner.ts index 6a8a4042f..d7c74307a 100644 --- a/src/core/GameRunner.ts +++ b/src/core/GameRunner.ts @@ -29,7 +29,7 @@ import { import { loadTerrainMap as loadGameMap } from "./game/TerrainMapLoader"; import { PseudoRandom } from "./PseudoRandom"; import { ClientID, GameStartInfo, Turn } from "./Schemas"; -import { sanitize, simpleHash } from "./Util"; +import { simpleHash } from "./Util"; import { censorNameWithClanTag } from "./validations/username"; export async function createGameRunner( @@ -48,9 +48,7 @@ export async function createGameRunner( const humans = gameStart.players.map((p) => { return new PlayerInfo( - p.clientID === clientID - ? sanitize(p.username) - : censorNameWithClanTag(p.username), + p.clientID === clientID ? p.username : censorNameWithClanTag(p.username), PlayerType.Human, p.clientID, random.nextID(), diff --git a/src/core/Schemas.ts b/src/core/Schemas.ts index 74b999b13..5ba0b04a5 100644 --- a/src/core/Schemas.ts +++ b/src/core/Schemas.ts @@ -209,7 +209,11 @@ export const ID = z export const AllPlayersStatsSchema = z.record(ID, PlayerStatsSchema); -export const UsernameSchema = SafeString; +export const UsernameSchema = z + .string() + .regex(/^[a-zA-Z0-9_ [\]üÜ]+$/u) + .min(3) + .max(27); const countryCodes = countries.filter((c) => !c.restricted).map((c) => c.code); export const QuickChatKeySchema = z.enum( diff --git a/src/core/game/PlayerImpl.ts b/src/core/game/PlayerImpl.ts index 856dfa77a..9d9d7ecf1 100644 --- a/src/core/game/PlayerImpl.ts +++ b/src/core/game/PlayerImpl.ts @@ -9,7 +9,6 @@ import { toInt, within, } from "../Util"; -import { sanitizeUsername } from "../validations/username"; import { AttackImpl } from "./AttackImpl"; import { Alliance, @@ -111,7 +110,7 @@ export class PlayerImpl implements Player { startTroops: number, private readonly _team: Team | null, ) { - this._name = sanitizeUsername(playerInfo.name); + this._name = playerInfo.name; this._troops = toInt(startTroops); this._gold = 0n; this._displayName = this._name; diff --git a/src/core/validations/username.ts b/src/core/validations/username.ts index 2096c0ae1..f9534411c 100644 --- a/src/core/validations/username.ts +++ b/src/core/validations/username.ts @@ -8,7 +8,8 @@ import { skipNonAlphabeticTransformer, } from "obscenity"; import { translateText } from "../../client/Utils"; -import { getClanTagOriginalCase, sanitize, simpleHash } from "../Util"; +import { UsernameSchema } from "../Schemas"; +import { getClanTagOriginalCase, simpleHash } from "../Util"; const matcher = new RegExpMatcher({ ...englishDataset.build(), @@ -22,8 +23,6 @@ const matcher = new RegExpMatcher({ export const MIN_USERNAME_LENGTH = 3; export const MAX_USERNAME_LENGTH = 27; -const validPattern = /^[a-zA-Z0-9_[\] üÜ]+$/u; // Don't allow more than in UserNameSchema - const shadowNames = [ "NicePeopleOnly", "BeKindPlz", @@ -58,7 +57,6 @@ export function isProfaneUsername(username: string): boolean { * * Examples: * - "GoodName" -> "GoodName" - * - "Good$Name" -> "GoodName" * - "BadName" -> "Censored" * - "[CLAN]GoodName" -> "[CLAN]GoodName" * - "[CLaN]BadName" -> "[CLaN] Censored" @@ -66,14 +64,12 @@ export function isProfaneUsername(username: string): boolean { * - "[BAD]BadName" -> "Censored" */ export function censorNameWithClanTag(username: string): string { - const sanitizedUsername = sanitize(username); - // Don't use getClanTag because that returns upperCase and if original isn't, str replace `[{$clanTag}]` won't match - const clanTag = getClanTagOriginalCase(sanitizedUsername); + const clanTag = getClanTagOriginalCase(username); const nameWithoutClan = clanTag - ? sanitizedUsername.replace(`[${clanTag}]`, "").trim() - : sanitizedUsername; + ? username.replace(`[${clanTag}]`, "").trim() + : username; const clanTagIsProfane = clanTag ? isProfaneUsername(clanTag) : false; const usernameIsProfane = isProfaneUsername(nameWithoutClan); @@ -87,7 +83,7 @@ export function censorNameWithClanTag(username: string): string { if (usernameIsProfane) { return `[${clanTag}] ${censoredNameWithoutClan}`; } - return sanitizedUsername; + return username; } // Don't restore profane or nonexistent clan tag @@ -98,43 +94,39 @@ export function validateUsername(username: string): { isValid: boolean; error?: string; } { - if (typeof username !== "string") { - return { isValid: false, error: translateText("username.not_string") }; - } + const parsed = UsernameSchema.safeParse(username); - if (username.length < MIN_USERNAME_LENGTH) { - return { - isValid: false, - error: translateText("username.too_short", { - min: MIN_USERNAME_LENGTH, - }), - }; - } + if (!parsed.success) { + const errType = parsed.error.issues[0].code; - if (username.length > MAX_USERNAME_LENGTH) { - return { - isValid: false, - error: translateText("username.too_long", { - max: MAX_USERNAME_LENGTH, - }), - }; - } + if (errType === "invalid_type") { + return { isValid: false, error: translateText("username.not_string") }; + } - if (!validPattern.test(username)) { - return { - isValid: false, - error: translateText("username.invalid_chars"), - }; + if (errType === "too_small") { + return { + isValid: false, + error: translateText("username.too_short", { + min: MIN_USERNAME_LENGTH, + }), + }; + } + + if (errType === "too_big") { + return { + isValid: false, + error: translateText("username.too_long", { + max: MAX_USERNAME_LENGTH, + }), + }; + } + + // Invalid regex, or any other issue + else { + return { isValid: false, error: translateText("username.invalid_chars") }; + } } // All checks passed return { isValid: true }; } - -export function sanitizeUsername(str: string): string { - const sanitized = Array.from(str) - .filter((ch) => validPattern.test(ch)) - .join("") - .slice(0, MAX_USERNAME_LENGTH); - return sanitized.padEnd(MIN_USERNAME_LENGTH, "x"); -} diff --git a/tests/Censor.test.ts b/tests/Censor.test.ts index a937d4e71..42b168cb5 100644 --- a/tests/Censor.test.ts +++ b/tests/Censor.test.ts @@ -35,8 +35,6 @@ import { fixProfaneUsername, isProfaneUsername, MAX_USERNAME_LENGTH, - MIN_USERNAME_LENGTH, - sanitizeUsername, validateUsername, } from "../src/core/validations/username"; @@ -110,25 +108,4 @@ describe("username.ts functions", () => { expect(res.isValid).toBe(true); }); }); - - describe("sanitizeUsername", () => { - test.each([ - { input: "GoodName", expected: "GoodName" }, - { input: "a!", expected: "axx" }, - { input: "a$%b", expected: "abx" }, - { - input: "abc".repeat(10), - expected: "abc" - .repeat(Math.floor(MAX_USERNAME_LENGTH / 3)) - .slice(0, MAX_USERNAME_LENGTH), - }, - { input: "", expected: "xxx" }, - { input: "Ünicode Test!", expected: "Ünicode Test" }, - ])('sanitizeUsername("%s") → "%s"', ({ input, expected }) => { - const out = sanitizeUsername(input); - expect(out).toBe(expected); - expect(out.length).toBeGreaterThanOrEqual(MIN_USERNAME_LENGTH); - expect(out.length).toBeLessThanOrEqual(MAX_USERNAME_LENGTH); - }); - }); });