Expand ign allow list

This commit is contained in:
Kalle
2026-08-29 10:01:36 +03:00
parent ee4586e4b3
commit e0ee25cbb6
3 changed files with 61 additions and 10 deletions

View File

@@ -1,5 +1,5 @@
import { describe, expect, test } from "vitest";
import { inGameNameIsValid } from "./in-game-name";
import { inGameNameIsValid, sanitizeInGameName } from "./in-game-name";
describe("inGameNameIsValid", () => {
test("passes valid in-game names", () => {
@@ -31,6 +31,14 @@ describe("inGameNameIsValid", () => {
"〆〇〃#1234",
"※Test#1234",
"○☆Mikurby#2897",
"仝#1234",
"三#1234",
"日本語#1234",
"名前テスト1234#ab12c",
"한국어#1234",
"中文名#1234",
"#1234",
"Cafe\u0301#1234",
];
for (const name of validNames) {
@@ -48,9 +56,7 @@ describe("inGameNameIsValid", () => {
"Sendou#ABCD",
"12345678901#1234",
"𝓔𝓔𝓔𝓔𝓔𝓔𝓔𝓔𝓔𝓔𝓔#1234",
"名前テスト1234#ab12c",
"☆CR☆Sh𝓔𝓔p!#1234",
"日本語#1234",
];
for (const name of invalidNames) {
@@ -72,3 +78,15 @@ describe("inGameNameIsValid", () => {
}
});
});
describe("sanitizeInGameName", () => {
test.each([
["仝#1234", "仝#1234", "kanji outside the character picker"],
["三三三#1234", "三三三#1234", "pasted kanji"],
["한국어#1234", "한국어#1234", "hangul"],
["Cafe\u0301#1234", "Café#1234", "decomposed accents are composed"],
["100%#1234", "100#1234", "characters the game does not allow"],
])("%s -> %s (%s)", (input, expected) => {
expect(sanitizeInGameName(input)).toBe(expected);
});
});

View File

@@ -58,7 +58,7 @@ export const IN_GAME_NAME_CHARACTER_CATEGORIES = [
characters: ReadonlyArray<string>;
}>;
const SPECIAL_CHARACTERS = IN_GAME_NAME_CHARACTER_CATEGORIES.flatMap(
const PICKER_CHARACTERS = IN_GAME_NAME_CHARACTER_CATEGORIES.flatMap(
(category) => category.characters,
);
@@ -69,9 +69,26 @@ const ASCII_CHARACTERS = range(0x20, 0x7e).filter(
const ALLOWED_CHARACTERS = new Set<string>([
...ASCII_CHARACTERS,
...SPECIAL_CHARACTERS,
...PICKER_CHARACTERS,
]);
/**
* Letters of every script a Nintendo Switch keyboard can produce. Notably this
* includes kanji/hanzi and hangul, which are too numerous to enumerate in the
* character picker but are perfectly valid to paste in.
*/
const ALLOWED_SCRIPTS_REGEXP =
/^[\p{Script=Latin}\p{Script=Greek}\p{Script=Cyrillic}\p{Script=Hiragana}\p{Script=Katakana}\p{Script=Han}\p{Script=Hangul}]$/u;
/** Punctuation & symbol code point ranges not covered by any of the scripts above. */
const ALLOWED_CODE_POINT_RANGES = [
[0x3000, 0x303f], // CJK symbols and punctuation
[0x3099, 0x309c], // Kana voiced sound marks
[0x30fb, 0x30fc], // Katakana middle dot & prolonged sound mark
[0xff01, 0xff60], // Fullwidth forms
[0xffe0, 0xffe6], // Fullwidth signs
] as const;
const IN_GAME_NAME_REGEXP = new RegExp(
`^(.+)#([0-9a-z]{${IN_GAME_NAME.DISCRIMINATOR_MIN_LENGTH},${IN_GAME_NAME.DISCRIMINATOR_MAX_LENGTH}})$`,
"u",
@@ -82,14 +99,18 @@ export function inGameNameLength(value: string): number {
return [...value].length;
}
/** Normalizes the in-game name and drops every character the game does not allow. */
export function sanitizeInGameName(value: string): string {
return [...value.normalize("NFC")]
.filter((character) => ALLOWED_CHARACTERS.has(character))
.join("");
return [...normalizeInGameName(value)].filter(characterIsAllowed).join("");
}
/** Unicode normalization applied to in-game names before they are validated or stored. */
export function normalizeInGameName(value: string): string {
return value.normalize("NFC");
}
export function inGameNameIsValid(value: string): boolean {
const match = IN_GAME_NAME_REGEXP.exec(value);
const match = IN_GAME_NAME_REGEXP.exec(normalizeInGameName(value));
if (!match) return false;
const nameCharacters = [...match[1]];
@@ -100,7 +121,17 @@ export function inGameNameIsValid(value: string): boolean {
return false;
}
return nameCharacters.every((character) => ALLOWED_CHARACTERS.has(character));
return nameCharacters.every(characterIsAllowed);
}
function characterIsAllowed(character: string): boolean {
if (ALLOWED_CHARACTERS.has(character)) return true;
if (ALLOWED_SCRIPTS_REGEXP.test(character)) return true;
const codePoint = character.codePointAt(0)!;
return ALLOWED_CODE_POINT_RANGES.some(
([from, to]) => codePoint >= from && codePoint <= to,
);
}
function range(from: number, to: number): string[] {

View File

@@ -3,6 +3,7 @@ import * as v from "valibot";
import {
IN_GAME_NAME_MAX_LENGTH,
inGameNameIsValid,
normalizeInGameName,
} from "~/features/user-page/in-game-name";
import type { MainWeaponId, StageId } from "~/modules/in-game-lists/types";
import { canonicalWeaponSplId } from "~/modules/in-game-lists/weapon-ids";
@@ -253,6 +254,7 @@ export function inGameName(
safeNullableStringSchema({
max: IN_GAME_NAME_MAX_LENGTH,
}),
v.transform((val) => (val === null ? null : normalizeInGameName(val))),
v.check(
(val) => val === null || inGameNameIsValid(val),
"forms:errors.profileInGameName",