From 505af0587481acfb383524048479b4ce52835097 Mon Sep 17 00:00:00 2001 From: Dennis van 't Hul Date: Tue, 25 Aug 2026 22:23:19 +0200 Subject: [PATCH] feat(names): filter display names on a canonical form instead of a hardcoded list MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The display name filter is an inline List in WebSocketController matched with Contains(), so it only catches spellings somebody thought of. Нitler with a Cyrillic Н, HITLER in fullwidth, Hıtler with a dotless i, h.i.t.l.e.r, hiiitler and a name with a zero-width space in it all pass today. The list answers that by enumerating h1tler, h1tl3r, hittler, h1ttler, h1ttl3r by hand, which is a losing game and still only covers ASCII. Three more problems in the same block: the rejection message prints the pattern that matched, which tells the user what to mutate; substring matching with no word mode means the ibra guard rejects Ibrahim; and uniqueness is DisplayName.ToLower() equality, so Rоnin with a Cyrillic о is a distinct string that renders identically in a lobby list. Names are now matched on a canonical form. NameSkeleton normalizes (NFKD, combining marks dropped, UTS #39 confusables folded to ASCII, lowercased) and then skeletonizes (leet fold, repeat runs collapsed, non-alphanumerics removed, capped at the stored length). Rule patterns go through the same fold, so both sides land in the same alphabet and every spelling above reduces to one pattern. Rules live in data/namefilter_rules.txt, one per line as action, match, pattern and category, with four actions (allow, block, review, shadow) and three match types (skeleton substring, word boundary on the normalized text, exact skeleton). They are read at startup and on !namefilter reload, and a rule's id is its line number, so a report points at the line to edit. A malformed line is logged with its line number and skipped; a file that cannot be read leaves the rules already in memory in force. It ships with the ten rules the hardcoded list enforced, the impersonation guards as exact matches, which is what stops them swallowing unrelated names. Around that: a structural gate for length and for control, zero-width and bidi characters; users.displayname_skeleton with uniqueness checked against it; name_filter_rejects instead of naming the rule back to the user; and a per-user rate limit that only counts rules and structural failures, not typos. !namefilter in the admin channel covers test, list, categories, reload, and - for names that predate a rule, since rules only ever applied at name change time - rescan, scanreport and decisions. The scan report is one row per distinct name rather than per account, because one name covers everyone whose name folds onto it. Renaming those accounts is opt in and needs an explicit confirm, and a keep verdict reports an allow line to add to the rules file rather than writing one. Co-Authored-By: Claude Opus 5 --- .../WebSocket/WebSocketController.cs | 155 +- .../Database/Database.NameFilter.cs | 371 ++++ GenOnlineService/Database/Database.User.cs | 13 +- GenOnlineService/Database/Database.cs | 3 + .../migrations/2026-08-24-name-filter.sql | 17 + GenOnlineService/Discord.cs | 246 +++ GenOnlineService/GenOnlineService.csproj | 6 + .../NameFilter/NameFilterRules.cs | 113 ++ .../NameFilter/NameFilterService.cs | 931 +++++++++ GenOnlineService/NameFilter/NameSkeleton.cs | 234 +++ GenOnlineService/Program.cs | 4 + GenOnlineService/data/confusables_ascii.tsv | 1735 +++++++++++++++++ GenOnlineService/data/namefilter_rules.txt | 29 + 13 files changed, 3758 insertions(+), 99 deletions(-) create mode 100644 GenOnlineService/Database/Database.NameFilter.cs create mode 100644 GenOnlineService/Database_Structure/migrations/2026-08-24-name-filter.sql create mode 100644 GenOnlineService/NameFilter/NameFilterRules.cs create mode 100644 GenOnlineService/NameFilter/NameFilterService.cs create mode 100644 GenOnlineService/NameFilter/NameSkeleton.cs create mode 100644 GenOnlineService/data/confusables_ascii.tsv create mode 100644 GenOnlineService/data/namefilter_rules.txt diff --git a/GenOnlineService/Controllers/WebSocket/WebSocketController.cs b/GenOnlineService/Controllers/WebSocket/WebSocketController.cs index 21ab9e0..84d179e 100644 --- a/GenOnlineService/Controllers/WebSocket/WebSocketController.cs +++ b/GenOnlineService/Controllers/WebSocket/WebSocketController.cs @@ -17,6 +17,7 @@ */ using Discord; +using GenOnlineService.NameFilter; using MaxMind.GeoIP2; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; @@ -34,11 +35,13 @@ public class WebSocketController : ControllerBase { private readonly LobbyManager _lobbyManager; private readonly IDbContextFactory _dbFactory; + private readonly NameFilterService _nameFilter; - public WebSocketController(LobbyManager lobbyManager, IDbContextFactory dbFactory) + public WebSocketController(LobbyManager lobbyManager, IDbContextFactory dbFactory, NameFilterService nameFilter) { _lobbyManager = lobbyManager; _dbFactory = dbFactory; + _nameFilter = nameFilter; } private static readonly JsonSerializerOptions JsonOpts = new() @@ -530,59 +533,23 @@ private async Task ProcessWSMessage(UserWebSocketInstance sourceWS, UserSession if (nameChangeRequest != null) { - // TODO: Move this to a file or DB - List lstProtectedNames = new List() - { - "admin", - "staff", - "mass^", - "mas^", - "m4ss^", - "m4s^", - "moderator", - "hitler", - "h1tler", - "h1tl3r", - "hittler", - "h1ttler", - "h1ttl3r", - "olda", - "oldanalytics", - "ibra", - "x64", - "ronin" - }; - - string strNameRequestLower = nameChangeRequest.name.ToLower(); - - // dont allow protected names - if (!sourceUserData.IsAdmin()) + // dont allow numeric (X) endings, those are protected + if (System.Text.RegularExpressions.Regex.IsMatch(nameChangeRequest.name, @"\((1[0-9]|20|[0-9])\)$")) { - foreach (string strProtectedName in lstProtectedNames) - { - if (strNameRequestLower.Contains(strProtectedName)) - { - // response back to user - WebSocketMessage_NetworkRoomChatMessageOutbound outboundMsg = new WebSocketMessage_NetworkRoomChatMessageOutbound(); - outboundMsg.msg_id = (int)EWebSocketMessageID.NETWORK_ROOM_CHAT_FROM_SERVER; - outboundMsg.message = String.Format("--NAME CHANGE-- The display name you tried to set contains a protected word/phrase ({0} - {1})", nameChangeRequest.name, strProtectedName); - outboundMsg.admin = true; // dont care for actions - outboundMsg.action = false; - outboundMsg.name_change = true; - byte[] bytesJSON = Encoding.UTF8.GetBytes(JsonSerializer.Serialize(outboundMsg)); - sourceUserSession.QueueWebsocketSend(bytesJSON); - - return; - } - } + // Remove the protected numeric ending + nameChangeRequest.name = System.Text.RegularExpressions.Regex.Replace(nameChangeRequest.name, @"\((1[0-9]|20|[0-9])\)$", ""); } - if (strNameRequestLower.StartsWith(" ") || strNameRequestLower.EndsWith(" ")) + await using var db = await _dbFactory.CreateDbContextAsync(); + + NameCheck nameCheck = await _nameFilter.CheckNameChange(db, sourceUserSession.m_UserID, nameChangeRequest.name, sourceUserData.IsAdmin()); + + if (!nameCheck.IsAccepted()) { - // response back to user + // response back to user - which rule matched stays server side, see NameFilterService WebSocketMessage_NetworkRoomChatMessageOutbound outboundMsg = new WebSocketMessage_NetworkRoomChatMessageOutbound(); outboundMsg.msg_id = (int)EWebSocketMessageID.NETWORK_ROOM_CHAT_FROM_SERVER; - outboundMsg.message = String.Format("--NAME CHANGE-- Display names cannot begin or end with spaces ({0})", nameChangeRequest.name); + outboundMsg.message = NameFilterService.GetUserMessage(nameCheck, nameChangeRequest.name); outboundMsg.admin = true; // dont care for actions outboundMsg.action = false; outboundMsg.name_change = true; @@ -592,71 +559,63 @@ private async Task ProcessWSMessage(UserWebSocketInstance sourceWS, UserSession return; } - // dont allow numeric (X) endings, those are protected - if (System.Text.RegularExpressions.Regex.IsMatch(nameChangeRequest.name, @"\((1[0-9]|20|[0-9])\)$")) + bool nameSet = await Database.Users.SetDisplayName(db, sourceUserSession.m_UserID, nameChangeRequest.name); + if (nameSet) { - // Remove the protected numeric ending - nameChangeRequest.name = System.Text.RegularExpressions.Regex.Replace(nameChangeRequest.name, @"\((1[0-9]|20|[0-9])\)$", ""); - } + _nameFilter.RegisterAcceptedChange(sourceUserSession.m_UserID); + _nameFilter.ReportForReview(nameCheck, sourceUserSession.m_UserID, nameChangeRequest.name); - if (nameChangeRequest.name.Length >= 3 && nameChangeRequest.name.Length <= 16) - { - await using var db = await _dbFactory.CreateDbContextAsync(); - bool nameSet = await Database.Users.SetDisplayName(db, sourceUserSession.m_UserID, nameChangeRequest.name); - if (nameSet) - { - // response - WebSocketMessage_NetworkRoomChatMessageOutbound outboundMsg = new WebSocketMessage_NetworkRoomChatMessageOutbound(); - outboundMsg.msg_id = (int)EWebSocketMessageID.NETWORK_ROOM_CHAT_FROM_SERVER; + // response + WebSocketMessage_NetworkRoomChatMessageOutbound outboundMsg = new WebSocketMessage_NetworkRoomChatMessageOutbound(); + outboundMsg.msg_id = (int)EWebSocketMessageID.NETWORK_ROOM_CHAT_FROM_SERVER; - outboundMsg.message = String.Format("--NAME CHANGE-- {0} has changed their display name to {1}", sourceUserData.m_strDisplayName, nameChangeRequest.name); - outboundMsg.admin = true; - outboundMsg.action = false; - outboundMsg.name_change = true; + outboundMsg.message = String.Format("--NAME CHANGE-- {0} has changed their display name to {1}", sourceUserData.m_strDisplayName, nameChangeRequest.name); + outboundMsg.admin = true; + outboundMsg.action = false; + outboundMsg.name_change = true; - // Serialize once before broadcasting - byte[] bytesJSON = Encoding.UTF8.GetBytes(JsonSerializer.Serialize(outboundMsg)); + // Serialize once before broadcasting + byte[] bytesJSON = Encoding.UTF8.GetBytes(JsonSerializer.Serialize(outboundMsg)); - // send it to the person doing the name change and everyone in the room - foreach (var sessionDataByClient in WebSocketManager.GetUserDataCache()) + // send it to the person doing the name change and everyone in the room + foreach (var sessionDataByClient in WebSocketManager.GetUserDataCache()) + { + foreach (var sessionData in sessionDataByClient.Value) { - foreach (var sessionData in sessionDataByClient.Value) + UserSession targetSess = sessionData.Value; + if (targetSess.networkRoomID == sourceUserSession.networkRoomID) { - UserSession targetSess = sessionData.Value; - if (targetSess.networkRoomID == sourceUserSession.networkRoomID) + SharedUserData? targetUserSharedData = WebSocketManager.GetSharedDataForUser(targetSess.m_UserID); + + if (targetUserSharedData != null) { - SharedUserData? targetUserSharedData = WebSocketManager.GetSharedDataForUser(targetSess.m_UserID); + // is it blocked by either side? dont deliver the chat + bool bBlocked = targetUserSharedData.GetSocialContainer().Blocked.Contains(sourceUserSession.m_UserID) || + sourceUserData.GetSocialContainer().Blocked.Contains(targetSess.m_UserID); - if (targetUserSharedData != null) + if (!bBlocked) { - // is it blocked by either side? dont deliver the chat - bool bBlocked = targetUserSharedData.GetSocialContainer().Blocked.Contains(sourceUserSession.m_UserID) || - sourceUserData.GetSocialContainer().Blocked.Contains(targetSess.m_UserID); - - if (!bBlocked) - { - targetSess.QueueWebsocketSend(bytesJSON); - } + targetSess.QueueWebsocketSend(bytesJSON); } } } } - - sourceUserData.m_strDisplayName = nameChangeRequest.name; - await WebSocketManager.MarkRoomMemberListAsDirty(sourceUserSession.networkRoomID); - } - else - { - // response back to user - WebSocketMessage_NetworkRoomChatMessageOutbound outboundMsg = new WebSocketMessage_NetworkRoomChatMessageOutbound(); - outboundMsg.msg_id = (int)EWebSocketMessageID.NETWORK_ROOM_CHAT_FROM_SERVER; - outboundMsg.message = String.Format("--NAME CHANGE-- The display name you tried to set is already in use by another user ({0})", nameChangeRequest.name); - outboundMsg.admin = true; // dont care for actions - outboundMsg.action = false; - outboundMsg.name_change = true; - byte[] bytesJSON = Encoding.UTF8.GetBytes(JsonSerializer.Serialize(outboundMsg)); - sourceUserSession.QueueWebsocketSend(bytesJSON); } + + sourceUserData.m_strDisplayName = nameChangeRequest.name; + await WebSocketManager.MarkRoomMemberListAsDirty(sourceUserSession.networkRoomID); + } + else + { + // response back to user + WebSocketMessage_NetworkRoomChatMessageOutbound outboundMsg = new WebSocketMessage_NetworkRoomChatMessageOutbound(); + outboundMsg.msg_id = (int)EWebSocketMessageID.NETWORK_ROOM_CHAT_FROM_SERVER; + outboundMsg.message = String.Format("--NAME CHANGE-- The display name you tried to set is already in use by another user ({0})", nameChangeRequest.name); + outboundMsg.admin = true; // dont care for actions + outboundMsg.action = false; + outboundMsg.name_change = true; + byte[] bytesJSON = Encoding.UTF8.GetBytes(JsonSerializer.Serialize(outboundMsg)); + sourceUserSession.QueueWebsocketSend(bytesJSON); } } } diff --git a/GenOnlineService/Database/Database.NameFilter.cs b/GenOnlineService/Database/Database.NameFilter.cs new file mode 100644 index 0000000..0c67f15 --- /dev/null +++ b/GenOnlineService/Database/Database.NameFilter.cs @@ -0,0 +1,371 @@ +/* +** GeneralsOnline Game Services - Backend Services for Command & Conquer Generals Online: Zero Hour +** Copyright (C) 2025 GeneralsOnline Development Team +** +** This program is free software: you can redistribute it and/or modify +** it under the terms of the GNU Affero General Public License as +** published by the Free Software Foundation, either version 3 of the +** License, or (at your option) any later version. +** +** This program is distributed in the hope that it will be useful, +** but WITHOUT ANY WARRANTY; without even the implied warranty of +** MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +** GNU Affero General Public License for more details. +** +** You should have received a copy of the GNU Affero General Public License +** along with this program. If not, see . +*/ + +using GenOnlineService; +using GenOnlineService.NameFilter; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +public class NameFilterReject +{ + public Int64 ID { get; set; } + + public Int64 UserID { get; set; } + public string AttemptedName { get; set; } = String.Empty; + public string Skeleton { get; set; } = String.Empty; + + public int RuleID { get; set; } = -1; + public ENameRuleAction Action { get; set; } = ENameRuleAction.Block; + public ENameRejectSource Source { get; set; } = ENameRejectSource.NameChange; + public DateTime Created { get; set; } = DateTime.UnixEpoch; +} + +public class NameFilterRejectConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("name_filter_rejects"); + + builder.HasKey(e => e.ID); + + builder.Property(e => e.ID).HasColumnName("id"); + builder.Property(e => e.UserID).HasColumnName("user_id"); + builder.Property(e => e.AttemptedName).HasColumnName("attempted_name").HasColumnType("varchar(64)"); + builder.Property(e => e.Skeleton).HasColumnName("skeleton").HasColumnType("varchar(64)"); + builder.Property(e => e.RuleID).HasColumnName("rule_id"); + builder.Property(e => e.Action).HasColumnName("action").HasColumnType("tinyint(4)"); + builder.Property(e => e.Source).HasColumnName("source").HasColumnType("tinyint(4)"); + builder.Property(e => e.Created).HasColumnName("created"); + } +} + +namespace Database +{ + public static class NameFilter + { + private static string Truncate(string strValue, int maxLength) + { + return strValue.Length <= maxLength ? strValue : strValue.Substring(0, maxLength); + } + + // The accounts a scan row points at. The scan rows are used rather than users. + // displayname_skeleton because they are the exact set the report was written about. + public static async Task> GetScanUserIDsBySkeleton(AppDbContext db, string strSkeleton) + { + try + { + return await db.NameFilterRejects + .Where(r => r.Source == ENameRejectSource.Rescan && r.Skeleton == strSkeleton) + .Select(r => r.UserID) + .Distinct() + .ToListAsync(); + } + catch (Exception ex) + { + Console.WriteLine($"[ERROR] GetScanUserIDsBySkeleton failed: {ex.Message}"); + SentrySdk.CaptureException(ex); + return new List(); + } + } + + public static async Task IsNameOrSkeletonTaken(AppDbContext db, string strName, string strSkeleton) + { + try + { + return await db.Users + .AnyAsync(u => u.DisplayName == strName || (strSkeleton != "" && u.DisplayNameSkeleton == strSkeleton)); + } + catch (Exception ex) + { + Console.WriteLine($"[ERROR] IsNameOrSkeletonTaken failed: {ex.Message}"); + SentrySdk.CaptureException(ex); + return true; + } + } + + // Thousands of hits is not a list anybody reads. One skeleton usually accounts for hundreds + // of accounts and one decision clears all of them, so group by rule and by skeleton. + public static async Task> GetScanSummary(AppDbContext db, Dictionary dictRules) + { + try + { + var groups = await db.NameFilterRejects + .Where(r => r.Source == ENameRejectSource.Rescan) + .GroupBy(r => r.RuleID) + .Select(g => new + { + RuleID = g.Key, + NumHits = g.Count(), + NumSkeletons = g.Select(r => r.Skeleton).Distinct().Count() + }) + .OrderByDescending(g => g.NumHits) + .ToListAsync(); + + List lstResult = new(); + + foreach (var group in groups) + { + dictRules.TryGetValue(group.RuleID, out NameFilterRule? rule); + + lstResult.Add(new NameScanRuleGroup + { + RuleID = group.RuleID, + Pattern = rule != null ? rule.Pattern : "(structural)", + MatchType = rule != null ? rule.MatchType : ENameRuleMatch.Skeleton, + Action = rule != null ? rule.Action : ENameRuleAction.Block, + NumHits = group.NumHits, + NumSkeletons = group.NumSkeletons + }); + } + + return lstResult; + } + catch (Exception ex) + { + Console.WriteLine($"[ERROR] GetScanSummary failed: {ex.Message}"); + SentrySdk.CaptureException(ex); + return new List(); + } + } + + public static async Task> GetScanSkeletons(AppDbContext db, int? ruleID, int limit) + { + try + { + IQueryable query = db.NameFilterRejects + .Where(r => r.Source == ENameRejectSource.Rescan); + + if (ruleID.HasValue) + { + query = query.Where(r => r.RuleID == ruleID.Value); + } + + return await query + .GroupBy(r => r.Skeleton) + .Select(g => new NameScanSkeletonGroup + { + Skeleton = g.Key, + NumUsers = g.Count(), + SampleName = g.Min(r => r.AttemptedName) + }) + .OrderByDescending(g => g.NumUsers) + .Take(limit) + .ToListAsync(); + } + catch (Exception ex) + { + Console.WriteLine($"[ERROR] GetScanSkeletons failed: {ex.Message}"); + SentrySdk.CaptureException(ex); + return new List(); + } + } + + public static async Task> GetScanHits(AppDbContext db, int? ruleID, int limit) + { + try + { + IQueryable query = db.NameFilterRejects + .Where(r => r.Source == ENameRejectSource.Rescan); + + if (ruleID.HasValue) + { + query = query.Where(r => r.RuleID == ruleID.Value); + } + + return await query + .OrderBy(r => r.RuleID) + .ThenBy(r => r.Skeleton) + .Take(limit) + .AsNoTracking() + .ToListAsync(); + } + catch (Exception ex) + { + Console.WriteLine($"[ERROR] GetScanHits failed: {ex.Message}"); + SentrySdk.CaptureException(ex); + return new List(); + } + } + + // A rescan replaces the previous one, otherwise the report mixes decisions that have + // already been acted on with the current state + public static async Task ClearScanHits(AppDbContext db) + { + try + { + await db.NameFilterRejects + .Where(r => r.Source == ENameRejectSource.Rescan) + .ExecuteDeleteAsync(); + } + catch (Exception ex) + { + Console.WriteLine($"[ERROR] ClearScanHits failed: {ex.Message}"); + SentrySdk.CaptureException(ex); + } + } + + // Keyset paged - there are six figures of users, so a rescan must never load them all + public static async Task> GetUsersWithDisplayName(AppDbContext db, Int64 afterUserID, int limit) + { + try + { + return await db.Users + .Where(u => u.ID > afterUserID && u.DisplayName != null && u.DisplayName != "") + .OrderBy(u => u.ID) + .Take(limit) + .AsNoTracking() + .ToListAsync(); + } + catch (Exception ex) + { + Console.WriteLine($"[ERROR] GetUsersWithDisplayName failed: {ex.Message}"); + SentrySdk.CaptureException(ex); + return new List(); + } + } + + public static async Task LogScanHits(AppDbContext db, List lstHits) + { + try + { + if (lstHits.Count == 0) + { + return; + } + + db.NameFilterRejects.AddRange(lstHits); + await db.SaveChangesAsync(); + } + catch (Exception ex) + { + Console.WriteLine($"[ERROR] LogScanHits failed: {ex.Message}"); + SentrySdk.CaptureException(ex); + } + } + + // Deliberately not SetDisplayName: the replacement is generated, not chosen, and must not + // be refused by a rule or a skeleton collision. + public static async Task ForceRename(AppDbContext db, Int64 userID, string newName) + { + try + { + string skeleton = NameSkeleton.Skeletonize(newName); + + int numUpdated = await db.Users + .Where(u => u.ID == userID) + .ExecuteUpdateAsync(setters => setters + .SetProperty(u => u.DisplayName, newName) + .SetProperty(u => u.DisplayNameSkeleton, skeleton) + ); + + return numUpdated > 0; + } + catch (Exception ex) + { + Console.WriteLine($"[ERROR] ForceRename failed: {ex.Message}"); + SentrySdk.CaptureException(ex); + return false; + } + } + + public static async Task LogReject( + AppDbContext db, + Int64 userID, + string strAttemptedName, + string strSkeleton, + int ruleID, + ENameRuleAction action, + ENameRejectSource source) + { + try + { + db.NameFilterRejects.Add(new NameFilterReject + { + UserID = userID, + AttemptedName = Truncate(strAttemptedName, 64), + Skeleton = Truncate(strSkeleton, 64), + RuleID = ruleID, + Action = action, + Source = source, + Created = DateTime.UtcNow + }); + + await db.SaveChangesAsync(); + } + catch (Exception ex) + { + Console.WriteLine($"[ERROR] LogReject failed: {ex.Message}"); + SentrySdk.CaptureException(ex); + } + } + + public static async Task IsSkeletonTaken(AppDbContext db, Int64 userID, string strSkeleton) + { + try + { + if (String.IsNullOrEmpty(strSkeleton)) + { + return false; + } + + return await db.Users + .AnyAsync(u => u.ID != userID && u.DisplayNameSkeleton == strSkeleton); + } + catch (Exception ex) + { + Console.WriteLine($"[ERROR] IsSkeletonTaken failed: {ex.Message}"); + SentrySdk.CaptureException(ex); + return false; + } + } + + // backfills displayname_skeleton for rows that predate the column + public static async Task BackfillSkeletons(AppDbContext db, int maxRows) + { + int numUpdated = 0; + + try + { + // Only NULL is unfilled. An empty skeleton is a real answer for a name made of + // decoration, and matching on it would hand the same rows back every pass. + List lstUsers = await db.Users + .Where(u => u.DisplayName != null && u.DisplayName != "" && u.DisplayNameSkeleton == null) + .Take(maxRows) + .ToListAsync(); + + foreach (User user in lstUsers) + { + user.DisplayNameSkeleton = NameSkeleton.Skeletonize(user.DisplayName ?? String.Empty); + ++numUpdated; + } + + if (numUpdated > 0) + { + await db.SaveChangesAsync(); + } + } + catch (Exception ex) + { + Console.WriteLine($"[ERROR] BackfillSkeletons failed: {ex.Message}"); + SentrySdk.CaptureException(ex); + } + + return numUpdated; + } + } +} diff --git a/GenOnlineService/Database/Database.User.cs b/GenOnlineService/Database/Database.User.cs index 9b06932..e55d2da 100644 --- a/GenOnlineService/Database/Database.User.cs +++ b/GenOnlineService/Database/Database.User.cs @@ -62,6 +62,10 @@ public class User public string? DisplayName { get; set; } = ""; + + // Canonical form of DisplayName, see NameSkeleton. Uniqueness is checked against this so + // homoglyph copies of an existing name collide with the original. + public string? DisplayNameSkeleton { get; set; } = ""; public DateTime LastLogin { get; set; } = DateTime.UnixEpoch; public string? LastIPAddress { get; set; } = String.Empty; public KnownClients.EKnownClients ClientID { get; set; } = KnownClients.EKnownClients.custom_third_party_client; @@ -152,6 +156,7 @@ public void Configure(EntityTypeBuilder builder) builder.Property(e => e.GameReplaysID).HasColumnName("gamereplays_id"); builder.Property(e => e.GameReplaysUsername).HasColumnName("gamereplays_username").HasColumnType("varchar(32)"); ; builder.Property(e => e.DisplayName).HasColumnName("displayname").HasColumnType("varchar(32)"); ; + builder.Property(e => e.DisplayNameSkeleton).HasColumnName("displayname_skeleton").HasColumnType("varchar(32)"); builder.Property(e => e.LastLogin).HasColumnName("lastlogin").HasColumnType("datetime(6)"); builder.Property(e => e.LastIPAddress).HasColumnName("last_ip").HasColumnType("varchar(45)"); ; builder.Property(e => e.ClientID).HasColumnName("client_id"); @@ -600,8 +605,13 @@ public static async Task SetDisplayName(AppDbContext db, long userId, stri { try { + string skeleton = GenOnlineService.NameFilter.NameSkeleton.Skeletonize(newName); + if (skeleton.Length > 32) + skeleton = skeleton.Substring(0, 32); + bool nameTaken = await db.Users - .AnyAsync(u => u.ID != userId && u.DisplayName.ToLower() == newName.ToLower()); + .AnyAsync(u => u.ID != userId && (u.DisplayName.ToLower() == newName.ToLower() || + (skeleton != "" && u.DisplayNameSkeleton == skeleton))); if (nameTaken) return false; @@ -610,6 +620,7 @@ await db.Users .Where(u => u.ID == userId) .ExecuteUpdateAsync(setters => setters .SetProperty(u => u.DisplayName, newName) + .SetProperty(u => u.DisplayNameSkeleton, skeleton) ); return true; diff --git a/GenOnlineService/Database/Database.cs b/GenOnlineService/Database/Database.cs index 9b22772..ee86018 100644 --- a/GenOnlineService/Database/Database.cs +++ b/GenOnlineService/Database/Database.cs @@ -46,6 +46,8 @@ public class AppDbContext : DbContext public DbSet AcReviewsNewAccountGames => Set(); public DbSet AcReviewsProbes => Set(); + public DbSet NameFilterRejects => Set(); + public AppDbContext(DbContextOptions options) : base(options) { @@ -75,5 +77,6 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) modelBuilder.ApplyConfiguration(new AcReviewModuleConfiguration()); modelBuilder.ApplyConfiguration(new AcReviewNewAccountGameConfiguration()); modelBuilder.ApplyConfiguration(new AcReviewProbeConfiguration()); + modelBuilder.ApplyConfiguration(new NameFilterRejectConfiguration()); } } \ No newline at end of file diff --git a/GenOnlineService/Database_Structure/migrations/2026-08-24-name-filter.sql b/GenOnlineService/Database_Structure/migrations/2026-08-24-name-filter.sql new file mode 100644 index 0000000..49297f6 --- /dev/null +++ b/GenOnlineService/Database_Structure/migrations/2026-08-24-name-filter.sql @@ -0,0 +1,17 @@ +CREATE TABLE IF NOT EXISTS `name_filter_rejects` ( + `id` bigint(20) NOT NULL AUTO_INCREMENT, + `user_id` bigint(20) NOT NULL, + `attempted_name` varchar(64) NOT NULL DEFAULT '', + `skeleton` varchar(64) NOT NULL DEFAULT '', + `rule_id` int(11) NOT NULL DEFAULT -1, + `action` tinyint(4) NOT NULL DEFAULT 1, + `source` tinyint(4) NOT NULL DEFAULT 0, + `created` datetime NOT NULL DEFAULT current_timestamp(), + PRIMARY KEY (`id`), + KEY `idx_user` (`user_id`), + KEY `idx_created` (`created`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci; + +ALTER TABLE `users` + ADD COLUMN `displayname_skeleton` varchar(32) DEFAULT NULL AFTER `displayname`, + ADD INDEX `idx_displayname_skeleton` (`displayname_skeleton`); diff --git a/GenOnlineService/Discord.cs b/GenOnlineService/Discord.cs index 6e9c598..734160d 100644 --- a/GenOnlineService/Discord.cs +++ b/GenOnlineService/Discord.cs @@ -23,6 +23,8 @@ using Discord.Rest; using Discord.WebSocket; using GenOnlineService; +using GenOnlineService.NameFilter; +using Microsoft.EntityFrameworkCore; using MySqlX.XDevAPI; using System; using System.Collections.Generic; @@ -680,6 +682,20 @@ private async Task OnMessageReceived(SocketMessage message) } } } + else if (message.Content.ToLower().StartsWith("!namefilter")) + { + if (message.Channel.Id == g_dictChannelIDs[EDiscordChannelIDs.AdminCommands]) + { + if (IsDiscordAdmin(message.Author.Id)) + { + await HandleNameFilterCommand(message); + } + else + { + PushDM(message.Author, "You don't have access to staff commands."); + } + } + } //JSONRequest_PushCommand requestToSend = new JSONRequest_PushCommand(new DiscordUser(message.Author.Id, message.Author.Username), message.Content, enumChannelID); @@ -718,6 +734,215 @@ private async Task OnMessageReceived(SocketMessage message) } } + // ---- Staff commands: display name filter ----------------------------------------------- + + private bool IsDiscordAdmin(UInt64 discordUserID) + { + if (Program.g_Config == null) + { + return false; + } + + List? discord_admins = Program.g_Config.GetSection("Discord").GetSection("discord_admins").Get>(); + + return discord_admins != null && discord_admins.Contains(discordUserID); + } + + private async Task HandleNameFilterCommand(SocketMessage message) + { + string[] strComponents = message.Content.Split(' ', StringSplitOptions.RemoveEmptyEntries); + string strSubCommand = strComponents.Length > 1 ? strComponents[1].ToLower() : "help"; + + NameFilterService nameFilter = ServiceLocator.Services.GetRequiredService(); + + if (strSubCommand == "test") + { + if (strComponents.Length < 3) + { + PushChannelMessage(EDiscordChannelIDs.AdminCommands, "Invalid Command Syntax. !namefilter test "); + return; + } + + string strName = String.Join(' ', strComponents.Skip(2)); + NameCheck check = nameFilter.Check(strName); + + string strVerdict = check.MatchedRule != null + ? $"rule {check.MatchedRule.ID} `{check.MatchedRule.Pattern}` ({check.MatchedRule.MatchType}, {check.MatchedRule.Action}, {check.MatchedRule.Category})" + : "no rule matched"; + + PushChannelMessage(EDiscordChannelIDs.AdminCommands, + $"`{strName}`\nnormalized: `{check.Normalized}`\nskeleton: `{check.Skeleton}`\nresult: {check.Result}\n{strVerdict}"); + return; + } + + if (strSubCommand == "list") + { + string strCategory = strComponents.Length > 2 ? strComponents[2] : String.Empty; + + List lstRules = nameFilter.GetRules(); + if (strCategory.Length > 0) + { + lstRules = lstRules.Where(r => r.Category == strCategory).ToList(); + } + + if (lstRules.Count == 0) + { + PushChannelMessage(EDiscordChannelIDs.AdminCommands, "No rules found."); + return; + } + + // a rule id is its line in data/namefilter_rules.txt, so the listing points at the edit + int numTotal = lstRules.Count; + lstRules = lstRules.Take(30).ToList(); + + string strResults = String.Join("\n", lstRules.Select(r => $"`{r.ID}` {r.Pattern} ({r.MatchType}, {r.Action}, {r.Category})")); + PushChannelMessage(EDiscordChannelIDs.AdminCommands, $"Name filter rules ({lstRules.Count} of {numTotal} shown, ids are line numbers in data/namefilter_rules.txt):\n{strResults}"); + return; + } + + if (strSubCommand == "reload") + { + int numRules = nameFilter.ReloadRules(); + if (numRules < 0) + { + PushChannelMessage(EDiscordChannelIDs.AdminCommands, "Could not read data/namefilter_rules.txt. The rules already loaded stay in force - check the service log."); + return; + } + + PushChannelMessage(EDiscordChannelIDs.AdminCommands, $"Reloaded data/namefilter_rules.txt, {numRules} rules active."); + return; + } + + if (strSubCommand == "rescan") + { + // renaming is destructive and touches accounts that did nothing today, so it needs the + // word rename plus an explicit confirm - a bare rescan only reports + bool bRename = strComponents.Length > 2 && strComponents[2].ToLower() == "rename"; + bool bConfirmed = strComponents.Length > 3 && strComponents[3].ToLower() == "confirm"; + + // renaming can be limited to one rule, so a rule set can be worked through a decision + // at a time instead of all at once + int? renameRuleID = null; + if (strComponents.Length > 5 && strComponents[4].ToLower() == "rule" && Int32.TryParse(strComponents[5], out int scopedRuleID)) + { + renameRuleID = scopedRuleID; + } + + if (bRename && !bConfirmed) + { + PushChannelMessage(EDiscordChannelIDs.AdminCommands, + "!namefilter rescan rename replaces every display name a block rule matches with `Player`. Run `!namefilter rescan` and `!namefilter scanreport` first, then `!namefilter rescan rename confirm [rule ]` to apply it."); + return; + } + + PushChannelMessage(EDiscordChannelIDs.AdminCommands, bRename ? "Rescanning and renaming, this takes a while..." : "Rescanning, this takes a while..."); + + NameScanResult scan = await nameFilter.ScanExistingNames(bRename, renameRuleID, message.Author.Username); + + if (scan.NumHits == 0) + { + PushChannelMessage(EDiscordChannelIDs.AdminCommands, $"Rescanned {scan.NumScanned} display names, no hits."); + return; + } + + string strResults = String.Join("\n", scan.Samples); + PushChannelMessage(EDiscordChannelIDs.AdminCommands, + $"Rescanned {scan.NumScanned} display names, {scan.NumHits} hits, {scan.NumRenamed} renamed ({scan.Samples.Count} shown):\n{strResults}\nRun `!namefilter scanreport` for the breakdown and the CSV."); + return; + } + + if (strSubCommand == "categories") + { + List lstCategories = nameFilter.GetCategories(); + + PushChannelMessage(EDiscordChannelIDs.AdminCommands, + lstCategories.Count == 0 ? "No categories." : $"Categories: {String.Join(", ", lstCategories)}"); + return; + } + + if (strSubCommand == "decisions") + { + if (strComponents.Length < 3) + { + PushChannelMessage(EDiscordChannelIDs.AdminCommands, + "Invalid Command Syntax. !namefilter decisions [confirm]. The file goes in data/namefilter_decisions/ and is the scanreport CSV with a verdict column of keep, remove or unsure. Without confirm this only reports what it would do."); + return; + } + + string strFileName = Path.GetFileName(strComponents[2]); + bool bApply = strComponents.Length > 3 && strComponents[3].ToLower() == "confirm"; + + NameDecisionResult decisions = await nameFilter.ApplyDecisions(strFileName, bApply, message.Author.Username); + + if (!String.IsNullOrEmpty(decisions.Error)) + { + PushChannelMessage(EDiscordChannelIDs.AdminCommands, $"Could not read the decisions: {decisions.Error}"); + return; + } + + string strVerb = bApply ? "Applied" : "Would apply"; + + // the service does not write the rules file, so allow lines come back as text to paste + string strAllow = String.Empty; + if (decisions.AllowLines.Count > 0) + { + strAllow = $"\nAdd these {decisions.AllowLines.Count} lines to data/namefilter_rules.txt and run `!namefilter reload`:\n```\n{String.Join("\n", decisions.AllowLines)}\n```"; + } + + PushChannelMessage(EDiscordChannelIDs.AdminCommands, + $"{strVerb} {strFileName}: {decisions.NumRows} rows, {decisions.NumRenamed} accounts renamed, {decisions.NumUnsure} left for a human, {decisions.NumSkipped} skipped, {decisions.NumFailed} failed." + + (bApply ? String.Empty : "\nRun it again with `confirm` to apply.") + + strAllow); + return; + } + + if (strSubCommand == "scanreport") + { + int? reportRuleID = null; + if (strComponents.Length > 2 && Int32.TryParse(strComponents[2], out int parsedRuleID)) + { + reportRuleID = parsedRuleID; + } + + using var scope = ServiceLocator.Services.CreateScope(); + var factory = scope.ServiceProvider.GetRequiredService>(); + await using var db = await factory.CreateDbContextAsync(); + + List lstGroups = await Database.NameFilter.GetScanSummary(db, nameFilter.GetRuleDictionary()); + if (lstGroups.Count == 0) + { + PushChannelMessage(EDiscordChannelIDs.AdminCommands, "No scan results. Run `!namefilter rescan` first."); + return; + } + + int numTotal = lstGroups.Sum(g => g.NumHits); + + // The breakdown is the decision list: one rule, or one skeleton inside it, usually + // accounts for hundreds of names, so this is tens of decisions rather than thousands. + string strSummary = String.Join("\n", lstGroups.Select(g => + $"rule `{g.RuleID}` {g.Pattern} ({g.MatchType}, {g.Action}) - {g.NumHits} accounts, {g.NumSkeletons} distinct names")); + + string strCsv = await nameFilter.BuildScanCsv(reportRuleID, 5000); + string strFileName = reportRuleID.HasValue ? $"namescan_rule{reportRuleID.Value}.csv" : "namescan.csv"; + + PushChannelFile(EDiscordChannelIDs.AdminCommands, strFileName, strCsv, + $"{numTotal} accounts hit, grouped by rule:\n{strSummary}\nThe CSV is one row per distinct name with a severity, not one per account."); + return; + } + + PushChannelMessage(EDiscordChannelIDs.AdminCommands, + "The rules live in data/namefilter_rules.txt. Edit that file and run `!namefilter reload` to change them.\n" + + "!namefilter test - show the normalized form, the skeleton and the rule that fires\n" + + "!namefilter list [category] - the loaded rules, by their line number in the file\n" + + "!namefilter categories\n" + + "!namefilter reload - re-read data/namefilter_rules.txt\n" + + "!namefilter rescan - run every existing display name through the filter and record the hits\n" + + "!namefilter scanreport [rule id] - breakdown per rule plus a CSV of the distinct names\n" + + "!namefilter decisions [confirm] - read that CSV back with a verdict column filled in\n" + + "!namefilter rescan rename confirm [rule ] - replace the names a block rule matches with Player\n" + + "Match types: skeleton = substring of the canonical form, word = word boundaries in the normalized text, exact = whole canonical form."); + } + private static Task LogAsync(LogMessage log) { Console.WriteLine(log.ToString()); @@ -823,6 +1048,27 @@ public void PushChannelMessage(EDiscordChannelIDs channelID, string strMessage) } } + // For results too long to be a message - a name filter scan can produce tens of thousands of + // rows, which belong in a file the staff can sort, not in the channel. + public void PushChannelFile(EDiscordChannelIDs channelID, string strFileName, string strContents, string strMessage) + { + try + { + ISocketMessageChannel? channel = GetChannel(channelID); + if (channel != null) + { + MemoryStream stream = new MemoryStream(Encoding.UTF8.GetBytes(strContents)); + + channel.SendFileAsync(stream, strFileName, strMessage) + .ContinueWith(t => stream.Dispose()); + } + } + catch + { + + } + } + public async Task PushMessage(SocketUser user, ulong channelToUse, string strMessage) { try diff --git a/GenOnlineService/GenOnlineService.csproj b/GenOnlineService/GenOnlineService.csproj index 29a5013..f6f3d5c 100644 --- a/GenOnlineService/GenOnlineService.csproj +++ b/GenOnlineService/GenOnlineService.csproj @@ -58,6 +58,12 @@ PreserveNewest + + PreserveNewest + + + PreserveNewest + PreserveNewest diff --git a/GenOnlineService/NameFilter/NameFilterRules.cs b/GenOnlineService/NameFilter/NameFilterRules.cs new file mode 100644 index 0000000..c4393fe --- /dev/null +++ b/GenOnlineService/NameFilter/NameFilterRules.cs @@ -0,0 +1,113 @@ +/* +** GeneralsOnline Game Services - Backend Services for Command & Conquer Generals Online: Zero Hour +** Copyright (C) 2025 GeneralsOnline Development Team +** +** This program is free software: you can redistribute it and/or modify +** it under the terms of the GNU Affero General Public License as +** published by the Free Software Foundation, either version 3 of the +** License, or (at your option) any later version. +** +** This program is distributed in the hope that it will be useful, +** but WITHOUT ANY WARRANTY; without even the implied warranty of +** MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +** GNU Affero General Public License for more details. +** +** You should have received a copy of the GNU Affero General Public License +** along with this program. If not, see . +*/ + +using Sentry; + +namespace GenOnlineService.NameFilter +{ + // A pattern plus what to do when a name matches it, loaded from data/namefilter_rules.txt. + public class NameFilterRule + { + // the line this rule is on in the rules file, so a report points at the line to edit + public int ID { get; set; } + + // written in the file as typed, folded for its match type by NameFilterService.Compile + public string Pattern { get; set; } = String.Empty; + + public ENameRuleMatch MatchType { get; set; } = ENameRuleMatch.Skeleton; + public ENameRuleAction Action { get; set; } = ENameRuleAction.Block; + + public string Category { get; set; } = String.Empty; + } + + public static class NameFilterRules + { + // Null means unreadable, which the caller treats differently from an empty file: an empty + // file is somebody clearing the rules on purpose. + public static List? Load() + { + string strPath = Path.Combine("data", "namefilter_rules.txt"); + + try + { + if (!System.IO.File.Exists(strPath)) + { + Console.WriteLine($"[NAMEFILTER] {strPath} does not exist, no rules loaded"); + return new List(); + } + + string[] strLines = System.IO.File.ReadAllLines(strPath); + + List lstRules = new(); + + for (int lineNumber = 1; lineNumber <= strLines.Length; ++lineNumber) + { + string strLine = strLines[lineNumber - 1].Trim(); + if (strLine.Length == 0 || strLine[0] == '#') + { + continue; + } + + // no field may contain whitespace, so a hand-typed space separates as well + string[] strFields = strLine.Split(new[] { '\t', ' ' }, StringSplitOptions.RemoveEmptyEntries); + if (strFields.Length < 3) + { + Console.WriteLine($"[NAMEFILTER] {strPath}:{lineNumber} needs at least action, match and pattern, ignored"); + continue; + } + + // TryParse accepts any number, so IsDefined is what rejects a typo + if (!Enum.TryParse(strFields[0], true, out ENameRuleAction action) || !Enum.IsDefined(action)) + { + Console.WriteLine($"[NAMEFILTER] {strPath}:{lineNumber} '{strFields[0]}' is not allow, block, review or shadow, ignored"); + continue; + } + + if (!Enum.TryParse(strFields[1], true, out ENameRuleMatch matchType) || !Enum.IsDefined(matchType)) + { + Console.WriteLine($"[NAMEFILTER] {strPath}:{lineNumber} '{strFields[1]}' is not skeleton, word or exact, ignored"); + continue; + } + + if (strFields[2].Length > 64) + { + Console.WriteLine($"[NAMEFILTER] {strPath}:{lineNumber} pattern is longer than 64 characters, ignored"); + continue; + } + + lstRules.Add(new NameFilterRule + { + ID = lineNumber, + Pattern = strFields[2], + MatchType = matchType, + Action = action, + Category = strFields.Length > 3 ? strFields[3] : "manual" + }); + } + + return lstRules; + } + catch (Exception ex) + { + Console.WriteLine($"[ERROR] NameFilterRules.Load failed: {ex.Message}"); + SentrySdk.CaptureException(ex); + return null; + } + } + } +} diff --git a/GenOnlineService/NameFilter/NameFilterService.cs b/GenOnlineService/NameFilter/NameFilterService.cs new file mode 100644 index 0000000..b0f9b55 --- /dev/null +++ b/GenOnlineService/NameFilter/NameFilterService.cs @@ -0,0 +1,931 @@ +/* +** GeneralsOnline Game Services - Backend Services for Command & Conquer Generals Online: Zero Hour +** Copyright (C) 2025 GeneralsOnline Development Team +** +** This program is free software: you can redistribute it and/or modify +** it under the terms of the GNU Affero General Public License as +** published by the Free Software Foundation, either version 3 of the +** License, or (at your option) any later version. +** +** This program is distributed in the hope that it will be useful, +** but WITHOUT ANY WARRANTY; without even the implied warranty of +** MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +** GNU Affero General Public License for more details. +** +** You should have received a copy of the GNU Affero General Public License +** along with this program. If not, see . +*/ + +using Microsoft.EntityFrameworkCore; +using System.Collections.Concurrent; +using System.Text; +using System.Text.RegularExpressions; + +namespace GenOnlineService.NameFilter +{ + public enum ENameRuleAction + { + Allow = 0, // allowlist, wins over everything else + Block = 1, // hard reject + Review = 2, // accepted, announced to the admin channel + Shadow = 3 // accepted, logged only (used to trial a rule) + } + + public enum ENameRuleMatch + { + Skeleton = 0, // substring of the skeleton + Word = 1, // word boundaries in the normalized text + Exact = 2 // whole skeleton + } + + public enum ENameRejectSource + { + NameChange = 0, // someone tried to set this name + Rescan = 1 // a name already in the database, found by !namefilter rescan + } + + public enum ENameCheckResult + { + Accepted = 0, + TooShort, + TooLong, + InvalidCharacters, + EmptyName, + SurroundingWhitespace, + Blocked, + NameTaken, + RateLimited + } + + public class NameCheck + { + public ENameCheckResult Result = ENameCheckResult.Accepted; + public string Normalized = String.Empty; + public string Skeleton = String.Empty; + + public NameFilterRule? MatchedRule = null; + + // only set for RateLimited + public int SecondsRemaining = 0; + + public bool IsAccepted() + { + return Result == ENameCheckResult.Accepted; + } + } + + // How much a hit is worth a human's attention. Derived from the rule that fired rather than + // stored, so retuning a rule retunes its hits with it. + public enum ENameSeverity + { + Low = 0, // shadow rules and structural failures - nothing was going to be blocked + Medium = 1, // review rules, and word matches, which are the deliberately cautious ones + High = 2 // a block rule matched + } + + public class NameScanRuleGroup + { + public int RuleID = -1; + public string Pattern = String.Empty; + public ENameRuleMatch MatchType = ENameRuleMatch.Skeleton; + public ENameRuleAction Action = ENameRuleAction.Block; + + public int NumHits = 0; + public int NumSkeletons = 0; + } + + public class NameScanSkeletonGroup + { + public string Skeleton { get; set; } = String.Empty; + public int NumUsers { get; set; } = 0; + public string SampleName { get; set; } = String.Empty; + } + + public class NameDecisionResult + { + public int NumRows = 0; + + // allow lines a verdict of keep asks for, reported for an admin to paste into the rules file + public List AllowLines = new(); + public int NumRenamed = 0; + public int NumUnsure = 0; + public int NumSkipped = 0; + public int NumFailed = 0; + + public string Error = String.Empty; + } + + public class NameScanResult + { + public int NumScanned = 0; + public int NumHits = 0; + public int NumRenamed = 0; + + public List Samples = new(); + } + + public class NameFilterService + { + private const int MinNameLength = 3; + private const int MaxNameLength = 16; + + private const int AcceptedChangeCooldownSeconds = 600; + private const int RejectWindowSeconds = 600; + private const int MaxRejectsPerWindow = 5; + + private const int BackfillBatchSize = 2000; + private const int RateLimitPruneThreshold = 4096; + + private const int ScanBatchSize = 1000; + private const int ScanSampleCount = 20; + private const int ReplacementNameAttempts = 8; + + private class CompiledRule + { + public NameFilterRule Rule = new(); + public string Pattern = String.Empty; + public Regex? WordRegex = null; + } + + private readonly IDbContextFactory m_dbFactory; + + private volatile List m_lstRules = new(); + + private readonly ConcurrentDictionary m_dictLastAcceptedChange = new(); + private readonly ConcurrentDictionary> m_dictRecentRejects = new(); + + public NameFilterService(IDbContextFactory dbFactory) + { + m_dbFactory = dbFactory; + } + + public void Initialize() + { + NameSkeleton.LoadConfusables(); + + ReloadRules(); + + Console.WriteLine($"[NAMEFILTER] {NameSkeleton.GetNumConfusables()} confusables, {m_lstRules.Count} rules"); + + // six figures of rows, too slow to hold up startup, so it runs behind it + _ = Task.Run(BackfillSkeletons); + } + + private async Task BackfillSkeletons() + { + try + { + int numTotal = 0; + + while (true) + { + await using var db = await m_dbFactory.CreateDbContextAsync(); + + int numBackfilled = await global::Database.NameFilter.BackfillSkeletons(db, BackfillBatchSize); + if (numBackfilled == 0) + { + break; + } + + numTotal += numBackfilled; + } + + if (numTotal > 0) + { + Console.WriteLine($"[NAMEFILTER] backfilled {numTotal} display name skeletons"); + } + } + catch (Exception ex) + { + Console.WriteLine($"[ERROR] NameFilterService.BackfillSkeletons failed: {ex.Message}"); + SentrySdk.CaptureException(ex); + } + } + + // Returns the number of rules loaded, or -1 if the file could not be read - keeping the + // rules already in memory rather than turning the filter off. + public int ReloadRules() + { + List? lstRules = NameFilterRules.Load(); + if (lstRules == null) + { + Console.WriteLine($"[ERROR] NameFilterService: could not read the rules, keeping the {m_lstRules.Count} already loaded"); + return -1; + } + + return ApplyRules(lstRules); + } + + private int ApplyRules(List lstRules) + { + List lstCompiled = new(); + + foreach (NameFilterRule rule in lstRules) + { + CompiledRule? compiled = Compile(rule); + if (compiled != null) + { + lstCompiled.Add(compiled); + } + } + + // allowlist first, then hard blocks, so the cheapest decisive answer comes out first + lstCompiled = lstCompiled.OrderBy(r => (int)r.Rule.Action).ToList(); + + m_lstRules = lstCompiled; + + return m_lstRules.Count; + } + + public int GetNumRules() + { + return m_lstRules.Count; + } + + public List GetRules() + { + return m_lstRules.Select(r => r.Rule).ToList(); + } + + // the scan report names the rule behind each group + public Dictionary GetRuleDictionary() + { + return m_lstRules.ToDictionary(r => r.Rule.ID, r => r.Rule); + } + + public List GetCategories() + { + return m_lstRules.Select(r => r.Rule.Category).Distinct().OrderBy(c => c).ToList(); + } + + private static CompiledRule? Compile(NameFilterRule rule) + { + try + { + if (rule.MatchType == ENameRuleMatch.Word) + { + string strPattern = NameSkeleton.Normalize(rule.Pattern); + if (String.IsNullOrEmpty(strPattern)) + { + return null; + } + + // Not RegexOptions.Compiled: this is a literal with two lookarounds, and the + // substring pre-filter in Matches keeps it from running at all in the normal case. + return new CompiledRule + { + Rule = rule, + Pattern = strPattern, + WordRegex = new Regex($"(? MaxNameLength) + { + check.Result = ENameCheckResult.TooLong; + return check; + } + + foreach (char c in strName) + { + if (Char.IsControl(c) || NameSkeleton.IsInvisible(c)) + { + check.Result = ENameCheckResult.InvalidCharacters; + return check; + } + + // anything that is not a plain space but still whitespace is a separator trick + if (Char.IsWhiteSpace(c) && c != ' ') + { + check.Result = ENameCheckResult.InvalidCharacters; + return check; + } + } + + check.Normalized = NameSkeleton.Normalize(strName); + check.Skeleton = NameSkeleton.Skeletonize(strName); + + // Checked on the normalized form, not the skeleton: the skeleton alphabet is ASCII, so + // a name in a script that does not fold onto it is empty there and still a good name. + if (!check.Normalized.Any(Char.IsLetterOrDigit)) + { + check.Result = ENameCheckResult.EmptyName; + return check; + } + + foreach (CompiledRule compiled in m_lstRules) + { + if (!Matches(compiled, check)) + { + continue; + } + + if (compiled.Rule.Action == ENameRuleAction.Allow) + { + return check; + } + + check.MatchedRule = compiled.Rule; + + if (compiled.Rule.Action == ENameRuleAction.Block) + { + check.Result = ENameCheckResult.Blocked; + } + + // Review and Shadow leave the result Accepted, the caller reports them + return check; + } + + return check; + } + + public static ENameSeverity GetSeverity(NameCheck check) + { + if (check.MatchedRule == null) + { + // structural failure - odd characters, nothing anyone chose to ban + return ENameSeverity.Low; + } + + switch (check.MatchedRule.Action) + { + case ENameRuleAction.Block: + // word rules are the cautious ones, they were written to avoid over-matching + return check.MatchedRule.MatchType == ENameRuleMatch.Word ? ENameSeverity.Medium : ENameSeverity.High; + + case ENameRuleAction.Review: + return ENameSeverity.Medium; + + default: + return ENameSeverity.Low; + } + } + + private static bool Matches(CompiledRule compiled, NameCheck check) + { + switch (compiled.Rule.MatchType) + { + case ENameRuleMatch.Exact: + return check.Skeleton == compiled.Pattern; + + case ENameRuleMatch.Word: + // the pattern has to be present at all before its boundaries can matter, and a + // rescan runs every rule against every name in the database + return compiled.WordRegex != null + && check.Normalized.Contains(compiled.Pattern, StringComparison.Ordinal) + && compiled.WordRegex.IsMatch(check.Normalized); + + default: + return check.Skeleton.Contains(compiled.Pattern, StringComparison.Ordinal); + } + } + + // Full name change check: rate limit, rules, skeleton uniqueness. Logs anything that is + // not a clean accept. + public async Task CheckNameChange(AppDbContext db, Int64 userID, string strName, bool bIsAdmin) + { + if (!bIsAdmin) + { + int secondsRemaining = GetRateLimitRemaining(userID); + if (secondsRemaining > 0) + { + return new NameCheck + { + Result = ENameCheckResult.RateLimited, + SecondsRemaining = secondsRemaining + }; + } + } + + NameCheck check = Check(strName); + + // admins are exempt from the rules, not from the structural gate + if (bIsAdmin && check.Result == ENameCheckResult.Blocked) + { + check.Result = ENameCheckResult.Accepted; + } + + if (check.IsAccepted() && await global::Database.NameFilter.IsSkeletonTaken(db, userID, check.Skeleton)) + { + check.Result = ENameCheckResult.NameTaken; + } + + // only bypass hunting counts towards the cooldown - a typo or a taken name should not + // lock someone out + if (check.Result == ENameCheckResult.Blocked || + check.Result == ENameCheckResult.InvalidCharacters || + check.Result == ENameCheckResult.EmptyName) + { + RegisterReject(userID); + } + + if (!check.IsAccepted() || check.MatchedRule != null) + { + // The action recorded is what happened, not what the rule says: a rule the admin + // bypass let through was matched and allowed, which is what Shadow means. + ENameRuleAction loggedAction = ENameRuleAction.Block; + + if (check.MatchedRule != null) + { + loggedAction = check.MatchedRule.Action; + + if (check.IsAccepted() && loggedAction == ENameRuleAction.Block) + { + loggedAction = ENameRuleAction.Shadow; + } + } + + await global::Database.NameFilter.LogReject( + db, + userID, + strName, + check.Skeleton, + check.MatchedRule != null ? check.MatchedRule.ID : -1, + loggedAction, + ENameRejectSource.NameChange); + } + + return check; + } + + // A Review rule does not block the name, it just makes sure a moderator sees it. + public void ReportForReview(NameCheck check, Int64 userID, string strName) + { + if (check.MatchedRule == null || check.MatchedRule.Action != ENameRuleAction.Review) + { + return; + } + + if (Program.g_Discord == null) + { + return; + } + + Program.g_Discord.PushChannelMessage(EDiscordChannelIDs.AdminCommands, + $"--NAME FILTER-- user {userID} took the display name `{strName}` (skeleton `{check.Skeleton}`), flagged for review by rule {check.MatchedRule.ID} (`{check.MatchedRule.Pattern}`, {check.MatchedRule.Category})."); + } + + public void RegisterAcceptedChange(Int64 userID) + { + m_dictLastAcceptedChange[userID] = DateTime.UtcNow; + m_dictRecentRejects.TryRemove(userID, out _); + + PruneRateLimitState(); + } + + // Entries are dead once their window has passed, but nothing walks them, so on a service + // that stays up for months they would only ever grow. + private void PruneRateLimitState() + { + if (m_dictLastAcceptedChange.Count < RateLimitPruneThreshold && m_dictRecentRejects.Count < RateLimitPruneThreshold) + { + return; + } + + DateTime now = DateTime.UtcNow; + + foreach (var kvPair in m_dictLastAcceptedChange) + { + if ((now - kvPair.Value).TotalSeconds >= AcceptedChangeCooldownSeconds) + { + m_dictLastAcceptedChange.TryRemove(kvPair.Key, out _); + } + } + + foreach (var kvPair in m_dictRecentRejects) + { + List lstRejects = kvPair.Value; + + lock (lstRejects) + { + lstRejects.RemoveAll(t => (now - t).TotalSeconds >= RejectWindowSeconds); + + if (lstRejects.Count == 0) + { + m_dictRecentRejects.TryRemove(kvPair.Key, out _); + } + } + } + } + + private void RegisterReject(Int64 userID) + { + List lstRejects = m_dictRecentRejects.GetOrAdd(userID, _ => new List()); + + lock (lstRejects) + { + DateTime cutoff = DateTime.UtcNow.AddSeconds(-RejectWindowSeconds); + lstRejects.RemoveAll(t => t < cutoff); + lstRejects.Add(DateTime.UtcNow); + } + } + + private int GetRateLimitRemaining(Int64 userID) + { + DateTime now = DateTime.UtcNow; + + if (m_dictLastAcceptedChange.TryGetValue(userID, out DateTime lastChange)) + { + double elapsed = (now - lastChange).TotalSeconds; + if (elapsed < AcceptedChangeCooldownSeconds) + { + return (int)Math.Ceiling(AcceptedChangeCooldownSeconds - elapsed); + } + } + + if (m_dictRecentRejects.TryGetValue(userID, out List? lstRejects) && lstRejects != null) + { + lock (lstRejects) + { + DateTime cutoff = now.AddSeconds(-RejectWindowSeconds); + lstRejects.RemoveAll(t => t < cutoff); + + if (lstRejects.Count >= MaxRejectsPerWindow) + { + double elapsed = (now - lstRejects[0]).TotalSeconds; + return (int)Math.Ceiling(RejectWindowSeconds - elapsed); + } + } + } + + return 0; + } + + // What the user is told. Deliberately does not name the rule that fired - that goes to + // name_filter_rejects, where it cannot be used to hunt for a bypass. + public static string GetUserMessage(NameCheck check, string strRequestedName) + { + switch (check.Result) + { + case ENameCheckResult.TooShort: + return String.Format("--NAME CHANGE-- Display names must be at least {0} characters ({1})", MinNameLength, strRequestedName); + + case ENameCheckResult.TooLong: + return String.Format("--NAME CHANGE-- Display names can be at most {0} characters ({1})", MaxNameLength, strRequestedName); + + case ENameCheckResult.SurroundingWhitespace: + return String.Format("--NAME CHANGE-- Display names cannot begin or end with spaces ({0})", strRequestedName); + + case ENameCheckResult.InvalidCharacters: + case ENameCheckResult.EmptyName: + return String.Format("--NAME CHANGE-- The display name you tried to set contains characters that are not allowed ({0})", strRequestedName); + + case ENameCheckResult.NameTaken: + return String.Format("--NAME CHANGE-- That display name is too close to one already in use ({0})", strRequestedName); + + case ENameCheckResult.RateLimited: + return String.Format("--NAME CHANGE-- You are changing your display name too often, try again in {0} seconds", check.SecondsRemaining); + + default: + return String.Format("--NAME CHANGE-- The display name you tried to set is not allowed ({0})", strRequestedName); + } + } + + // Rules only apply at name change time, so names that predate a rule are invisible until this + // looks at them. With bRename, Block matches are renamed; everything else is reported only. + public async Task ScanExistingNames(bool bRename, int? renameRuleID, string strActor) + { + NameScanResult result = new NameScanResult(); + + // a scan replaces the previous one, so the report is never a mix of old and current + await using (var clearDb = await m_dbFactory.CreateDbContextAsync()) + { + await global::Database.NameFilter.ClearScanHits(clearDb); + } + + // keyset paged - six figures of users, so never load them all + Int64 afterUserID = 0; + + while (true) + { + await using var db = await m_dbFactory.CreateDbContextAsync(); + + List lstUsers = await global::Database.NameFilter.GetUsersWithDisplayName(db, afterUserID, ScanBatchSize); + if (lstUsers.Count == 0) + { + break; + } + + afterUserID = lstUsers[lstUsers.Count - 1].ID; + result.NumScanned += lstUsers.Count; + + List lstHits = new(); + + foreach (User user in lstUsers) + { + string strName = user.DisplayName ?? String.Empty; + NameCheck check = Check(strName); + + // a clean name. Review and Shadow are accepted but carry a rule, so they stay + if (check.IsAccepted() && check.MatchedRule == null) + { + continue; + } + + ++result.NumHits; + + lstHits.Add(new NameFilterReject + { + UserID = user.ID, + AttemptedName = strName, + Skeleton = check.Skeleton, + RuleID = check.MatchedRule != null ? check.MatchedRule.ID : -1, + Action = check.MatchedRule != null ? check.MatchedRule.Action : ENameRuleAction.Block, + Source = ENameRejectSource.Rescan, + Created = DateTime.UtcNow + }); + + bool bRenamed = false; + + // a rename can be scoped to one rule, so a rule set is worked through one decision at a time + bool bInRenameScope = renameRuleID == null || (check.MatchedRule != null && check.MatchedRule.ID == renameRuleID.Value); + + if (bRename && bInRenameScope && check.Result == ENameCheckResult.Blocked) + { + string strReplacement = await GenerateReplacementName(db); + + bRenamed = !String.IsNullOrEmpty(strReplacement) && await global::Database.NameFilter.ForceRename(db, user.ID, strReplacement); + if (bRenamed) + { + ++result.NumRenamed; + Console.WriteLine($"[NAMEFILTER] {strActor} renamed user {user.ID} from '{strName}' to '{strReplacement}' (rule {(check.MatchedRule != null ? check.MatchedRule.ID : -1)})"); + } + } + + if (result.Samples.Count < ScanSampleCount) + { + result.Samples.Add($"`{user.ID}` {strName} ({check.Result}, rule {(check.MatchedRule != null ? check.MatchedRule.ID : -1)}{(bRenamed ? ", renamed" : String.Empty)})"); + } + } + + await global::Database.NameFilter.LogScanHits(db, lstHits); + } + + return result; + } + + // GeneralX, X random rather than derived from the account, so the new name does not + // advertise which accounts were renamed. Uniqueness is checked because random collides. + public async Task GenerateReplacementName(AppDbContext db) + { + for (int attempt = 0; attempt < ReplacementNameAttempts; ++attempt) + { + string strCandidate = String.Format("General{0}", Random.Shared.Next(1000, 1000000)); + + if (!await global::Database.NameFilter.IsNameOrSkeletonTaken(db, strCandidate, NameSkeleton.Skeletonize(strCandidate))) + { + return strCandidate; + } + } + + return String.Empty; + } + + // The scanreport CSV read back with a verdict column filled in: keep reports an allow line for + // the rules file, remove renames every account under that name, unsure is left alone. + public async Task ApplyDecisions(string strFileName, bool bApply, string strActor) + { + NameDecisionResult result = new NameDecisionResult(); + + string strPath = Path.Combine("data", "namefilter_decisions", strFileName); + if (!System.IO.File.Exists(strPath)) + { + result.Error = $"{strPath} does not exist"; + return result; + } + + List> lstRows = ReadCsv(await System.IO.File.ReadAllLinesAsync(strPath)); + if (lstRows.Count == 0) + { + result.Error = "the file has no rows"; + return result; + } + + await using var db = await m_dbFactory.CreateDbContextAsync(); + + foreach (Dictionary dictRow in lstRows) + { + ++result.NumRows; + + dictRow.TryGetValue("verdict", out string? strVerdict); + dictRow.TryGetValue("sample_name", out string? strName); + dictRow.TryGetValue("skeleton", out string? strSkeleton); + + if (String.IsNullOrEmpty(strName) || String.IsNullOrEmpty(strSkeleton)) + { + ++result.NumSkipped; + continue; + } + + switch ((strVerdict ?? String.Empty).Trim().ToLowerInvariant()) + { + case "keep": + result.AllowLines.Add($"allow\texact\t{strName}\ttriage"); + break; + + case "remove": + List lstUserIDs = await global::Database.NameFilter.GetScanUserIDsBySkeleton(db, strSkeleton); + result.NumRenamed += lstUserIDs.Count; + + // counted either way, applied only on confirm + if (bApply) + { + foreach (Int64 userID in lstUserIDs) + { + string strReplacement = await GenerateReplacementName(db); + if (String.IsNullOrEmpty(strReplacement)) + { + ++result.NumFailed; + continue; + } + + if (await global::Database.NameFilter.ForceRename(db, userID, strReplacement)) + { + Console.WriteLine($"[NAMEFILTER] {strActor} renamed user {userID} from '{strName}' to '{strReplacement}' (decision file {strFileName})"); + } + else + { + ++result.NumFailed; + } + } + } + break; + + case "unsure": + ++result.NumUnsure; + break; + + default: + ++result.NumSkipped; + break; + } + } + + return result; + } + + private static List> ReadCsv(string[] strLines) + { + List> lstRows = new(); + + if (strLines.Length < 2) + { + return lstRows; + } + + List lstHeaders = SplitCsvLine(strLines[0]); + + for (int i = 1; i < strLines.Length; ++i) + { + if (strLines[i].Trim().Length == 0) + { + continue; + } + + List lstFields = SplitCsvLine(strLines[i]); + Dictionary dictRow = new(); + + for (int field = 0; field < lstHeaders.Count && field < lstFields.Count; ++field) + { + dictRow[lstHeaders[field].Trim().ToLowerInvariant()] = lstFields[field]; + } + + lstRows.Add(dictRow); + } + + return lstRows; + } + + private static List SplitCsvLine(string strLine) + { + List lstFields = new(); + StringBuilder builder = new StringBuilder(); + bool bInQuotes = false; + + for (int i = 0; i < strLine.Length; ++i) + { + char c = strLine[i]; + + if (bInQuotes) + { + if (c == '"') + { + if (i + 1 < strLine.Length && strLine[i + 1] == '"') + { + builder.Append('"'); + ++i; + } + else + { + bInQuotes = false; + } + } + else + { + builder.Append(c); + } + } + else if (c == '"') + { + bInQuotes = true; + } + else if (c == ',') + { + lstFields.Add(builder.ToString()); + builder.Clear(); + } + else + { + builder.Append(c); + } + } + + lstFields.Add(builder.ToString()); + + return lstFields; + } + + // One row per distinct skeleton rather than per user - a decision on a skeleton covers + // every account that folds onto it. + public async Task BuildScanCsv(int? ruleID, int limit) + { + await using var db = await m_dbFactory.CreateDbContextAsync(); + + List lstGroups = await global::Database.NameFilter.GetScanSkeletons(db, ruleID, limit); + + StringBuilder builder = new StringBuilder(); + // verdict is left empty on purpose - this file is meant to come back with it filled in + builder.AppendLine("skeleton,sample_name,accounts,severity,rule_id,rule_pattern,rule_category,match_type,verdict"); + + foreach (NameScanSkeletonGroup group in lstGroups) + { + NameCheck check = Check(group.SampleName); + + builder.Append(CsvField(group.Skeleton)).Append(','); + builder.Append(CsvField(group.SampleName)).Append(','); + builder.Append(group.NumUsers).Append(','); + builder.Append(GetSeverity(check)).Append(','); + builder.Append(check.MatchedRule != null ? check.MatchedRule.ID : -1).Append(','); + builder.Append(CsvField(check.MatchedRule != null ? check.MatchedRule.Pattern : String.Empty)).Append(','); + builder.Append(CsvField(check.MatchedRule != null ? check.MatchedRule.Category : "structural")).Append(','); + builder.Append(check.MatchedRule != null ? check.MatchedRule.MatchType.ToString() : check.Result.ToString()); + builder.Append(','); + builder.AppendLine(); + } + + return builder.ToString(); + } + + private static string CsvField(string strValue) + { + // names can contain commas, quotes and the odd control character + string strClean = strValue.Replace("\r", String.Empty).Replace("\n", " "); + + return "\"" + strClean.Replace("\"", "\"\"") + "\""; + } + } +} diff --git a/GenOnlineService/NameFilter/NameSkeleton.cs b/GenOnlineService/NameFilter/NameSkeleton.cs new file mode 100644 index 0000000..2f648a3 --- /dev/null +++ b/GenOnlineService/NameFilter/NameSkeleton.cs @@ -0,0 +1,234 @@ +/* +** GeneralsOnline Game Services - Backend Services for Command & Conquer Generals Online: Zero Hour +** Copyright (C) 2025 GeneralsOnline Development Team +** +** This program is free software: you can redistribute it and/or modify +** it under the terms of the GNU Affero General Public License as +** published by the Free Software Foundation, either version 3 of the +** License, or (at your option) any later version. +** +** This program is distributed in the hope that it will be useful, +** but WITHOUT ANY WARRANTY; without even the implied warranty of +** MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +** GNU Affero General Public License for more details. +** +** You should have received a copy of the GNU Affero General Public License +** along with this program. If not, see . +*/ + +using System.Globalization; +using System.Text; + +namespace GenOnlineService.NameFilter +{ + // Canonical forms of a display name. Rule patterns and candidate names both go through this, + // so both sides land in the same alphabet. + public static class NameSkeleton + { + // A confusable can expand to several ASCII characters, so a name inside the 16 character + // limit can still fold to more than this. + public const int MaxSkeletonLength = 32; + + // Unicode confusable -> ASCII, from UTS #39. ASCII sources are not in here, the leet fold + // covers those. See data/confusables_ascii.tsv for the source and the reduction rule. + private static Dictionary g_dictConfusables = new(); + private static bool g_bConfusablesLoaded = false; + + // Applied after the confusable fold, to rules and names alike. Lossy on purpose. + private static readonly Dictionary g_dictLeet = new() + { + { '1', 'i' }, { 'l', 'i' }, { '|', 'i' }, { '!', 'i' }, + { '0', 'o' }, + { '3', 'e' }, + { '4', 'a' }, { '@', 'a' }, + { '5', 's' }, { '$', 's' }, + { '7', 't' }, + { '8', 'b' } + }; + + public static void LoadConfusables() + { + if (g_bConfusablesLoaded) + { + return; + } + + g_bConfusablesLoaded = true; + + try + { + string strPath = Path.Combine("data", "confusables_ascii.tsv"); + if (!System.IO.File.Exists(strPath)) + { + Console.WriteLine($"[ERROR] NameSkeleton: {strPath} is missing, homoglyph folding is disabled"); + return; + } + + Dictionary dictMappings = new(); + + foreach (string strLine in System.IO.File.ReadLines(strPath)) + { + if (strLine.Length == 0 || strLine[0] == '#') + { + continue; + } + + string[] strParts = strLine.Split('\t'); + if (strParts.Length != 2) + { + continue; + } + + if (Int32.TryParse(strParts[0], NumberStyles.HexNumber, CultureInfo.InvariantCulture, out int codepoint)) + { + dictMappings[codepoint] = strParts[1]; + } + } + + g_dictConfusables = dictMappings; + } + catch (Exception ex) + { + Console.WriteLine($"[ERROR] NameSkeleton.LoadConfusables failed: {ex.Message}"); + SentrySdk.CaptureException(ex); + } + } + + public static int GetNumConfusables() + { + return g_dictConfusables.Count; + } + + // NFKD, combining marks dropped, confusables folded to ASCII, lowercased. Separators are + // kept - word matching needs them. + public static string Normalize(string strName) + { + if (String.IsNullOrEmpty(strName)) + { + return String.Empty; + } + + string strDecomposed; + try + { + strDecomposed = strName.Normalize(NormalizationForm.FormKD); + } + catch (ArgumentException) + { + // unpaired surrogates and other malformed input + strDecomposed = strName; + } + + StringBuilder builder = new StringBuilder(strDecomposed.Length); + + for (int i = 0; i < strDecomposed.Length; ++i) + { + bool bSurrogatePair = Char.IsSurrogatePair(strDecomposed, i); + if (!bSurrogatePair && Char.IsSurrogate(strDecomposed[i])) + { + // unpaired surrogate, not a character + continue; + } + + UnicodeCategory category = CharUnicodeInfo.GetUnicodeCategory(strDecomposed, i); + int codepoint = Char.ConvertToUtf32(strDecomposed, i); + + if (bSurrogatePair) + { + ++i; + } + + if (IsInvisible(codepoint) || category == UnicodeCategory.NonSpacingMark) + { + continue; + } + + if (g_dictConfusables.TryGetValue(codepoint, out string? strMapped)) + { + builder.Append(strMapped); + } + else + { + builder.Append(Char.ConvertFromUtf32(codepoint)); + } + } + + return builder.ToString().ToLowerInvariant().Trim(); + } + + // Normalized form plus the leet fold, repeat runs collapsed, everything outside [a-z0-9] + // removed. This is what substring rules match against. + public static string Skeletonize(string strName) + { + string strNormalized = Normalize(strName); + + StringBuilder builder = new StringBuilder(strNormalized.Length); + char lastAppended = '\0'; + + foreach (char c in strNormalized) + { + char folded = g_dictLeet.TryGetValue(c, out char mapped) ? mapped : c; + + if (!((folded >= 'a' && folded <= 'z') || (folded >= '0' && folded <= '9'))) + { + continue; + } + + if (folded == lastAppended) + { + continue; + } + + builder.Append(folded); + lastAppended = folded; + + if (builder.Length == MaxSkeletonLength) + { + break; + } + } + + return builder.ToString(); + } + + public static bool IsInvisible(int codepoint) + { + // zero width space/non-joiner/joiner, LRM/RLM, word joiner, BOM + if (codepoint >= 0x200B && codepoint <= 0x200F) + { + return true; + } + + // bidi embedding/override + if (codepoint >= 0x202A && codepoint <= 0x202E) + { + return true; + } + + // bidi isolates + if (codepoint >= 0x2066 && codepoint <= 0x2069) + { + return true; + } + + if (codepoint == 0x2060 || codepoint == 0xFEFF || codepoint == 0x00AD || codepoint == 0x180E) + { + return true; + } + + // variation selectors + if (codepoint >= 0xFE00 && codepoint <= 0xFE0F) + { + return true; + } + + // tag characters + if (codepoint >= 0xE0000 && codepoint <= 0xE007F) + { + return true; + } + + return false; + } + } +} diff --git a/GenOnlineService/Program.cs b/GenOnlineService/Program.cs index e2d54e4..fd44c1b 100644 --- a/GenOnlineService/Program.cs +++ b/GenOnlineService/Program.cs @@ -16,6 +16,7 @@ ** along with this program. If not, see . */ +using GenOnlineService.NameFilter; using Google.Protobuf.WellKnownTypes; using MaxMind.GeoIP2; using Microsoft.AspNetCore.Authentication; @@ -896,6 +897,7 @@ public static async Task Main(string[] args) } builder.Services.AddSingleton(); + builder.Services.AddSingleton(); var rateLimitingSettings = Program.g_Config.GetSection("RateLimiting"); bool bUseBuiltinRateLimiter = rateLimitingSettings.GetValue("use_builtin_ratelimiter"); // use built in Kestrel/dotnet rate limiting if you do not have a reverse proxy or other rate limiter in front of service @@ -1195,6 +1197,8 @@ public static async Task Main(string[] args) var app = builder.Build(); ServiceLocator.Services = app.Services; + app.Services.GetRequiredService().Initialize(); + if (bUseBuiltinRateLimiter) { app.UseRateLimiter(); diff --git a/GenOnlineService/data/confusables_ascii.tsv b/GenOnlineService/data/confusables_ascii.tsv new file mode 100644 index 0000000..a26f242 --- /dev/null +++ b/GenOnlineService/data/confusables_ascii.tsv @@ -0,0 +1,1735 @@ +# Reduced from the Unicode UTS #39 confusables.txt data file: +# https://www.unicode.org/Public/security/latest/confusables.txt +# Kept: single codepoint sources, non-ASCII, whose prototype folds (NFKD, marks stripped, +# lowercased) to plain ASCII alphanumerics. Format: +# Unicode data is used under the Unicode License: https://www.unicode.org/license.txt +00A2 c +00A5 y +00C6 ae +00C7 c +00D0 d +00D7 x +00D8 o +00E6 ae +00E7 c +00F8 o +00FE p +0110 d +0111 d +011A e +011B e +0126 h +0127 h +0131 i +0132 lj +0133 ij +0141 l +0142 l +014B n +0150 o +0152 oe +0153 oe +0166 t +0167 t +017F f +0180 b +0182 b +0183 b +0184 b +0189 d +018C d +018D g +0191 f +0192 f +0196 l +0197 l +0199 k +019A l +019D n +019E n +019F o +01A5 p +01A6 r +01A7 2 +01AD t +01AE t +01B4 y +01B5 z +01B6 z +01B7 3 +01BB 2 +01BC 5 +01BD s +01BF p +01C0 l +01C1 ll +01C4 dz +01C5 dz +01C6 dz +01C7 lj +01C8 lj +01C9 lj +01CA nj +01CB nj +01CC nj +01CD a +01CE a +01CF i +01D0 i +01D1 o +01D2 o +01D3 u +01D4 u +01E4 g +01E5 g +01E6 g +01E7 g +01F1 dz +01F2 dz +01F3 dz +01F5 g +01FE o +021A t +021C 3 +0222 8 +0223 8 +0224 z +0225 z +0226 a +0227 a +023C c +023E t +0244 u +0246 e +0247 e +0248 j +0249 j +024D r +024E y +024F y +0251 a +0253 b +0256 d +0257 d +0260 g +0261 g +0263 y +0266 h +0268 i +0269 i +026A i +026B l +026D l +026F w +0271 rn +0273 n +0275 o +027C r +027D r +0282 s +028B u +028F y +0290 z +02A0 q +02A3 dz +02A6 ts +02A9 fn +02AA ls +02AB lz +02DB i +037A i +037F j +0391 a +0392 b +0395 e +0396 z +0397 h +0398 o +0399 l +039A k +039C m +039D n +039F o +03A1 p +03A4 t +03A5 y +03A7 x +03B1 a +03B3 y +03B7 n +03B8 o +03B9 i +03BD v +03BF o +03C1 p +03C3 o +03C5 u +03D1 o +03D2 y +03DC f +03E8 2 +03EC 6 +03ED o +03F1 p +03F2 c +03F3 j +03F4 o +03F8 p +03F9 c +03FA m +0405 s +0406 l +0408 j +0410 a +0411 b +0412 b +0415 e +0417 3 +041A k +041C m +041D h +041E o +0420 p +0421 c +0422 t +0423 y +0425 x +042B bl +042C b +042E lo +0430 a +0431 6 +0433 r +0435 e +043E o +0440 p +0441 c +0443 y +0445 x +0448 w +0455 s +0456 i +0458 j +045B h +045F u +0461 w +0462 b +0463 b +0472 o +0473 o +0474 v +0475 v +047D w +048C b +048D b +0493 r +0498 3 +049A k +049E k +04A2 h +04AA c +04AB c +04AC t +04AE y +04AF y +04B0 y +04B1 y +04B2 x +04BB h +04BD e +04BF e +04C0 l +04C7 h +04C9 h +04CD m +04CF l +04D4 ae +04D5 ae +04E0 3 +04E8 o +04E9 o +0501 d +050C g +051B q +051C w +051D w +054D u +054F s +0555 o +0561 w +0563 q +0566 q +0570 h +0572 n +0578 n +057C n +057D u +0581 g +0582 i +0584 f +0585 o +05C0 l +05D5 l +05D8 v +05DF l +05E1 o +05F0 ll +0625 l +0627 l +0647 o +0661 l +0665 o +0667 v +0673 l +06BE o +06C1 o +06D5 o +06F1 l +06F5 o +06F7 v +06FF o +07C0 o +07CA l +0966 o +0969 3 +09E6 o +09EA 8 +09ED 9 +0A66 o +0A67 9 +0A6A 8 +0AE6 o +0AE9 3 +0B03 8 +0B20 o +0B66 o +0B68 9 +0BE6 o +0C02 o +0C66 o +0C82 o +0CE6 o +0D02 o +0D1F s +0D20 o +0D66 o +0D6D 9 +0D82 o +0E50 o +0ED0 o +1004 c +101D o +1040 o +105A c +10E7 y +10FF o +1200 u +12D0 o +13A0 d +13A1 r +13A2 t +13A5 i +13A9 y +13AA a +13AB j +13AC e +13B3 w +13B7 m +13BB h +13BD y +13BE o +13C0 g +13C2 h +13C3 z +13CC u +13CE 4 +13CF b +13D2 r +13D4 w +13D5 s +13D9 v +13DA s +13DE l +13DF c +13E2 p +13E6 k +13E7 d +13EB o +13EE 6 +13F2 h +13F3 g +13F4 b +142F v +144C u +146D p +146F d +1472 b +1473 b +148D j +14AA l +14BF 2 +1541 x +157C h +157D x +1587 r +15AF b +15B4 f +15C5 a +15DE d +15EA d +15F0 m +15F7 b +166D x +166E x +16B7 x +16C1 l +16D5 k +16D6 m +17E0 o +1D04 c +1D0F o +1D11 o +1D1C u +1D20 v +1D21 w +1D22 z +1D26 r +1D52 o +1D6B ue +1D6E f +1D6F rn +1D70 n +1D72 r +1D74 s +1D75 t +1D76 z +1D78 h +1D7B i +1D7C i +1D7D p +1D7E u +1D83 g +1D8C y +1DA2 g +1E9A a +1E9D f +1EFF y +1FBE i +2016 ll +2070 o +20A1 c +20A5 rn +20A8 rs +20A9 w +20AB d +20AD k +20AE t +20B6 lt +2102 c +210A g +210B h +210C h +210D h +210E h +210F h +2110 l +2111 l +2112 l +2113 l +2115 n +2116 no +2119 p +211A q +211B r +211C r +211D r +2121 tel +2124 z +2128 z +212A k +212C b +212D c +212E e +212F e +2130 e +2131 f +2133 m +2134 o +2139 i +213B fax +213D y +2145 d +2146 d +2147 e +2148 i +2149 j +2160 l +2161 ll +2162 lll +2163 lv +2164 v +2165 vl +2166 vll +2167 vlll +2168 lx +2169 x +216A xl +216B xll +216C l +216D c +216E d +216F m +2170 i +2171 ii +2172 iii +2173 iv +2174 v +2175 vi +2176 vii +2177 viii +2178 ix +2179 x +217A xi +217B xii +217C l +217D c +217E d +217F rn +221E oo +2223 l +2225 ll +2228 v +222A u +2296 o +229D o +22A4 t +22C1 v +22C3 u +22FF e +2361 t +236C o +2373 i +2374 p +2376 a +2378 i +237A a +23E8 10 +23FD l +24DB i +2573 x +27D9 t +292B x +292C x +2A2F x +2A30 x +2C67 h +2C69 k +2C82 b +2C85 r +2C8E h +2C90 o +2C91 o +2C92 l +2C93 i +2C94 k +2C98 m +2C9A n +2C9C 3 +2C9E o +2C9F o +2CA2 p +2CA3 p +2CA4 c +2CA5 c +2CA6 t +2CA8 y +2CA9 y +2CAC x +2CBD w +2CC4 3 +2CCA 9 +2CCB 9 +2CCC 3 +2CCE p +2CCF p +2CD0 l +2CD2 6 +2CD3 6 +2CDC 6 +2D31 o +2D38 v +2D39 e +2D41 o +2D4F l +2D54 o +2D55 q +2D5D x +3007 o +A4D0 b +A4D1 p +A4D2 d +A4D3 d +A4D4 t +A4D6 g +A4D7 k +A4D9 j +A4DA c +A4DC z +A4DD f +A4DF m +A4E0 n +A4E1 l +A4E2 s +A4E3 r +A4E6 v +A4E7 h +A4EA w +A4EB x +A4EC y +A4EE a +A4F0 e +A4F2 l +A4F3 o +A4F4 u +A644 2 +A647 i +A695 h +A698 oo +A699 oo +A6DF v +A6EF 2 +A728 t3 +A731 s +A732 aa +A733 aa +A734 ao +A735 ao +A736 au +A737 au +A738 av +A739 av +A73A av +A73B av +A73C ay +A73D ay +A740 k +A74A o +A74B o +A74E oo +A74F oo +A75A 2 +A761 w +A76A 3 +A76E 9 +A777 tf +A798 f +A799 f +A79F u +A7AB 3 +A7B2 j +A7B3 x +A7B4 b +AB32 e +AB35 f +AB3D o +AB3E o +AB47 r +AB48 r +AB4E u +AB52 u +AB5A y +AB63 uo +AB74 o +AB75 i +AB81 r +AB83 w +AB8E o +AB93 z +AB9C u +ABA9 v +ABAA s +ABAF c +ABBB o +FB00 ff +FB01 fi +FB02 fl +FB03 ffi +FB04 ffl +FB06 st +FBA6 o +FBA7 o +FBA8 o +FBA9 o +FBAA o +FBAB o +FBAC o +FBAD o +FCD9 o +FD3C l +FD3D l +FE87 l +FE88 l +FE8D l +FE8E l +FEE9 o +FEEA o +FEEB o +FEEC o +FF21 a +FF22 b +FF23 c +FF25 e +FF28 h +FF29 l +FF2A j +FF2B k +FF2D m +FF2E n +FF2F o +FF30 p +FF33 s +FF34 t +FF38 x +FF39 y +FF3A z +FF41 a +FF43 c +FF45 e +FF47 g +FF48 h +FF49 i +FF4A j +FF4C l +FF4F o +FF50 p +FF53 s +FF56 v +FF58 x +FF59 y +FFE8 l +1018E n +10196 x +10197 v +10198 lls +10199 ll +10282 b +10286 e +10287 f +1028A l +10290 x +10292 o +10295 p +10296 s +10297 t +102A0 a +102A1 b +102A2 c +102A5 f +102AB o +102B0 m +102B1 t +102B2 y +102B4 x +102CF h +102F5 z +10301 b +10302 c +10309 l +10311 m +10315 t +10317 x +1031A 8 +10320 l +10322 x +10404 o +10415 c +1041B l +10420 s +1042C o +1043D c +10448 s +104B4 r +104C2 o +104CE u +104D2 7 +104EA o +104F6 u +10513 n +10516 o +10518 k +1051C c +1051D v +10525 f +10526 l +10527 x +114C5 w +114D0 o +11700 rn +11706 v +1170A w +1170E w +1170F w +118A0 v +118A2 f +118A3 l +118A4 y +118A6 e +118A9 z +118AC 9 +118AE e +118AF 4 +118B2 l +118B5 o +118B8 u +118BB 5 +118BC t +118C0 v +118C1 s +118C2 f +118C3 i +118C4 z +118C6 7 +118C8 o +118CA 3 +118CC 9 +118D5 6 +118D6 9 +118D7 o +118D8 u +118DC y +118E0 o +118E3 rn +118E5 z +118E6 w +118E9 c +118EC x +118EF w +118F2 c +11DDA l +11DE0 o +11DE1 l +16EAA l +16EB6 b +16F08 v +16F0A t +16F16 l +16F28 l +16F35 r +16F3A s +16F3B 3 +16F40 a +16F42 u +16F43 y +1CCD6 a +1CCD7 b +1CCD8 c +1CCD9 d +1CCDA e +1CCDB f +1CCDC g +1CCDD h +1CCDE l +1CCDF j +1CCE0 k +1CCE1 l +1CCE2 m +1CCE3 n +1CCE4 o +1CCE5 p +1CCE6 q +1CCE7 r +1CCE8 s +1CCE9 t +1CCEA u +1CCEB v +1CCEC w +1CCED x +1CCEE y +1CCEF z +1CCF0 o +1CCF1 l +1CCF2 2 +1CCF3 3 +1CCF4 4 +1CCF5 5 +1CCF6 6 +1CCF7 7 +1CCF8 8 +1CCF9 9 +1D206 3 +1D20D v +1D212 7 +1D213 f +1D216 r +1D21A o +1D22A l +1D400 a +1D401 b +1D402 c +1D403 d +1D404 e +1D405 f +1D406 g +1D407 h +1D408 l +1D409 j +1D40A k +1D40B l +1D40C m +1D40D n +1D40E o +1D40F p +1D410 q +1D411 r +1D412 s +1D413 t +1D414 u +1D415 v +1D416 w +1D417 x +1D418 y +1D419 z +1D41A a +1D41B b +1D41C c +1D41D d +1D41E e +1D41F f +1D420 g +1D421 h +1D422 i +1D423 j +1D424 k +1D425 l +1D426 rn +1D427 n +1D428 o +1D429 p +1D42A q +1D42B r +1D42C s +1D42D t +1D42E u +1D42F v +1D430 w +1D431 x +1D432 y +1D433 z +1D434 a +1D435 b +1D436 c +1D437 d +1D438 e +1D439 f +1D43A g +1D43B h +1D43C l +1D43D j +1D43E k +1D43F l +1D440 m +1D441 n +1D442 o +1D443 p +1D444 q +1D445 r +1D446 s +1D447 t +1D448 u +1D449 v +1D44A w +1D44B x +1D44C y +1D44D z +1D44E a +1D44F b +1D450 c +1D451 d +1D452 e +1D453 f +1D454 g +1D456 i +1D457 j +1D458 k +1D459 l +1D45A rn +1D45B n +1D45C o +1D45D p +1D45E q +1D45F r +1D460 s +1D461 t +1D462 u +1D463 v +1D464 w +1D465 x +1D466 y +1D467 z +1D468 a +1D469 b +1D46A c +1D46B d +1D46C e +1D46D f +1D46E g +1D46F h +1D470 l +1D471 j +1D472 k +1D473 l +1D474 m +1D475 n +1D476 o +1D477 p +1D478 q +1D479 r +1D47A s +1D47B t +1D47C u +1D47D v +1D47E w +1D47F x +1D480 y +1D481 z +1D482 a +1D483 b +1D484 c +1D485 d +1D486 e +1D487 f +1D488 g +1D489 h +1D48A i +1D48B j +1D48C k +1D48D l +1D48E rn +1D48F n +1D490 o +1D491 p +1D492 q +1D493 r +1D494 s +1D495 t +1D496 u +1D497 v +1D498 w +1D499 x +1D49A y +1D49B z +1D49C a +1D49E c +1D49F d +1D4A2 g +1D4A5 j +1D4A6 k +1D4A9 n +1D4AA o +1D4AB p +1D4AC q +1D4AE s +1D4AF t +1D4B0 u +1D4B1 v +1D4B2 w +1D4B3 x +1D4B4 y +1D4B5 z +1D4B6 a +1D4B7 b +1D4B8 c +1D4B9 d +1D4BB f +1D4BD h +1D4BE i +1D4BF j +1D4C0 k +1D4C1 l +1D4C2 rn +1D4C3 n +1D4C5 p +1D4C6 q +1D4C7 r +1D4C8 s +1D4C9 t +1D4CA u +1D4CB v +1D4CC w +1D4CD x +1D4CE y +1D4CF z +1D4D0 a +1D4D1 b +1D4D2 c +1D4D3 d +1D4D4 e +1D4D5 f +1D4D6 g +1D4D7 h +1D4D8 l +1D4D9 j +1D4DA k +1D4DB l +1D4DC m +1D4DD n +1D4DE o +1D4DF p +1D4E0 q +1D4E1 r +1D4E2 s +1D4E3 t +1D4E4 u +1D4E5 v +1D4E6 w +1D4E7 x +1D4E8 y +1D4E9 z +1D4EA a +1D4EB b +1D4EC c +1D4ED d +1D4EE e +1D4EF f +1D4F0 g +1D4F1 h +1D4F2 i +1D4F3 j +1D4F4 k +1D4F5 l +1D4F6 rn +1D4F7 n +1D4F8 o +1D4F9 p +1D4FA q +1D4FB r +1D4FC s +1D4FD t +1D4FE u +1D4FF v +1D500 w +1D501 x +1D502 y +1D503 z +1D504 a +1D505 b +1D507 d +1D508 e +1D509 f +1D50A g +1D50D j +1D50E k +1D50F l +1D510 m +1D511 n +1D512 o +1D513 p +1D514 q +1D516 s +1D517 t +1D518 u +1D519 v +1D51A w +1D51B x +1D51C y +1D51E a +1D51F b +1D520 c +1D521 d +1D522 e +1D523 f +1D524 g +1D525 h +1D526 i +1D527 j +1D528 k +1D529 l +1D52A rn +1D52B n +1D52C o +1D52D p +1D52E q +1D52F r +1D530 s +1D531 t +1D532 u +1D533 v +1D534 w +1D535 x +1D536 y +1D537 z +1D538 a +1D539 b +1D53B d +1D53C e +1D53D f +1D53E g +1D540 l +1D541 j +1D542 k +1D543 l +1D544 m +1D546 o +1D54A s +1D54B t +1D54C u +1D54D v +1D54E w +1D54F x +1D550 y +1D552 a +1D553 b +1D554 c +1D555 d +1D556 e +1D557 f +1D558 g +1D559 h +1D55A i +1D55B j +1D55C k +1D55D l +1D55E rn +1D55F n +1D560 o +1D561 p +1D562 q +1D563 r +1D564 s +1D565 t +1D566 u +1D567 v +1D568 w +1D569 x +1D56A y +1D56B z +1D56C a +1D56D b +1D56E c +1D56F d +1D570 e +1D571 f +1D572 g +1D573 h +1D574 l +1D575 j +1D576 k +1D577 l +1D578 m +1D579 n +1D57A o +1D57B p +1D57C q +1D57D r +1D57E s +1D57F t +1D580 u +1D581 v +1D582 w +1D583 x +1D584 y +1D585 z +1D586 a +1D587 b +1D588 c +1D589 d +1D58A e +1D58B f +1D58C g +1D58D h +1D58E i +1D58F j +1D590 k +1D591 l +1D592 rn +1D593 n +1D594 o +1D595 p +1D596 q +1D597 r +1D598 s +1D599 t +1D59A u +1D59B v +1D59C w +1D59D x +1D59E y +1D59F z +1D5A0 a +1D5A1 b +1D5A2 c +1D5A3 d +1D5A4 e +1D5A5 f +1D5A6 g +1D5A7 h +1D5A8 l +1D5A9 j +1D5AA k +1D5AB l +1D5AC m +1D5AD n +1D5AE o +1D5AF p +1D5B0 q +1D5B1 r +1D5B2 s +1D5B3 t +1D5B4 u +1D5B5 v +1D5B6 w +1D5B7 x +1D5B8 y +1D5B9 z +1D5BA a +1D5BB b +1D5BC c +1D5BD d +1D5BE e +1D5BF f +1D5C0 g +1D5C1 h +1D5C2 i +1D5C3 j +1D5C4 k +1D5C5 l +1D5C6 rn +1D5C7 n +1D5C8 o +1D5C9 p +1D5CA q +1D5CB r +1D5CC s +1D5CD t +1D5CE u +1D5CF v +1D5D0 w +1D5D1 x +1D5D2 y +1D5D3 z +1D5D4 a +1D5D5 b +1D5D6 c +1D5D7 d +1D5D8 e +1D5D9 f +1D5DA g +1D5DB h +1D5DC l +1D5DD j +1D5DE k +1D5DF l +1D5E0 m +1D5E1 n +1D5E2 o +1D5E3 p +1D5E4 q +1D5E5 r +1D5E6 s +1D5E7 t +1D5E8 u +1D5E9 v +1D5EA w +1D5EB x +1D5EC y +1D5ED z +1D5EE a +1D5EF b +1D5F0 c +1D5F1 d +1D5F2 e +1D5F3 f +1D5F4 g +1D5F5 h +1D5F6 i +1D5F7 j +1D5F8 k +1D5F9 l +1D5FA rn +1D5FB n +1D5FC o +1D5FD p +1D5FE q +1D5FF r +1D600 s +1D601 t +1D602 u +1D603 v +1D604 w +1D605 x +1D606 y +1D607 z +1D608 a +1D609 b +1D60A c +1D60B d +1D60C e +1D60D f +1D60E g +1D60F h +1D610 l +1D611 j +1D612 k +1D613 l +1D614 m +1D615 n +1D616 o +1D617 p +1D618 q +1D619 r +1D61A s +1D61B t +1D61C u +1D61D v +1D61E w +1D61F x +1D620 y +1D621 z +1D622 a +1D623 b +1D624 c +1D625 d +1D626 e +1D627 f +1D628 g +1D629 h +1D62A i +1D62B j +1D62C k +1D62D l +1D62E rn +1D62F n +1D630 o +1D631 p +1D632 q +1D633 r +1D634 s +1D635 t +1D636 u +1D637 v +1D638 w +1D639 x +1D63A y +1D63B z +1D63C a +1D63D b +1D63E c +1D63F d +1D640 e +1D641 f +1D642 g +1D643 h +1D644 l +1D645 j +1D646 k +1D647 l +1D648 m +1D649 n +1D64A o +1D64B p +1D64C q +1D64D r +1D64E s +1D64F t +1D650 u +1D651 v +1D652 w +1D653 x +1D654 y +1D655 z +1D656 a +1D657 b +1D658 c +1D659 d +1D65A e +1D65B f +1D65C g +1D65D h +1D65E i +1D65F j +1D660 k +1D661 l +1D662 rn +1D663 n +1D664 o +1D665 p +1D666 q +1D667 r +1D668 s +1D669 t +1D66A u +1D66B v +1D66C w +1D66D x +1D66E y +1D66F z +1D670 a +1D671 b +1D672 c +1D673 d +1D674 e +1D675 f +1D676 g +1D677 h +1D678 l +1D679 j +1D67A k +1D67B l +1D67C m +1D67D n +1D67E o +1D67F p +1D680 q +1D681 r +1D682 s +1D683 t +1D684 u +1D685 v +1D686 w +1D687 x +1D688 y +1D689 z +1D68A a +1D68B b +1D68C c +1D68D d +1D68E e +1D68F f +1D690 g +1D691 h +1D692 i +1D693 j +1D694 k +1D695 l +1D696 rn +1D697 n +1D698 o +1D699 p +1D69A q +1D69B r +1D69C s +1D69D t +1D69E u +1D69F v +1D6A0 w +1D6A1 x +1D6A2 y +1D6A3 z +1D6A4 i +1D6A8 a +1D6A9 b +1D6AC e +1D6AD z +1D6AE h +1D6AF o +1D6B0 l +1D6B1 k +1D6B3 m +1D6B4 n +1D6B6 o +1D6B8 p +1D6B9 o +1D6BB t +1D6BC y +1D6BE x +1D6C2 a +1D6C4 y +1D6C8 n +1D6C9 o +1D6CA i +1D6CE v +1D6D0 o +1D6D2 p +1D6D4 o +1D6D6 u +1D6DD o +1D6E0 p +1D6E2 a +1D6E3 b +1D6E6 e +1D6E7 z +1D6E8 h +1D6E9 o +1D6EA l +1D6EB k +1D6ED m +1D6EE n +1D6F0 o +1D6F2 p +1D6F3 o +1D6F5 t +1D6F6 y +1D6F8 x +1D6FC a +1D6FE y +1D702 n +1D703 o +1D704 i +1D708 v +1D70A o +1D70C p +1D70E o +1D710 u +1D717 o +1D71A p +1D71C a +1D71D b +1D720 e +1D721 z +1D722 h +1D723 o +1D724 l +1D725 k +1D727 m +1D728 n +1D72A o +1D72C p +1D72D o +1D72F t +1D730 y +1D732 x +1D736 a +1D738 y +1D73C n +1D73D o +1D73E i +1D742 v +1D744 o +1D746 p +1D748 o +1D74A u +1D751 o +1D754 p +1D756 a +1D757 b +1D75A e +1D75B z +1D75C h +1D75D o +1D75E l +1D75F k +1D761 m +1D762 n +1D764 o +1D766 p +1D767 o +1D769 t +1D76A y +1D76C x +1D770 a +1D772 y +1D776 n +1D777 o +1D778 i +1D77C v +1D77E o +1D780 p +1D782 o +1D784 u +1D78B o +1D78E p +1D790 a +1D791 b +1D794 e +1D795 z +1D796 h +1D797 o +1D798 l +1D799 k +1D79B m +1D79C n +1D79E o +1D7A0 p +1D7A1 o +1D7A3 t +1D7A4 y +1D7A6 x +1D7AA a +1D7AC y +1D7B0 n +1D7B1 o +1D7B2 i +1D7B6 v +1D7B8 o +1D7BA p +1D7BC o +1D7BE u +1D7C5 o +1D7C8 p +1D7CA f +1D7CE o +1D7CF l +1D7D0 2 +1D7D1 3 +1D7D2 4 +1D7D3 5 +1D7D4 6 +1D7D5 7 +1D7D6 8 +1D7D7 9 +1D7D8 o +1D7D9 l +1D7DA 2 +1D7DB 3 +1D7DC 4 +1D7DD 5 +1D7DE 6 +1D7DF 7 +1D7E0 8 +1D7E1 9 +1D7E2 o +1D7E3 l +1D7E4 2 +1D7E5 3 +1D7E6 4 +1D7E7 5 +1D7E8 6 +1D7E9 7 +1D7EA 8 +1D7EB 9 +1D7EC o +1D7ED l +1D7EE 2 +1D7EF 3 +1D7F0 4 +1D7F1 5 +1D7F2 6 +1D7F3 7 +1D7F4 8 +1D7F5 9 +1D7F6 o +1D7F7 l +1D7F8 2 +1D7F9 3 +1D7FA 4 +1D7FB 5 +1D7FC 6 +1D7FD 7 +1D7FE 8 +1D7FF 9 +1E8C7 l +1E8CB 8 +1EE00 l +1EE24 o +1EE64 o +1EE80 l +1EE84 o +1F700 qe +1F707 ar +1F708 v +1F714 o +1F74C c +1F75C sss +1F768 t +1F76B mb +1F76C vb +1FBF0 o +1FBF1 l +1FBF2 2 +1FBF3 3 +1FBF4 4 +1FBF5 5 +1FBF6 6 +1FBF7 7 +1FBF8 8 +1FBF9 9 diff --git a/GenOnlineService/data/namefilter_rules.txt b/GenOnlineService/data/namefilter_rules.txt new file mode 100644 index 0000000..1339bb8 --- /dev/null +++ b/GenOnlineService/data/namefilter_rules.txt @@ -0,0 +1,29 @@ +# Display name filter rules, one per line, tab separated: +# +# action match pattern category +# +# action allow / block / review / shadow. allow wins over everything, block rejects, +# review accepts and announces in the admin channel, shadow accepts and logs only. +# match skeleton = pattern appears in the name's skeleton, word = pattern appears as a +# whole word in the normalized name, exact = the whole skeleton is the pattern. +# +# Patterns are folded the same way a name is, so plain lowercase ASCII `hitler` also catches +# `Hitler`, `Нitler`, `HITLER`, `h.i.t.l.e.r` and `hiiitler`. Use exact for a name that is +# only a problem on its own: `ibra` as exact rejects `ibra` without rejecting `Ibrahim`. +# +# A rule's id is its line number here. Run `!namefilter reload` after editing, and +# `!namefilter rescan` to apply a new rule to names that already exist. + +# Impersonation of staff or known community members. +block exact admin impersonation +block exact staff impersonation +block exact moderator impersonation +block exact mass^ impersonation +block exact olda impersonation +block exact oldanalytics impersonation +block exact ibra impersonation +block exact x64 impersonation +block exact ronin impersonation + +# Historical figures. +block skeleton hitler figures