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