diff --git a/backend/internal/handler/game_heroname_test.go b/backend/internal/handler/game_heroname_test.go new file mode 100644 index 0000000..e019d45 --- /dev/null +++ b/backend/internal/handler/game_heroname_test.go @@ -0,0 +1,35 @@ +package handler + +import ( + "strings" + "testing" + "unicode/utf8" + + "golang.org/x/text/unicode/norm" +) + +func TestIsValidHeroName_NFCCombiningY(t *testing.T) { + // Й в NFD: и + U+0306; без NFC вторая руна не входит в блок кириллицы и имя отклонялось. + raw := "\u0438\u0306a" + nfc := norm.NFC.String(strings.TrimSpace(raw)) + if nfc != "\u0439a" && utf8.RuneCountInString(nfc) != 2 { + t.Fatalf("unexpected NFC: %q (%d runes)", nfc, utf8.RuneCountInString(nfc)) + } + if !isValidHeroName(nfc) { + t.Fatal("expected valid name with й (NFC)") + } +} + +func TestIsValidHeroName_runeLengthNotBytes(t *testing.T) { + // 9 кириллических букв = 18 байт UTF-8, но 9 символов — должно быть допустимо. + name := "Абвгдейкz" + if utf8.RuneCountInString(name) != 9 { + t.Fatal("test setup") + } + if len(name) <= 16 { + t.Fatal("test needs >16 bytes with <=16 runes") + } + if !isValidHeroName(name) { + t.Fatal("expected valid: length limit is runes, not UTF-8 bytes") + } +} diff --git a/backend/internal/profanity/heroname.go b/backend/internal/profanity/heroname.go new file mode 100644 index 0000000..fd855a8 --- /dev/null +++ b/backend/internal/profanity/heroname.go @@ -0,0 +1,72 @@ +package profanity + +import ( + "strings" + + goaway "github.com/TwiN/go-away" +) + +var detector *goaway.ProfanityDetector + +func init() { + combined := mergeUniqueWords( + goaway.DefaultProfanities, + russianProfanityFullWords, + latinRussianProfanityFull, + ) + detector = goaway.NewProfanityDetector().WithCustomDictionary( + combined, + goaway.DefaultFalsePositives, + goaway.DefaultFalseNegatives, + ) +} + +func mergeUniqueWords(parts ...[]string) []string { + seen := make(map[string]struct{}, 768) + out := make([]string, 0, 768) + for _, p := range parts { + for _, w := range p { + if w == "" { + continue + } + if _, ok := seen[w]; ok { + continue + } + seen[w] = struct{}{} + out = append(out, w) + } + } + return out +} + +// foldYoToE maps ё→е so dictionary entries using «е» still match nicknames typed with «ё». +func foldYoToE(s string) string { + var b strings.Builder + b.Grow(len(s)) + for _, r := range s { + switch r { + case 'ё', 'Ё': + b.WriteRune('е') + default: + b.WriteRune(r) + } + } + return b.String() +} + +// HeroNameIsProfane: go-away (англ. + leet) + полные русские и латинские формы; две нормализации омоглифов. +func HeroNameIsProfane(name string) bool { + folded := foldYoToE(name) + if detector.IsProfane(folded) { + return true + } + if detector.IsProfane(cyrillicHomoglyphsToLatin(folded)) { + return true + } + if hasCyrillicLetter(folded) { + if detector.IsProfane(latinAndDigitHomoglyphsToCyrillic(folded)) { + return true + } + } + return false +} diff --git a/backend/internal/profanity/heroname_test.go b/backend/internal/profanity/heroname_test.go new file mode 100644 index 0000000..737937d --- /dev/null +++ b/backend/internal/profanity/heroname_test.go @@ -0,0 +1,54 @@ +package profanity + +import "testing" + +func TestHeroNameIsProfane_english(t *testing.T) { + if HeroNameIsProfane("Alice") { + t.Fatal("expected clean name") + } + if !HeroNameIsProfane("fuckface") { + t.Fatal("expected English profanity") + } +} + +func TestHeroNameIsProfane_russianPlain(t *testing.T) { + for _, n := range []string{"ХУЙ", "хуй", "ХуЙ", "хуйло", "пиздец", "сука"} { + if !HeroNameIsProfane(n) { + t.Fatalf("expected profane: %q", n) + } + } +} + +func TestHeroNameIsProfane_khersonNotBlocked(t *testing.T) { + if HeroNameIsProfane("Херсон") { + t.Fatal("did not expect city name to be blocked") + } +} + +func TestHeroNameIsProfane_mixedLatinCyrillic(t *testing.T) { + cases := []string{ + "xуй", // лат. x + кирил. уй + "хyй", // кирил. х + лат. y + кирил. й + "hуй", // лат. h → х + "xyи", // лат. xy + кирил. и + "cука", // лат. c → с + } + for _, n := range cases { + if !HeroNameIsProfane(n) { + t.Fatalf("expected profane (mixed script): %q", n) + } + } +} + +func TestHeroNameIsProfane_digitHomoglyphs(t *testing.T) { + // смешанный ник: кириллица + цифра вместо буквы (3 → з) + if !HeroNameIsProfane("пи3да") { + t.Fatal("expected 3→з to form пизда") + } +} + +func TestHeroNameIsProfane_cyrillicHomoglyphEnglish(t *testing.T) { + if !HeroNameIsProfane("fu\u0441k") { // кириллическая «с» + t.Fatal("expected spoof latin profanity to be caught") + } +} diff --git a/backend/internal/profanity/homoglyphs.go b/backend/internal/profanity/homoglyphs.go new file mode 100644 index 0000000..1846f21 --- /dev/null +++ b/backend/internal/profanity/homoglyphs.go @@ -0,0 +1,96 @@ +package profanity + +import ( + "strings" + "unicode" +) + +// cyrillicHomoglyphsToLatin: кириллица, похожая на латиницу → латиница (обход «fuсk» с кириллической «с»). +func cyrillicHomoglyphsToLatin(s string) string { + var b strings.Builder + b.Grow(len(s)) + for _, r := range s { + if lat, ok := cyrillicToLatinConfusable[r]; ok { + b.WriteRune(lat) + continue + } + b.WriteRune(r) + } + return b.String() +} + +// latinAndDigitHomoglyphsToCyrillic: латиница и цифры, похожие на кириллицу → кириллица. +// Для ников со смешанным вводом (есть хотя бы одна кириллическая буква). +func latinAndDigitHomoglyphsToCyrillic(s string) string { + var b strings.Builder + b.Grow(len(s)) + for _, r := range s { + if cy, ok := latinToCyrillicConfusable[r]; ok { + b.WriteRune(cy) + continue + } + if cy, ok := digitToCyrillicConfusable[r]; ok { + b.WriteRune(cy) + continue + } + b.WriteRune(unicode.ToLower(r)) + } + return b.String() +} + +func hasCyrillicLetter(s string) bool { + for _, r := range s { + if unicode.Is(unicode.Cyrillic, r) { + return true + } + } + return false +} + +var cyrillicToLatinConfusable = map[rune]rune{ + 'А': 'a', 'а': 'a', + 'В': 'b', 'в': 'b', + 'Е': 'e', 'е': 'e', + 'З': '3', 'з': '3', + 'И': 'u', 'и': 'u', + 'К': 'k', 'к': 'k', + 'М': 'm', 'м': 'm', + 'Н': 'h', 'н': 'h', + 'О': 'o', 'о': 'o', + 'Р': 'p', 'р': 'p', + 'С': 'c', 'с': 'c', + 'Т': 't', 'т': 't', + 'У': 'y', 'у': 'y', + 'Х': 'x', 'х': 'x', + 'Ч': '4', 'ч': '4', +} + +var latinToCyrillicConfusable = map[rune]rune{ + 'a': 'а', 'A': 'а', + 'b': 'в', 'B': 'в', + 'c': 'с', 'C': 'с', + 'd': 'д', 'D': 'д', + 'e': 'е', 'E': 'е', + 'f': 'ф', 'F': 'ф', + 'h': 'х', 'H': 'х', + 'i': 'и', 'I': 'и', + 'k': 'к', 'K': 'к', + 'm': 'м', 'M': 'м', + 'o': 'о', 'O': 'о', + 'p': 'р', 'P': 'р', + 's': 'с', 'S': 'с', + 't': 'т', 'T': 'т', + 'u': 'и', 'U': 'и', + 'v': 'в', 'V': 'в', + 'w': 'ш', 'W': 'ш', + 'x': 'х', 'X': 'х', + 'y': 'у', 'Y': 'у', +} + +var digitToCyrillicConfusable = map[rune]rune{ + '0': 'о', + '3': 'з', + '4': 'ч', + '6': 'б', + '8': 'в', +} diff --git a/backend/internal/profanity/latin_russian_words.go b/backend/internal/profanity/latin_russian_words.go new file mode 100644 index 0000000..46d1ae6 --- /dev/null +++ b/backend/internal/profanity/latin_russian_words.go @@ -0,0 +1,22 @@ +package profanity + +// latinRussianProfanityFull — популярные латинские написания и «транслит» русского мата (цельные слова). +var latinRussianProfanityFull = []string{ + "ahuel", "ahuet", "ahui", "ahuj", "ahuy", + "blya", "blyad", "blyat", "blyadina", + "dermo", "dolboeb", "dolboyob", + "ebal", "ebalo", "eban", "ebany", "ebat", "eblan", "eblo", "ebnut", + "gandon", "govno", "govnyuk", + "hren", "hrenov", "hui", "huj", "huy", "hue", "huilo", "huinya", "huesos", + "jebat", "jopa", "mudak", "mudilo", + "nahuja", "nahui", "nahuj", "nahuy", "naher", "nafig", "nafiga", + "ohuel", "ohuet", "ohueno", "oxuenno", "oxuel", + "pedik", "pedril", "pidar", "pidor", "pidoras", "pidrila", + "pizda", "pizdec", "pizdetc", "pizduk", "pizdobol", "pohui", "pohuj", "pohuy", "poher", + "sran", "srat", "suka", "suchka", "svoloch", + "trahat", "trahnut", "traxat", "traxnut", + "ueban", "uebok", "uebishche", "vyeban", "vyebon", + "xren", "xrenya", "xyi", "xyu", "xyulo", "xyesos", + "zaeb", "zaebal", "zaebis", "zalupa", + "yebat", "yeban", "yeblo", +} diff --git a/backend/internal/profanity/russian_full_words.go b/backend/internal/profanity/russian_full_words.go new file mode 100644 index 0000000..341f262 --- /dev/null +++ b/backend/internal/profanity/russian_full_words.go @@ -0,0 +1,29 @@ +package profanity + +// russianProfanityFullWords — цельные слова и устойчивые формы (нижний регистр, «е» вместо «ё»). +// Без коротких корней вроде «пизд», «хер» — чтобы не резать нормальные имена («Херсон» и т.д.). +var russianProfanityFullWords = []string{ + "ахуеть", "ахуенно", "ахуительно", + "блядина", "блядский", "блядство", "блядь", "блять", + "въебать", "выебать", "выебон", "выебывается", + "гандон", "говно", "говнюк", "говнюшка", "говенный", + "дерьмо", "дерьмоед", "долбоеб", "долбоящер", "доебаться", + "ебало", "ебальник", "ебанутый", "ебаный", "ебарь", "ебать", "ебаться", "еблан", "ебло", "ебнутый", "ебучий", + "жопа", "жопник", "жополиз", + "залупа", "залупень", "залупонос", "заебал", "заебись", "заебать", "засранец", + "изъебнуться", + "мразь", "мразота", "мразишка", "мудак", "мудацкий", "мудила", "мудозвон", "мудень", + "нахуй", "нафиг", + "охуеть", "охуенно", "охуительно", "охуевший", + "педик", "педрила", + "пидор", "пидарас", "пидорас", "пидрила", + "пизда", "пиздец", "пиздюк", "пиздобол", "пиздюля", "пиздун", "пиздострадалец", "пиздануть", "пиздатый", + "похуй", "подъебать", "подъебнуть", + "сволочь", "сосунок", "сраный", "срать", "ссанина", "ссыкун", "сука", "сучара", "сученыш", "сучий", + "тварь", "трахать", "трахнуть", + "уебан", "уебище", "уебок", + "херня", "хренов", "хреново", + "хуй", "хуя", "хую", "хуе", "хуи", "хуйня", "хуйло", "хуесос", "хуеглот", "хуеплет", "хуиндец", + "шлюха", "шлюшка", "шлюхин", + "скотина", "ублюдок", +}