From 71d51d7b92858b32229a367f7286e55b13236b2e Mon Sep 17 00:00:00 2001 From: Astra Date: Sat, 3 Oct 2026 12:08:45 +0100 Subject: [PATCH 1/3] zio fork 1.8.2b-zio16: backport unban, rename and css_history from upstream 1.9.0 From upstream 530f603: - css_unban updates the ban cache at once (and clears the engine banid with UnlockedCommands) instead of waiting up to a minute for the cache refresh. - css_prename is saved in sa_renames (migration 017) and re-applied on connect and name change, so it survives restarts. css_rename from a hidden admin renames again. - New css_history command and Players > History menu entry. Commands missing from an existing Commands.json are registered under their default name. - Migrations are copied with a glob, so 017 reaches the build output. Upstream's admin-loading changes, css_say change and css_penalties fix are left out. --- .gitignore | 1 + .../docs/user/commands/basecommands.md | 30 ++++++ .../docs/user/commands/playercommands.md | 6 +- CS2-SimpleAdmin/CS2-SimpleAdmin.cs | 2 +- CS2-SimpleAdmin/CS2-SimpleAdmin.csproj | 98 +------------------ CS2-SimpleAdmin/Commands/RegisterCommands.cs | 9 ++ CS2-SimpleAdmin/Commands/basecommands.cs | 93 ++++++++++++++++++ CS2-SimpleAdmin/Commands/playercommands.cs | 12 ++- CS2-SimpleAdmin/Database/IDatabaseProvider.cs | 10 +- .../Mysql/017_CreateRenamesTable.sql | 5 + .../Sqlite/017_CreateRenamesTable.sql | 4 + .../Database/MysqlDatabaseProvider.cs | 36 ++++++- .../Database/SqliteDatabaseProvider.cs | 45 +++++++-- CS2-SimpleAdmin/Events.cs | 37 ++++--- CS2-SimpleAdmin/Managers/BanManager.cs | 29 +++++- CS2-SimpleAdmin/Managers/CacheManager.cs | 56 ++++++++--- CS2-SimpleAdmin/Managers/PlayerManager.cs | 92 ++++++++++++++++- CS2-SimpleAdmin/Managers/ServerManager.cs | 6 +- CS2-SimpleAdmin/Menus/BasicMenu.cs | 57 +++++++++++ CS2-SimpleAdmin/Variables.cs | 2 +- CS2-SimpleAdmin/admin_help.txt | 1 + CS2-SimpleAdmin/lang/ar.json | 3 + CS2-SimpleAdmin/lang/de.json | 3 + CS2-SimpleAdmin/lang/en.json | 3 + CS2-SimpleAdmin/lang/es.json | 3 + CS2-SimpleAdmin/lang/fa.json | 3 + CS2-SimpleAdmin/lang/fr.json | 3 + CS2-SimpleAdmin/lang/lv.json | 3 + CS2-SimpleAdmin/lang/pl.json | 3 + CS2-SimpleAdmin/lang/pt-BR.json | 3 + CS2-SimpleAdmin/lang/pt-PT.json | 3 + CS2-SimpleAdmin/lang/ru.json | 3 + CS2-SimpleAdmin/lang/tr.json | 3 + CS2-SimpleAdmin/lang/zh-Hans.json | 3 + README.md | 12 +++ 35 files changed, 539 insertions(+), 143 deletions(-) create mode 100644 CS2-SimpleAdmin/Database/Migrations/Mysql/017_CreateRenamesTable.sql create mode 100644 CS2-SimpleAdmin/Database/Migrations/Sqlite/017_CreateRenamesTable.sql diff --git a/.gitignore b/.gitignore index 55358e7..dad4536 100644 --- a/.gitignore +++ b/.gitignore @@ -14,3 +14,4 @@ CLAUDE.md /Modules/CS2-SimpleAdmin_StealthModule/METAMOD PLUGIN release-out/ +.claude/ diff --git a/CS2-SimpleAdmin-docs/docs/user/commands/basecommands.md b/CS2-SimpleAdmin-docs/docs/user/commands/basecommands.md index 5414380..b56f80f 100644 --- a/CS2-SimpleAdmin-docs/docs/user/commands/basecommands.md +++ b/CS2-SimpleAdmin-docs/docs/user/commands/basecommands.md @@ -313,6 +313,36 @@ css_warns PlayerName --- +### Show Penalty History + +Print every past and current penalty for a player to your console. + +```bash +css_history <#userid or name or steamid64> [bans|gags|mutes|silences|warns] +``` + +**Permission:** `@css/kick` + +**Shows:** +- Bans, gags, mutes, silences and warns, newest first +- Status (active, expired, unbanned, unmuted), duration and reason +- Admin who issued it +- Who lifted it, when and why, for unbans and unmutes + +**Notes:** +- Accepts a SteamID64, so it works for players who are not online +- The optional second argument narrows the list to one penalty type +- Also available in the admin menu under Players > History + +**Examples:** +```bash +css_history #123 +css_history PlayerName bans +css_history 76561198012345678 +``` + +--- + ### Show Online Players Show information about all online players. diff --git a/CS2-SimpleAdmin-docs/docs/user/commands/playercommands.md b/CS2-SimpleAdmin-docs/docs/user/commands/playercommands.md index 1db3d93..e51c959 100644 --- a/CS2-SimpleAdmin-docs/docs/user/commands/playercommands.md +++ b/CS2-SimpleAdmin-docs/docs/user/commands/playercommands.md @@ -251,9 +251,9 @@ css_prename PlayerName "NewIdentity" ``` **Notes:** -- Name is enforced even after reconnect -- Stored in database -- Player cannot change it +- Name is enforced even after reconnect, map change and server restart +- Stored in the database (`sa_renames`) and shared by every server using that database +- Player cannot change it; `css_prename ` with no name removes the rename - Useful for offensive names --- diff --git a/CS2-SimpleAdmin/CS2-SimpleAdmin.cs b/CS2-SimpleAdmin/CS2-SimpleAdmin.cs index c4ea959..338a41f 100644 --- a/CS2-SimpleAdmin/CS2-SimpleAdmin.cs +++ b/CS2-SimpleAdmin/CS2-SimpleAdmin.cs @@ -22,7 +22,7 @@ public partial class CS2_SimpleAdmin : BasePlugin, IPluginConfig "CS2-SimpleAdmin" + (Helper.IsDebugBuild ? " (DEBUG)" : " (RELEASE)"); public override string ModuleDescription => "Simple admin plugin for Counter-Strike 2 :)"; public override string ModuleAuthor => "daffyy"; - public override string ModuleVersion => "1.8.2b-zio15"; + public override string ModuleVersion => "1.8.2b-zio16"; public override void Load(bool hotReload) { diff --git a/CS2-SimpleAdmin/CS2-SimpleAdmin.csproj b/CS2-SimpleAdmin/CS2-SimpleAdmin.csproj index a3ad25d..98ecdcd 100644 --- a/CS2-SimpleAdmin/CS2-SimpleAdmin.csproj +++ b/CS2-SimpleAdmin/CS2-SimpleAdmin.csproj @@ -36,106 +36,10 @@ - - PreserveNewest - - - PreserveNewest - - - PreserveNewest - - - PreserveNewest - - - PreserveNewest - - - PreserveNewest - - - PreserveNewest - - - PreserveNewest - - - PreserveNewest - - - PreserveNewest - - - PreserveNewest - - - PreserveNewest - - - PreserveNewest - - - PreserveNewest - - - PreserveNewest - - - PreserveNewest - - - PreserveNewest - - - PreserveNewest - - - PreserveNewest - - - PreserveNewest - - - PreserveNewest - - - PreserveNewest - - - PreserveNewest - - - PreserveNewest - - - PreserveNewest - - - PreserveNewest - - - PreserveNewest - - - PreserveNewest - - - PreserveNewest - - - PreserveNewest - - - PreserveNewest - - - PreserveNewest - - + diff --git a/CS2-SimpleAdmin/Commands/RegisterCommands.cs b/CS2-SimpleAdmin/Commands/RegisterCommands.cs index 584f3ec..2edfa43 100644 --- a/CS2-SimpleAdmin/Commands/RegisterCommands.cs +++ b/CS2-SimpleAdmin/Commands/RegisterCommands.cs @@ -45,6 +45,7 @@ public static class RegisterCommands new("css_who", CS2_SimpleAdmin.Instance.OnWhoCommand), new("css_disconnected", CS2_SimpleAdmin.Instance.OnDisconnectedCommand), new("css_warns", CS2_SimpleAdmin.Instance.OnWarnsCommand), + new("css_history", CS2_SimpleAdmin.Instance.OnHistoryCommand), new("css_players", CS2_SimpleAdmin.Instance.OnPlayersCommand), new("css_kick", CS2_SimpleAdmin.Instance.OnKickCommand), new("css_map", CS2_SimpleAdmin.Instance.OnMapCommand), @@ -130,6 +131,7 @@ public static class RegisterCommands { "css_who", new Command { Aliases = ["css_who"] } }, { "css_disconnected", new Command { Aliases = ["css_disconnected", "css_last"] } }, { "css_warns", new Command { Aliases = ["css_warns"] } }, + { "css_history", new Command { Aliases = ["css_history", "css_penaltyhistory"] } }, { "css_players", new Command { Aliases = ["css_players"] } }, { "css_kick", new Command { Aliases = ["css_kick"] } }, { "css_map", new Command { Aliases = ["css_map", "css_changemap"] } }, @@ -198,6 +200,13 @@ public static class RegisterCommands CS2_SimpleAdmin.Instance.AddCommand(alias, "", mapping.Callback); } } + + // Commands added after Commands.json was generated are registered under their default name + foreach (var mapping in CommandMappings.Where(m => !commandsConfig.Commands.ContainsKey(m.CommandKey))) + { + CS2_SimpleAdmin._logger?.LogInformation($"Registering command: `{mapping.CommandKey}` (not in Commands.json, using default alias)"); + CS2_SimpleAdmin.Instance.AddCommand(mapping.CommandKey, "", mapping.Callback); + } } foreach (var (name, definitions) in _commandDefinitions) diff --git a/CS2-SimpleAdmin/Commands/basecommands.cs b/CS2-SimpleAdmin/Commands/basecommands.cs index 4b2302e..e0c362b 100644 --- a/CS2-SimpleAdmin/Commands/basecommands.cs +++ b/CS2-SimpleAdmin/Commands/basecommands.cs @@ -846,6 +846,99 @@ public partial class CS2_SimpleAdmin }); } + /// + /// Prints a player's full penalty history (bans, gags, mutes, silences, warns) to the caller's console. + /// Accepts a SteamID64 so it also works for players who are not online. + /// + /// The player issuing the command or null for console. + /// The command containing the target and an optional penalty type filter. + [RequiresPermissions("@css/kick")] + [CommandHelper(minArgs: 1, usage: "<#userid or name or steamid64> [bans|gags|mutes|silences|warns]", whoCanExecute: CommandUsage.CLIENT_AND_SERVER)] + public void OnHistoryCommand(CCSPlayerController? caller, CommandInfo command) + { + if (DatabaseProvider == null || _localizer == null) return; + + var filter = command.ArgCount > 2 ? command.GetArg(2).ToLowerInvariant() : null; + if (filter is not (null or "bans" or "gags" or "mutes" or "silences" or "warns")) + { + command.ReplyToCommand("Usage: css_history <#userid or name or steamid64> [bans|gags|mutes|silences|warns]"); + return; + } + + ulong steamId; + string? name = null; + + if (Helper.ValidateSteamId(command.GetArg(1), out var parsedSteamId) && parsedSteamId != null) + { + if (caller != null && !caller.CanTarget(parsedSteamId)) return; + + steamId = parsedSteamId.SteamId64; + if (PlayersInfo.TryGetValue(steamId, out var info)) name = info.Name; + } + else + { + var targets = GetTarget(command); + if (targets == null) return; + + var players = targets.Players.Where(p => p is { IsValid: true, IsBot: false, IsHLTV: false }).ToList(); + if (players.Count != 1) + { + command.ReplyToCommand("Only one target is allowed."); + return; + } + + var player = players[0]; + if (caller != null && !caller.CanTarget(player)) return; + + steamId = player.SteamID; + name = player.PlayerName; + } + + Helper.LogCommand(caller, command); + Action print = caller == null ? Server.PrintToConsole : caller.PrintToConsole; + var callerSteamId = caller?.SteamID; + + Task.Run(async () => + { + var rows = await PlayerManager.GetPenaltyHistory(steamId, filter); + // Offline players are only known by SteamID; borrow the name stored on their latest record + name ??= rows.Count > 0 ? (string?)rows[0].player_name : null; + + await Server.NextWorldUpdateAsync(() => + { + // The slot may have been reused by another player while the query ran + if (caller != null && (!caller.IsValid || caller.SteamID != callerSteamId)) return; + PrintHistory(print, name ?? steamId.ToString(), steamId, rows); + }); + }); + } + + /// + /// Writes history rows from as one console line each. + /// + internal void PrintHistory(Action print, string name, ulong steamId, List rows) + { + if (_localizer == null) return; + + if (rows.Count == 0) + { + print(_localizer["sa_history_none", name]); + return; + } + + print(_localizer["sa_history_header", name, steamId, rows.Count]); + foreach (var r in rows) + { + var duration = (int)r.duration == 0 ? "perm" : $"{(int)r.duration}m"; + var line = $"[{Date(r.created)}] {(string)r.type,-7} {(string)r.status,-8} {duration,-6} by {(string)r.admin_name}: {(string)r.reason}"; + if (r.lift_date != null) + line += $" | lifted {Date(r.lift_date)} by {(string?)r.lift_admin ?? "?"}: {(string?)r.lift_reason}"; + print(line); + } + + static string Date(object? value) => PlayerManager.ToDateTime(value)?.ToString("yyyy-MM-dd HH:mm") ?? "?"; + } + /// /// Lists players currently connected to the server with options to output JSON or filter duplicate IPs. /// diff --git a/CS2-SimpleAdmin/Commands/playercommands.cs b/CS2-SimpleAdmin/Commands/playercommands.cs index 2330ccf..209f122 100644 --- a/CS2-SimpleAdmin/Commands/playercommands.cs +++ b/CS2-SimpleAdmin/Commands/playercommands.cs @@ -292,10 +292,9 @@ public partial class CS2_SimpleAdmin var adminActivityArgs = new object[] { "CALLER", player.PlayerName, newName }; // Display admin activity message to other players - if (caller != null && SilentPlayers.Contains(caller.Slot)) return; + if (caller != null && !SilentPlayers.Contains(caller.Slot)) + Helper.ShowAdminActivity(activityMessageKey, callerName, false, adminActivityArgs); - Helper.ShowAdminActivity(activityMessageKey, callerName, false, adminActivityArgs); - // Rename the player player.Rename(newName); }); @@ -344,15 +343,18 @@ public partial class CS2_SimpleAdmin } // Determine if the new name is valid and update the renamed players list + var steamId = player.SteamID; if (!string.IsNullOrEmpty(newName)) { - RenamedPlayers[player.SteamID] = newName; + RenamedPlayers[steamId] = newName; player.Rename(newName); } else { - RenamedPlayers.Remove(player.SteamID); + RenamedPlayers.Remove(steamId); } + + Task.Run(() => PlayerManager.SaveRenamedPlayer(steamId, newName)); }); } diff --git a/CS2-SimpleAdmin/Database/IDatabaseProvider.cs b/CS2-SimpleAdmin/Database/IDatabaseProvider.cs index e2a4ae4..cdfea20 100644 --- a/CS2-SimpleAdmin/Database/IDatabaseProvider.cs +++ b/CS2-SimpleAdmin/Database/IDatabaseProvider.cs @@ -41,7 +41,12 @@ public interface IDatabaseProvider string GetExpireBansQuery(bool multiServer); string GetExpireIpBansQuery(bool multiServer); string GetExpireOldPlayerIpsQuery(); - + + // Renames (css_prename) + string GetRenamesQuery(); + string GetUpsertRenameQuery(); + string GetDeleteRenameQuery(); + // MuteManager string GetAddMuteQuery(bool includePlayerName); string GetIsMutedQuery(bool multiServer, int timeMode); @@ -61,4 +66,7 @@ public interface IDatabaseProvider string GetUnwarnByIdQuery(bool multiServer); string GetUnwarnLastQuery(bool multiServer); string GetExpireWarnsQuery(bool multiServer); + + // Penalty history (css_history) + string GetPenaltyHistoryQuery(bool multiServer); } \ No newline at end of file diff --git a/CS2-SimpleAdmin/Database/Migrations/Mysql/017_CreateRenamesTable.sql b/CS2-SimpleAdmin/Database/Migrations/Mysql/017_CreateRenamesTable.sql new file mode 100644 index 0000000..634bd07 --- /dev/null +++ b/CS2-SimpleAdmin/Database/Migrations/Mysql/017_CreateRenamesTable.sql @@ -0,0 +1,5 @@ +CREATE TABLE IF NOT EXISTS `sa_renames` ( + `player_steamid` BIGINT NOT NULL, + `name` VARCHAR(128) NOT NULL, + PRIMARY KEY (`player_steamid`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci; diff --git a/CS2-SimpleAdmin/Database/Migrations/Sqlite/017_CreateRenamesTable.sql b/CS2-SimpleAdmin/Database/Migrations/Sqlite/017_CreateRenamesTable.sql new file mode 100644 index 0000000..ec893be --- /dev/null +++ b/CS2-SimpleAdmin/Database/Migrations/Sqlite/017_CreateRenamesTable.sql @@ -0,0 +1,4 @@ +CREATE TABLE IF NOT EXISTS `sa_renames` ( + `player_steamid` INTEGER NOT NULL PRIMARY KEY, + `name` TEXT NOT NULL +); diff --git a/CS2-SimpleAdmin/Database/MysqlDatabaseProvider.cs b/CS2-SimpleAdmin/Database/MysqlDatabaseProvider.cs index fa9a27e..baf4cac 100644 --- a/CS2-SimpleAdmin/Database/MysqlDatabaseProvider.cs +++ b/CS2-SimpleAdmin/Database/MysqlDatabaseProvider.cs @@ -217,8 +217,8 @@ public class MySqlDatabaseProvider(string connectionString) : IDatabaseProvider public string GetUnbanRetrieveBansQuery(bool multiServer) { return multiServer - ? "SELECT id FROM sa_bans WHERE (player_steamid = @pattern OR player_name = @pattern OR player_ip = @pattern) AND status = 'ACTIVE'" - : "SELECT id FROM sa_bans WHERE (player_steamid = @pattern OR player_name = @pattern OR player_ip = @pattern) AND status = 'ACTIVE' AND server_id = @serverid"; + ? "SELECT id, player_steamid FROM sa_bans WHERE (player_steamid = @pattern OR player_name = @pattern OR player_ip = @pattern) AND status = 'ACTIVE'" + : "SELECT id, player_steamid FROM sa_bans WHERE (player_steamid = @pattern OR player_name = @pattern OR player_ip = @pattern) AND status = 'ACTIVE' AND server_id = @serverid"; } public string GetUnbanAdminIdQuery() @@ -238,6 +238,15 @@ public class MySqlDatabaseProvider(string connectionString) : IDatabaseProvider return "UPDATE sa_bans SET status = 'UNBANNED', unban_id = @unbanId WHERE id = @banId"; } + public string GetRenamesQuery() => + "SELECT player_steamid, name FROM sa_renames"; + + public string GetUpsertRenameQuery() => + "INSERT INTO sa_renames (player_steamid, name) VALUES (@steamId, @name) ON DUPLICATE KEY UPDATE name = @name"; + + public string GetDeleteRenameQuery() => + "DELETE FROM sa_renames WHERE player_steamid = @steamId"; + public string GetExpireBansQuery(bool multiServer) { return multiServer @@ -404,6 +413,29 @@ public class MySqlDatabaseProvider(string connectionString) : IDatabaseProvider ? "UPDATE sa_warns SET status = 'EXPIRED' WHERE status = 'ACTIVE' AND `duration` > 0 AND ends <= @CurrentTime" : "UPDATE sa_warns SET status = 'EXPIRED' WHERE status = 'ACTIVE' AND `duration` > 0 AND ends <= @CurrentTime AND server_id = @serverid"; + public string GetPenaltyHistoryQuery(bool multiServer) => + $""" + SELECT b.id, 'BAN' AS type, b.player_name, b.admin_name, b.reason, b.duration, b.created, b.ends, b.status, + ub.reason AS lift_reason, ub.date AS lift_date, ua.player_name AS lift_admin + FROM sa_bans b + LEFT JOIN sa_unbans ub ON ub.id = b.unban_id + LEFT JOIN sa_admins ua ON ua.id = ub.admin_id + WHERE b.player_steamid = @PlayerSteamID {(multiServer ? "" : "AND b.server_id = @serverid")} + UNION ALL + SELECT m.id, m.type, m.player_name, m.admin_name, m.reason, m.duration, m.created, m.ends, m.status, + um.reason AS lift_reason, um.date AS lift_date, ua.player_name AS lift_admin + FROM sa_mutes m + LEFT JOIN sa_unmutes um ON um.id = m.unmute_id + LEFT JOIN sa_admins ua ON ua.id = um.admin_id + WHERE m.player_steamid = @PlayerSteamID {(multiServer ? "" : "AND m.server_id = @serverid")} + UNION ALL + SELECT w.id, 'WARN' AS type, w.player_name, w.admin_name, w.reason, w.duration, w.created, w.ends, w.status, + NULL AS lift_reason, NULL AS lift_date, NULL AS lift_admin + FROM sa_warns w + WHERE w.player_steamid = @PlayerSteamID {(multiServer ? "" : "AND w.server_id = @serverid")} + ORDER BY created DESC + """; + public ValueTask DisposeAsync() => ValueTask.CompletedTask; } diff --git a/CS2-SimpleAdmin/Database/SqliteDatabaseProvider.cs b/CS2-SimpleAdmin/Database/SqliteDatabaseProvider.cs index d6186fb..53b2191 100644 --- a/CS2-SimpleAdmin/Database/SqliteDatabaseProvider.cs +++ b/CS2-SimpleAdmin/Database/SqliteDatabaseProvider.cs @@ -143,8 +143,8 @@ public class SqliteDatabaseProvider(string filePath) : IDatabaseProvider public string GetUnbanRetrieveBansQuery(bool multiServer) => multiServer - ? "SELECT id FROM sa_bans WHERE (player_steamid = @pattern OR player_name = @pattern OR player_ip = @pattern) AND status = 'ACTIVE'" - : "SELECT id FROM sa_bans WHERE (player_steamid = @pattern OR player_name = @pattern OR player_ip = @pattern) AND status = 'ACTIVE' AND server_id = @serverid"; + ? "SELECT id, player_steamid FROM sa_bans WHERE (player_steamid = @pattern OR player_name = @pattern OR player_ip = @pattern) AND status = 'ACTIVE'" + : "SELECT id, player_steamid FROM sa_bans WHERE (player_steamid = @pattern OR player_name = @pattern OR player_ip = @pattern) AND status = 'ACTIVE' AND server_id = @serverid"; public string GetUnbanAdminIdQuery() => "SELECT id FROM sa_admins WHERE player_steamid = @adminSteamId"; @@ -154,13 +154,14 @@ public class SqliteDatabaseProvider(string filePath) : IDatabaseProvider ? "INSERT INTO sa_unbans (ban_id, admin_id, reason) VALUES (@banId, @adminId, @reason); SELECT last_insert_rowid();" : "INSERT INTO sa_unbans (ban_id, admin_id) VALUES (@banId, @adminId); SELECT last_insert_rowid();"; + // SQLite has no ON UPDATE CURRENT_TIMESTAMP, so updated_at must be set explicitly or the cache refresh never sees the change public string GetUpdateBanStatusQuery() => - "UPDATE sa_bans SET status = 'UNBANNED', unban_id = @unbanId WHERE id = @banId"; + "UPDATE sa_bans SET status = 'UNBANNED', unban_id = @unbanId, updated_at = CURRENT_TIMESTAMP WHERE id = @banId"; public string GetExpireBansQuery(bool multiServer) => multiServer - ? "UPDATE sa_bans SET status = 'EXPIRED' WHERE status = 'ACTIVE' AND duration > 0 AND ends <= @currentTime" - : "UPDATE sa_bans SET status = 'EXPIRED' WHERE status = 'ACTIVE' AND duration > 0 AND ends <= @currentTime AND server_id = @serverid"; + ? "UPDATE sa_bans SET status = 'EXPIRED', updated_at = CURRENT_TIMESTAMP WHERE status = 'ACTIVE' AND duration > 0 AND ends <= @currentTime" + : "UPDATE sa_bans SET status = 'EXPIRED', updated_at = CURRENT_TIMESTAMP WHERE status = 'ACTIVE' AND duration > 0 AND ends <= @currentTime AND server_id = @serverid"; public string GetExpireIpBansQuery(bool multiServer) => multiServer @@ -169,7 +170,16 @@ public class SqliteDatabaseProvider(string filePath) : IDatabaseProvider public string GetExpireOldPlayerIpsQuery() => "DELETE FROM sa_players_ips WHERE used_at <= @ipBansTime"; - + + public string GetRenamesQuery() => + "SELECT player_steamid, name FROM sa_renames"; + + public string GetUpsertRenameQuery() => + "INSERT INTO sa_renames (player_steamid, name) VALUES (@steamId, @name) ON CONFLICT(player_steamid) DO UPDATE SET name = excluded.name"; + + public string GetDeleteRenameQuery() => + "DELETE FROM sa_renames WHERE player_steamid = @steamId"; + public string GetAdminsQuery() => """ SELECT sa_admins.player_steamid, sa_admins.player_name, sa_admins_flags.flag, sa_admins.immunity, sa_admins.ends @@ -377,4 +387,27 @@ public class SqliteDatabaseProvider(string filePath) : IDatabaseProvider multiServer ? "UPDATE sa_warns SET status = 'EXPIRED' WHERE status = 'ACTIVE' AND duration > 0 AND ends <= @CurrentTime" : "UPDATE sa_warns SET status = 'EXPIRED' WHERE status = 'ACTIVE' AND duration > 0 AND ends <= @CurrentTime AND server_id = @serverid"; + + public string GetPenaltyHistoryQuery(bool multiServer) => + $""" + SELECT b.id, 'BAN' AS type, b.player_name, b.admin_name, b.reason, b.duration, b.created, b.ends, b.status, + ub.reason AS lift_reason, ub.date AS lift_date, ua.player_name AS lift_admin + FROM sa_bans b + LEFT JOIN sa_unbans ub ON ub.id = b.unban_id + LEFT JOIN sa_admins ua ON ua.id = ub.admin_id + WHERE b.player_steamid = @PlayerSteamID {(multiServer ? "" : "AND b.server_id = @serverid")} + UNION ALL + SELECT m.id, m.type, m.player_name, m.admin_name, m.reason, m.duration, m.created, m.ends, m.status, + um.reason AS lift_reason, um.date AS lift_date, ua.player_name AS lift_admin + FROM sa_mutes m + LEFT JOIN sa_unmutes um ON um.id = m.unmute_id + LEFT JOIN sa_admins ua ON ua.id = um.admin_id + WHERE m.player_steamid = @PlayerSteamID {(multiServer ? "" : "AND m.server_id = @serverid")} + UNION ALL + SELECT w.id, 'WARN' AS type, w.player_name, w.admin_name, w.reason, w.duration, w.created, w.ends, w.status, + NULL AS lift_reason, NULL AS lift_date, NULL AS lift_admin + FROM sa_warns w + WHERE w.player_steamid = @PlayerSteamID {(multiServer ? "" : "AND w.server_id = @serverid")} + ORDER BY created DESC + """; } diff --git a/CS2-SimpleAdmin/Events.cs b/CS2-SimpleAdmin/Events.cs index bd0a404..7559a72 100644 --- a/CS2-SimpleAdmin/Events.cs +++ b/CS2-SimpleAdmin/Events.cs @@ -543,18 +543,31 @@ public partial class CS2_SimpleAdmin [GameEventHandler] public HookResult OnPlayerInfo(EventPlayerInfo @event, GameEventInfo _) { - var player = @event.Userid; - - if (player is null || !player.IsValid || player.IsBot) - return HookResult.Continue; - - if (!RenamedPlayers.TryGetValue(player.SteamID, out var name)) return HookResult.Continue; - - if (player.PlayerName.Equals(name)) - return HookResult.Continue; - - player.Rename(name); - + EnforceRename(@event.Userid); return HookResult.Continue; } + + [GameEventHandler] + public HookResult OnPlayerChangename(EventPlayerChangename @event, GameEventInfo _) + { + EnforceRename(@event.Userid); + return HookResult.Continue; + } + + /// + /// Re-applies a permanent rename. Checked after a short delay because the engine may not + /// have written the client's new name yet when the event fires, and Rename itself + /// takes 0.4s to settle (it briefly writes the name with a trailing space). + /// + private void EnforceRename(CCSPlayerController? player) + { + if (player is null || !player.IsValid || player.IsBot) return; + if (!RenamedPlayers.TryGetValue(player.SteamID, out var name)) return; + + AddTimer(0.5f, () => + { + if (player.IsValid && !player.PlayerName.Equals(name)) + player.Rename(name); + }); + } } \ No newline at end of file diff --git a/CS2-SimpleAdmin/Managers/BanManager.cs b/CS2-SimpleAdmin/Managers/BanManager.cs index 5863b59..f8a04b2 100644 --- a/CS2-SimpleAdmin/Managers/BanManager.cs +++ b/CS2-SimpleAdmin/Managers/BanManager.cs @@ -1,5 +1,6 @@ using CounterStrikeSharp.API; using CounterStrikeSharp.API.ValveConstants.Protobuf; +using CounterStrikeSharp.API.Modules.Entities; using CS2_SimpleAdminApi; using Dapper; using Microsoft.Extensions.Logging; @@ -339,8 +340,34 @@ public async Task UnbanPlayer(string playerPattern, string adminSteamId, string var sqlUpdateBan = databaseProvider.GetUpdateBanStatusQuery(); await connection.ExecuteAsync(sqlUpdateBan, new { unbanId, banId }); } + + // Apply immediately; the periodic cache refresh would otherwise keep rejecting the player for up to a minute + CS2_SimpleAdmin.Instance.CacheManager?.SetBanStatus(bansList.Select(b => (int)b.id), Models.BanStatus.UNBANNED); + + // css_ban also issues a native banid when UnlockedCommands is on; clear it so the engine stops + // rejecting the player. Done here, after the rows are updated, and for whichever pattern matched them. + if (CS2_SimpleAdmin.UnlockedCommands) + { + var steamIds = bansList + .Select(b => (object?)b.player_steamid) + .Where(id => id != null) + .Select(id => Convert.ToUInt64(id)) + .Where(id => id > 0) + .Distinct() + .ToList(); + + if (steamIds.Count > 0) + await Server.NextWorldUpdateAsync(() => + { + foreach (var steamId in steamIds) + Server.ExecuteCommand($"removeid {new SteamID(steamId).SteamId3}"); + }); + } + } + catch (Exception ex) + { + CS2_SimpleAdmin._logger?.LogError("Unable to unban player: {exception}", ex.Message); } - catch { } } // public async Task CheckOnlinePlayers(List<(string? IpAddress, ulong SteamID, int? UserId, int Slot)> players) diff --git a/CS2-SimpleAdmin/Managers/CacheManager.cs b/CS2-SimpleAdmin/Managers/CacheManager.cs index db74f2c..165efac 100644 --- a/CS2-SimpleAdmin/Managers/CacheManager.cs +++ b/CS2-SimpleAdmin/Managers/CacheManager.cs @@ -10,8 +10,10 @@ namespace CS2_SimpleAdmin.Managers; internal class CacheManager: IDisposable { private readonly ConcurrentDictionary _banCache = []; - private readonly ConcurrentDictionary> _steamIdIndex = []; - private readonly ConcurrentDictionary> _ipIndex = []; + // Rebuilt into fresh instances and swapped, so unlocked readers on the game thread never see a half-built index + private ConcurrentDictionary> _steamIdIndex = []; + private ConcurrentDictionary> _ipIndex = []; + private readonly object _indexLock = new(); private readonly ConcurrentDictionary> _playerIpsCache = []; private HashSet _cachedIgnoredIps = []; @@ -178,7 +180,7 @@ internal class CacheManager: IDisposable player_steamid AS PlayerSteamId, player_ip AS PlayerIp, status AS Status - FROM `sa_bans` WHERE updated_at > @lastUpdate OR created > @lastUpdate ORDER BY updated_at DESC + FROM `sa_bans` WHERE updated_at >= @lastUpdate OR created >= @lastUpdate ORDER BY updated_at DESC """, new { lastUpdate = lastCheckTime } )).ToList(); @@ -221,7 +223,7 @@ internal class CacheManager: IDisposable player_steamid AS PlayerSteamId, player_ip AS PlayerIp, status AS Status - FROM `sa_bans` WHERE server_id = @serverId AND (updated_at > @lastUpdate OR created > @lastUpdate) ORDER BY updated_at DESC + FROM `sa_bans` WHERE server_id = @serverId AND (updated_at >= @lastUpdate OR created >= @lastUpdate) ORDER BY updated_at DESC """, new { serverId = CS2_SimpleAdmin.ServerId, lastUpdate = lastCheckTime } )).ToList(); @@ -294,7 +296,7 @@ internal class CacheManager: IDisposable .GroupBy(x => x.Address) .Select(g => { - var latest = g.MaxBy(x => x.Used_at); + var latest = g.MaxBy(x => x.Used_at)!; // group is never empty return new IpRecord( g.Key, latest.Used_at, @@ -353,8 +355,36 @@ internal class CacheManager: IDisposable /// private void RebuildIndexes() { - _steamIdIndex.Clear(); - _ipIndex.Clear(); + // SetBanStatus (unban thread) and RefreshCacheAsync (timer thread) can both get here + lock (_indexLock) + { + RebuildIndexesCore(); + } + } + + /// + /// Updates the status of the given bans in the cache and rebuilds the indexes, + /// so unbans take effect immediately instead of waiting for the next refresh. + /// + /// Ban ids to update. + /// New status. + public void SetBanStatus(IEnumerable banIds, BanStatus status) + { + var changed = false; + foreach (var id in banIds) + { + if (!_banCache.TryGetValue(id, out var ban) || ban.StatusEnum == status) continue; + _banCache[id] = ban with { Status = status.ToString() }; + changed = true; + } + + if (changed) RebuildIndexes(); + } + + private void RebuildIndexesCore() + { + var steamIdIndex = new ConcurrentDictionary>(); + var ipIndex = new ConcurrentDictionary>(); // Optimization: Cache config value to avoid repeated property access var banType = CS2_SimpleAdmin.Instance.Config.OtherSettings.BanType; @@ -369,10 +399,10 @@ internal class CacheManager: IDisposable if (ban.PlayerSteamId.HasValue) { var steamId = ban.PlayerSteamId.Value; - if (!_steamIdIndex.TryGetValue(steamId, out var steamList)) + if (!steamIdIndex.TryGetValue(steamId, out var steamList)) { steamList = new List(); - _steamIdIndex[steamId] = steamList; + steamIdIndex[steamId] = steamList; } steamList.Add(ban); } @@ -381,14 +411,18 @@ internal class CacheManager: IDisposable if (checkIpBans && !string.IsNullOrEmpty(ban.PlayerIp) && IpHelper.TryConvertIpToUint(ban.PlayerIp, out var ipUInt)) { - if (!_ipIndex.TryGetValue(ipUInt, out var ipList)) + if (!ipIndex.TryGetValue(ipUInt, out var ipList)) { ipList = new List(); - _ipIndex[ipUInt] = ipList; + ipIndex[ipUInt] = ipList; } ipList.Add(ban); } } + + // Reference swap is atomic; readers finish on the old index or start on the complete new one + Volatile.Write(ref _steamIdIndex, steamIdIndex); + Volatile.Write(ref _ipIndex, ipIndex); } /// diff --git a/CS2-SimpleAdmin/Managers/PlayerManager.cs b/CS2-SimpleAdmin/Managers/PlayerManager.cs index 44debaa..a7422ef 100644 --- a/CS2-SimpleAdmin/Managers/PlayerManager.cs +++ b/CS2-SimpleAdmin/Managers/PlayerManager.cs @@ -1,3 +1,4 @@ +using System.Globalization; using CounterStrikeSharp.API; using CounterStrikeSharp.API.Core; using CounterStrikeSharp.API.Modules.Admin; @@ -92,6 +93,11 @@ internal class PlayerManager // zio: read on the main thread; the ban check below runs on a worker thread. var bypassIpBans = AdminManager.PlayerHasPermissions(new SteamID(steamId), IpBanBypassPermission); + if (CS2_SimpleAdmin.RenamedPlayers.TryGetValue(steamId, out var renamedTo)) + { + player.Rename(renamedTo); + } + if (CS2_SimpleAdmin.DatabaseProvider == null || CS2_SimpleAdmin.Instance.CacheManager == null) return; Task.Run(async () => @@ -297,10 +303,92 @@ internal class PlayerManager _loadPlayerSemaphore.Release(); } }); + } - if (CS2_SimpleAdmin.RenamedPlayers.TryGetValue(player.SteamID, out var name)) + /// + /// Returns every ban, gag/mute/silence and warn ever recorded for a SteamID, newest first. + /// Rows carry: type, status, reason, duration, created, ends, admin_name, lift_reason, lift_date, lift_admin. + /// + /// SteamID64 of the player, online or not. + /// Optional filter: bans, gags, mutes, silences or warns. + public async Task> GetPenaltyHistory(ulong steamId, string? type = null) + { + if (CS2_SimpleAdmin.DatabaseProvider == null) return []; + + try { - player.Rename(name); + await using var connection = await CS2_SimpleAdmin.DatabaseProvider.CreateConnectionAsync(); + var sql = CS2_SimpleAdmin.DatabaseProvider.GetPenaltyHistoryQuery(CS2_SimpleAdmin.Instance.Config.MultiServerMode); + var rows = (await connection.QueryAsync(sql, new { PlayerSteamID = steamId, serverid = CS2_SimpleAdmin.ServerId })).ToList(); + + // Filter word is the plural (bans, gags, mutes, silences, warns); rows carry BAN/GAG/MUTE/SILENCE/WARN + if (type != null) + rows.RemoveAll(r => !type.Equals((string)r.type + "s", StringComparison.OrdinalIgnoreCase)); + + return rows; + } + catch (Exception ex) + { + CS2_SimpleAdmin._logger?.LogError("Unable to load penalty history: {exception}", ex.Message); + return []; + } + } + + /// + /// Dapper gives DateTime on MySQL but can give a string on SQLite; accept both. + /// + internal static DateTime? ToDateTime(object? value) => + value switch + { + DateTime d => d, + string s when DateTime.TryParse(s, CultureInfo.InvariantCulture, DateTimeStyles.None, out var parsed) => parsed, + _ => null + }; + + /// + /// Loads permanent renames from the database into memory. + /// + public async Task LoadRenamedPlayers() + { + if (CS2_SimpleAdmin.DatabaseProvider == null) return; + + try + { + await using var connection = await CS2_SimpleAdmin.DatabaseProvider.CreateConnectionAsync(); + var rows = await connection.QueryAsync<(long steamId, string name)>(CS2_SimpleAdmin.DatabaseProvider.GetRenamesQuery()); + + await Server.NextWorldUpdateAsync(() => + { + foreach (var (steamId, name) in rows) + CS2_SimpleAdmin.RenamedPlayers[(ulong)steamId] = name; + }); + } + catch (Exception ex) + { + CS2_SimpleAdmin._logger?.LogError("Unable to load renames: {exception}", ex.Message); + } + } + + /// + /// Persists or removes a permanent rename (css_prename) for a player. + /// + /// SteamID64 of the player. + /// Forced name, or null/empty to remove the rename. + public async Task SaveRenamedPlayer(ulong steamId, string? name) + { + if (CS2_SimpleAdmin.DatabaseProvider == null) return; + + try + { + await using var connection = await CS2_SimpleAdmin.DatabaseProvider.CreateConnectionAsync(); + if (string.IsNullOrEmpty(name)) + await connection.ExecuteAsync(CS2_SimpleAdmin.DatabaseProvider.GetDeleteRenameQuery(), new { steamId }); + else + await connection.ExecuteAsync(CS2_SimpleAdmin.DatabaseProvider.GetUpsertRenameQuery(), new { steamId, name }); + } + catch (Exception ex) + { + CS2_SimpleAdmin._logger?.LogError("Unable to save rename: {exception}", ex.Message); } } diff --git a/CS2-SimpleAdmin/Managers/ServerManager.cs b/CS2-SimpleAdmin/Managers/ServerManager.cs index ec80aaa..10dcc16 100644 --- a/CS2-SimpleAdmin/Managers/ServerManager.cs +++ b/CS2-SimpleAdmin/Managers/ServerManager.cs @@ -43,6 +43,7 @@ public class ServerManager if (_getIpTryCount > 32 && isInvalidIp) { CS2_SimpleAdmin._logger?.LogError("Unable to load server data - can't fetch ip address!"); + _ = CS2_SimpleAdmin.PlayerManager.LoadRenamedPlayers(); return; } @@ -103,7 +104,7 @@ public class ServerManager CS2_SimpleAdmin.ServerId = serverId; CS2_SimpleAdmin._logger?.LogInformation("Loaded server with ip {ip}", ipAddress); -CS2_SimpleAdmin.ServerLoaded = true; + CS2_SimpleAdmin.ServerLoaded = true; if (CS2_SimpleAdmin.Instance.CacheManager != null) { @@ -115,6 +116,9 @@ CS2_SimpleAdmin.ServerLoaded = true; CS2_SimpleAdmin._logger?.LogCritical("Unable to create or get server_id: " + ex.Message); } + // Renames are server-independent, so load them even when the server row could not be resolved + await CS2_SimpleAdmin.PlayerManager.LoadRenamedPlayers(); + if (CS2_SimpleAdmin.Instance.Config.EnableMetrics) { var queryString = $"?address={address}&hostname={hostname}"; diff --git a/CS2-SimpleAdmin/Menus/BasicMenu.cs b/CS2-SimpleAdmin/Menus/BasicMenu.cs index ae9ef3f..41c8ea1 100644 --- a/CS2-SimpleAdmin/Menus/BasicMenu.cs +++ b/CS2-SimpleAdmin/Menus/BasicMenu.cs @@ -30,6 +30,7 @@ public abstract class BasicMenu manager.RegisterMenu("players", "mute", "sa_mute", CreateMuteMenu, "@css/chat", "css_mute"); manager.RegisterMenu("players", "silence", "sa_silence", CreateSilenceMenu, "@css/chat", "css_silence"); manager.RegisterMenu("players", "team", "sa_team_force", CreateForceTeamMenu, "@css/kick", "css_team"); + manager.RegisterMenu("players", "history", "sa_history", CreateHistoryMenu, "@css/kick", "css_history"); // Server category menus - using translation keys manager.RegisterMenu("server", "plugins", "sa_menu_pluginsmanager_title", CreatePluginsMenu, "@css/root"); @@ -128,6 +129,62 @@ public abstract class BasicMenu return slayMenu.WithBackButton(); } + /// + /// Creates the penalty history menu: pick an online player, then one entry per past penalty. + /// Selecting an entry prints its full details to the admin's console. + /// + /// The admin player opening the menu. + /// A MenuBuilder instance for the history menu. + private static MenuBuilder CreateHistoryMenu(CCSPlayerController admin) + { + var localizer = CS2_SimpleAdmin._localizer; + var historyMenu = new MenuBuilder("sa_history", admin, localizer); + + var players = Helper.GetValidPlayers().Where(p => !p.IsBot && admin.CanTarget(p)); + + foreach (var player in players) + { + var playerName = player.PlayerName.Length > 26 ? player.PlayerName[..26] : player.PlayerName; + historyMenu.AddOption(playerName, _ => + { + var steamId = player.SteamID; + var name = player.PlayerName; + var adminSteamId = admin.SteamID; + + // The list is built after the DB round trip, so it is opened from the world update rather than returned here + Task.Run(async () => + { + var rows = await CS2_SimpleAdmin.PlayerManager.GetPenaltyHistory(steamId); + await Server.NextWorldUpdateAsync(() => + { + // The slot may have been reused by another player while the query ran + if (!admin.IsValid || admin.SteamID != adminSteamId) return; + + if (rows.Count == 0) + { + CS2_SimpleAdmin.Instance.PrintHistory(admin.PrintToConsole, name, steamId, rows); + return; + } + + var list = new MenuBuilder("sa_history", admin, localizer); + foreach (var row in rows) + { + var active = (string)row.status == "ACTIVE"; + var label = $"[{(active ? $"{ChatColors.LightRed}X" : $"{ChatColors.Lime}✔️")}{ChatColors.Default}] {(string)row.type} {(string)row.reason}"; + list.AddOption(label.Length > 40 ? label[..40] : label, + _ => CS2_SimpleAdmin.Instance.PrintHistory(admin.PrintToConsole, name, steamId, [row])); + } + + // Built standalone, so the back button must be wired to the player list explicitly + list.WithBackAction(historyMenu.OpenMenu).OpenMenu(admin); + }); + }); + }); + } + + return historyMenu.WithBackButton(); + } + /// /// Creates menu for kicking players with reason selection. /// diff --git a/CS2-SimpleAdmin/Variables.cs b/CS2-SimpleAdmin/Variables.cs index d3566d6..ab65b36 100644 --- a/CS2-SimpleAdmin/Variables.cs +++ b/CS2-SimpleAdmin/Variables.cs @@ -77,7 +77,7 @@ public partial class CS2_SimpleAdmin internal MuteManager MuteManager = new(DatabaseProvider); internal WarnManager WarnManager = new(DatabaseProvider); internal CacheManager? CacheManager = new(); - private static readonly PlayerManager PlayerManager = new(); + internal static readonly PlayerManager PlayerManager = new(); // Timers internal Timer? PlayersTimer = null; diff --git a/CS2-SimpleAdmin/admin_help.txt b/CS2-SimpleAdmin/admin_help.txt index e8ce7af..8a01ab5 100644 --- a/CS2-SimpleAdmin/admin_help.txt +++ b/CS2-SimpleAdmin/admin_help.txt @@ -1,5 +1,6 @@ {GREEN}[ CS2-SimpleAdmin HELP ]{DEFAULT} - css_who <#userid or name> - Display informations about player +- css_history <#userid or name or steamid64> [bans|gags|mutes|silences|warns] - Print player's penalty history to console - css_players - Display player list - css_ban <#userid or name> [time in minutes/0 perm] [reason] - Ban player - css_addban [time in minutes/0 perm] [reason] - Ban player via steamid64 diff --git a/CS2-SimpleAdmin/lang/ar.json b/CS2-SimpleAdmin/lang/ar.json index 4533510..1236e7f 100644 --- a/CS2-SimpleAdmin/lang/ar.json +++ b/CS2-SimpleAdmin/lang/ar.json @@ -122,6 +122,9 @@ "sa_admin_team_message": "{lightred}{0}{default} نقل {lightred}{1}{default} إلى {lightred}{2}{default}!", "sa_admin_warns_menu_title": "{gold}{0} {lime}تحذيرات", "sa_admin_warns_unwarn": "{lime}تم إلغاء التحذير بنجاح{default} لـ {gold}{0} {default}بسبب {gold}{1}{default}!", + "sa_history": "السجل", + "sa_history_header": "===== سجل العقوبات للاعب {0} ({1}) - {2} سجل =====", + "sa_history_none": "لم يتم العثور على عقوبات للاعب {0}.", "sa_admin_vote_menu_title": "{lime}تصويت لـ {gold}{0}", "sa_admin_vote_message": "{lightred}{0}{default} بدأ التصويت لـ {lightred}{1}{default}", "sa_admin_vote_message_results": "{lime}نتائج التصويت لـ {gold}{0}", diff --git a/CS2-SimpleAdmin/lang/de.json b/CS2-SimpleAdmin/lang/de.json index f32db52..dfdfc85 100644 --- a/CS2-SimpleAdmin/lang/de.json +++ b/CS2-SimpleAdmin/lang/de.json @@ -122,6 +122,9 @@ "sa_admin_team_message": "{lightred}{0}{default} hat {lightred}{1}{default} zu {lightred}{2}{default} transferiert!", "sa_admin_warns_menu_title": "{gold}{0} {lime}VERWARNUNGEN", "sa_admin_warns_unwarn": "{lime}Erfolgreich{default} Verwarnung von {gold}{0} {default}aufgehoben wegen {gold}{1}{default}!", + "sa_history": "Verlauf", + "sa_history_header": "===== Strafverlauf für {0} ({1}) - {2} Eintrag/Einträge =====", + "sa_history_none": "Keine Strafen für {0} gefunden.", "sa_admin_vote_menu_title": "{lime}ABSTIMMUNG FÜR {gold}{0}", "sa_admin_vote_message": "{lightred}{0}{default} hat eine Abstimmung für {lightred}{1}{default} gestartet", "sa_admin_vote_message_results": "{lime}ABSTIMMUNGSERGEBNISSE FÜR {gold}{0}", diff --git a/CS2-SimpleAdmin/lang/en.json b/CS2-SimpleAdmin/lang/en.json index 36b31c6..d808e55 100644 --- a/CS2-SimpleAdmin/lang/en.json +++ b/CS2-SimpleAdmin/lang/en.json @@ -131,6 +131,9 @@ "sa_admin_team_message": "{red}{0}{default} transfered {yellow}{1}{default} to {grey}{2}", "sa_admin_warns_menu_title": "{gold}{0} {lime}WARNS", "sa_admin_warns_unwarn": "{lime}Successfully{default} unwarned {yellow}{0} {default}for {orange}{1}", + "sa_history": "History", + "sa_history_header": "===== Penalty history for {0} ({1}) - {2} record(s) =====", + "sa_history_none": "No penalties found for {0}.", "sa_admin_vote_menu_title": "{lime}VOTING FOR {gold}{0}", "sa_admin_vote_message": "{red}{0}{default} started voting for {orange}{1}", "sa_admin_vote_message_results": "{lime}VOTING RESULTS FOR {orange}{0}", diff --git a/CS2-SimpleAdmin/lang/es.json b/CS2-SimpleAdmin/lang/es.json index e5b0edd..a966057 100644 --- a/CS2-SimpleAdmin/lang/es.json +++ b/CS2-SimpleAdmin/lang/es.json @@ -122,6 +122,9 @@ "sa_admin_team_message": "{lightred}{0}{default} transfirió a {lightred}{1}{default} al {lightred}{2}{default}!", "sa_admin_warns_menu_title": "{gold}{0} {lime}ADVERTENCIAS", "sa_admin_warns_unwarn": "{lime}Desadvertencia{default} exitosa de {gold}{0} {default}por {gold}{1}{default}!", + "sa_history": "Historial", + "sa_history_header": "===== Historial de sanciones de {0} ({1}) - {2} registro(s) =====", + "sa_history_none": "No se encontraron sanciones para {0}.", "sa_admin_vote_menu_title": "{lime}VOTACIÓN PARA {gold}{0}", "sa_admin_vote_message": "{lightred}{0}{default} inició una votación para {lightred}{1}{default}", "sa_admin_vote_message_results": "{lime}RESULTADOS DE LA VOTACIÓN PARA {gold}{0}", diff --git a/CS2-SimpleAdmin/lang/fa.json b/CS2-SimpleAdmin/lang/fa.json index 77d8273..a15fbd8 100644 --- a/CS2-SimpleAdmin/lang/fa.json +++ b/CS2-SimpleAdmin/lang/fa.json @@ -122,6 +122,9 @@ "sa_admin_team_message": "{lightred}{0}{default} {lightred}{1}{default} را به {lightred}{2}{default} انتقال داد!", "sa_admin_warns_menu_title": "{gold}{0} {lime}هشدارها", "sa_admin_warns_unwarn": "{lime}هشدار با موفقیت{default} برای {gold}{0} {default}لغو شد به دلیل {gold}{1}{default}!", + "sa_history": "تاریخچه", + "sa_history_header": "===== تاریخچه مجازات‌های {0} ({1}) - {2} مورد =====", + "sa_history_none": "هیچ مجازاتی برای {0} یافت نشد.", "sa_admin_vote_menu_title": "{lime}رأی‌گیری برای {gold}{0}", "sa_admin_vote_message": "{lightred}{0}{default} رأی‌گیری برای {lightred}{1}{default} را شروع کرد", "sa_admin_vote_message_results": "{lime}نتایج رأی‌گیری برای {gold}{0}", diff --git a/CS2-SimpleAdmin/lang/fr.json b/CS2-SimpleAdmin/lang/fr.json index 27a4731..391a3a5 100644 --- a/CS2-SimpleAdmin/lang/fr.json +++ b/CS2-SimpleAdmin/lang/fr.json @@ -122,6 +122,9 @@ "sa_admin_team_message": "{lightred}{0}{default} a transféré {lightred}{1}{default} à l'équipe {lightred}{2}{default}!", "sa_admin_warns_menu_title": "{gold}{0} {lime}AVERTISSEMENTS", "sa_admin_warns_unwarn": "{lime}Avertissement annulé{default} avec succès pour {gold}{0} {default}en raison de {gold}{1}{default}!", + "sa_history": "Historique", + "sa_history_header": "===== Historique des sanctions de {0} ({1}) - {2} entrée(s) =====", + "sa_history_none": "Aucune sanction trouvée pour {0}.", "sa_admin_vote_menu_title": "{lime}VOTE POUR {gold}{0}", "sa_admin_vote_message": "{lightred}{0}{default} a lancé un vote pour {lightred}{1}{default}", "sa_admin_vote_message_results": "{lime}RÉSULTATS DU VOTE POUR {gold}{0}", diff --git a/CS2-SimpleAdmin/lang/lv.json b/CS2-SimpleAdmin/lang/lv.json index e27aed8..1128ff3 100644 --- a/CS2-SimpleAdmin/lang/lv.json +++ b/CS2-SimpleAdmin/lang/lv.json @@ -122,6 +122,9 @@ "sa_admin_team_message": "{lightred}{0}{default} pārvietoja {lightred}{1}{default} uz {lightred}{2}{default} komandu!", "sa_admin_warns_menu_title": "{gold}{0} {lime}BRĪDINĀJUMI", "sa_admin_warns_unwarn": "{lime}Brīdinājums veiksmīgi{default} atsaukts priekš {gold}{0} {default}dēļ {gold}{1}{default}!", + "sa_history": "Vēsture", + "sa_history_header": "===== Sodu vēsture spēlētājam {0} ({1}) - {2} ieraksts(-i) =====", + "sa_history_none": "Spēlētājam {0} nav atrasts neviens sods.", "sa_admin_vote_menu_title": "{lime}BALSOŠANA PAR {gold}{0}", "sa_admin_vote_message": "{lightred}{0}{default} uzsāka balsošanu par {lightred}{1}{default}", "sa_admin_vote_message_results": "{lime}BALSOŠANAS REZULTĀTI PAR {gold}{0}", diff --git a/CS2-SimpleAdmin/lang/pl.json b/CS2-SimpleAdmin/lang/pl.json index 3b6b3a7..ae6be91 100644 --- a/CS2-SimpleAdmin/lang/pl.json +++ b/CS2-SimpleAdmin/lang/pl.json @@ -122,6 +122,9 @@ "sa_admin_team_message": "{lightred}{0}{default} przerzucił {lightred}{1}{default} do {lightred}{2}{default}!", "sa_admin_warns_menu_title": "{lime}OSTRZEŻENIA {gold}{0}", "sa_admin_warns_unwarn": "{lime}Pomyślnie{default} usunięto ostrzeżenie dla {gold}{0} {default}za {gold}{1}{default}!", + "sa_history": "Historia", + "sa_history_header": "===== Historia kar gracza {0} ({1}) - {2} wpis(ow) =====", + "sa_history_none": "Nie znaleziono kar dla {0}.", "sa_admin_vote_menu_title": "{lime}GŁOSOWANIE NA {gold}{0}", "sa_admin_vote_message": "{lightred}{0}{default} rozpoczął głosowanie na {lightred}{1}{default}", "sa_admin_vote_message_results": "{lime}WYNIKI GŁOSOWANIA {gold}{0}", diff --git a/CS2-SimpleAdmin/lang/pt-BR.json b/CS2-SimpleAdmin/lang/pt-BR.json index 299ebc2..95f08cb 100644 --- a/CS2-SimpleAdmin/lang/pt-BR.json +++ b/CS2-SimpleAdmin/lang/pt-BR.json @@ -122,6 +122,9 @@ "sa_admin_team_message": "{lightred}{0}{default} transferiu {lightred}{1}{default} para a equipe {lightred}{2}{default}!", "sa_admin_warns_menu_title": "{gold}{0} {lime}ADVERTÊNCIAS", "sa_admin_warns_unwarn": "{lime}Advertência removida com sucesso{default} para {gold}{0} {default}por {gold}{1}{default}!", + "sa_history": "Histórico", + "sa_history_header": "===== Histórico de punições de {0} ({1}) - {2} registro(s) =====", + "sa_history_none": "Nenhuma punição encontrada para {0}.", "sa_admin_vote_menu_title": "{lime}VOTAÇÃO PARA {gold}{0}", "sa_admin_vote_message": "{lightred}{0}{default} iniciou uma votação para {lightred}{1}{default}", "sa_admin_vote_message_results": "{lime}RESULTADOS DA VOTAÇÃO PARA {gold}{0}", diff --git a/CS2-SimpleAdmin/lang/pt-PT.json b/CS2-SimpleAdmin/lang/pt-PT.json index 94cce8c..93fc675 100644 --- a/CS2-SimpleAdmin/lang/pt-PT.json +++ b/CS2-SimpleAdmin/lang/pt-PT.json @@ -122,6 +122,9 @@ "sa_admin_team_message": "{lightred}{0}{default} transferiu {lightred}{1}{default} para a equipe {lightred}{2}{default}!", "sa_admin_warns_menu_title": "{gold}{0} {lime}ADVERTÊNCIAS", "sa_admin_warns_unwarn": "{lime}Advertência removida com sucesso{default} para {gold}{0} {default}por {gold}{1}{default}!", + "sa_history": "Histórico", + "sa_history_header": "===== Histórico de punições de {0} ({1}) - {2} registo(s) =====", + "sa_history_none": "Nenhuma punição encontrada para {0}.", "sa_admin_vote_menu_title": "{lime}VOTAÇÃO PARA {gold}{0}", "sa_admin_vote_message": "{lightred}{0}{default} iniciou uma votação para {lightred}{1}{default}", "sa_admin_vote_message_results": "{lime}RESULTADOS DA VOTAÇÃO PARA {gold}{0}", diff --git a/CS2-SimpleAdmin/lang/ru.json b/CS2-SimpleAdmin/lang/ru.json index f92fb5a..de16a5f 100644 --- a/CS2-SimpleAdmin/lang/ru.json +++ b/CS2-SimpleAdmin/lang/ru.json @@ -122,6 +122,9 @@ "sa_admin_team_message": "{lightred}{0}{default} перевел {lightred}{1}{default} в команду {lightred}{2}{default}!", "sa_admin_warns_menu_title": "{gold}{0} {lime}ПРЕДУПРЕЖДЕНИЯ", "sa_admin_warns_unwarn": "{lime}Предупреждение успешно удалено{default} для {gold}{0} {default}за {gold}{1}{default}!", + "sa_history": "История", + "sa_history_header": "===== История наказаний игрока {0} ({1}) - записей: {2} =====", + "sa_history_none": "Наказания для {0} не найдены.", "sa_admin_vote_menu_title": "{lime}ГОЛОСОВАНИЕ ЗА {gold}{0}", "sa_admin_vote_message": "{lightred}{0}{default} начал голосование за {lightred}{1}{default}", "sa_admin_vote_message_results": "{lime}РЕЗУЛЬТАТЫ ГОЛОСОВАНИЯ ЗА {gold}{0}", diff --git a/CS2-SimpleAdmin/lang/tr.json b/CS2-SimpleAdmin/lang/tr.json index c520397..12c9cbd 100644 --- a/CS2-SimpleAdmin/lang/tr.json +++ b/CS2-SimpleAdmin/lang/tr.json @@ -122,6 +122,9 @@ "sa_admin_team_message": "{lightred}{0}{default} {lightred}{1}{default}'i {lightred}{2}{default} takımına transfer etti!", "sa_admin_warns_menu_title": "{gold}{0} {lime}UYARILAR", "sa_admin_warns_unwarn": "{lime}Uyarı başarıyla kaldırıldı{default} {gold}{0} {default} için {gold}{1}{default}!", + "sa_history": "Geçmiş", + "sa_history_header": "===== {0} ({1}) ceza geçmişi - {2} kayıt =====", + "sa_history_none": "{0} için ceza bulunamadı.", "sa_admin_vote_menu_title": "{lime}OY VERME {gold}{0}", "sa_admin_vote_message": "{lightred}{0}{default} {lightred}{1}{default} için oy kullanmaya başladı", "sa_admin_vote_message_results": "{lime}OY SONUÇLARI {gold}{0}", diff --git a/CS2-SimpleAdmin/lang/zh-Hans.json b/CS2-SimpleAdmin/lang/zh-Hans.json index c3d2331..d8597da 100644 --- a/CS2-SimpleAdmin/lang/zh-Hans.json +++ b/CS2-SimpleAdmin/lang/zh-Hans.json @@ -120,6 +120,9 @@ "sa_admin_team_message": "{lightred}{0}{default} 将 {lightred}{1}{default} 转移到 {lightred}{2}{default} 队伍!", "sa_admin_warns_menu_title": "{gold}{0} {lime}警告", "sa_admin_warns_unwarn": "{lime}成功移除警告{default} {gold}{0} 对于 {gold}{1}{default}!", + "sa_history": "历史记录", + "sa_history_header": "===== {0} ({1}) 的处罚记录 - 共 {2} 条 =====", + "sa_history_none": "未找到 {0} 的处罚记录。", "sa_admin_vote_menu_title": "{lime}投票 {gold}{0}", "sa_admin_vote_message": "{lightred}{0}{default} 发起了 [{lightred}{1}{default}] 的投票", "sa_admin_vote_message_results": "{lime}投票结果 {gold}{0}", diff --git a/README.md b/README.md index b0669d1..47b3a1b 100644 --- a/README.md +++ b/README.md @@ -42,6 +42,18 @@ from the `build-1.8.2b` tag). Changes, all marked `zio:` in the code: `css_unsilence` match online players by part of the name too (upstream needed the full name), falling back to the offline search only when no one online matches. +Backported from upstream 1.9.0 (commit `530f603`), not marked `zio:`: + +- `css_unban` applies at once: the ban cache is updated straight away instead of on the next + 61-second refresh, and `removeid` clears the engine ban when `UnlockedCommands` is on. +- `css_prename` survives restarts: renames are stored in the new `sa_renames` table (migration + 017) and re-applied on connect and name change. `css_rename` from a hidden admin renames again. +- New `css_history <#userid|name|steamid64> [bans|gags|mutes|silences|warns]` (`@css/kick`), and + Players > History in the admin menu. Commands missing from an existing `Commands.json` are + registered under their default name. +- Left out: upstream's admin-loading changes (ours differ, see `1.8.2b-zio11`), its `css_say` + change and its `css_penalties` fix. + Publish with `FORGEJO_TOKEN=... ./release.sh v` (ModuleVersion is `1.8.2b-zioN`). It rebuilds from the committed source and uploads `SimpleAdmin-.tar.gz` to git.zio.sh (`cs2/SimpleAdmin`), laid out like `game/csgo/`. It holds the whole plugin (its libraries, all lang From 1a1253b8653e0f7bd205bca0b42d3d62bb095a49 Mon Sep 17 00:00:00 2001 From: Astra Date: Sun, 4 Oct 2026 18:34:10 +0100 Subject: [PATCH 2/3] release.sh: read the Forgejo token from ../../.forgejo-token when FORGEJO_TOKEN isn't set --- release.sh | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/release.sh b/release.sh index 0ccb29b..6913b4e 100755 --- a/release.sh +++ b/release.sh @@ -22,8 +22,13 @@ set -euo pipefail FORGEJO_URL="https://git.zio.sh" OWNER="cs2" REPO="SimpleAdmin" -# Placeholder - replace, or set FORGEJO_TOKEN in the environment instead. Needs write:repository. -# Only uploading needs it; downloading from the public repo doesn't. Don't commit a real token. +# Needs write:repository. Only uploading needs it; downloading from the public repo doesn't. From the +# environment, or else from .forgejo-token in the cs2/ folder above every plugin (outside their +# repos, mode 600). Don't commit a real token. +TOKEN_FILE="$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")/../../.forgejo-token" +if [[ -z "${FORGEJO_TOKEN:-}" && -r "$TOKEN_FILE" ]]; then + FORGEJO_TOKEN="$(tr -d '[:space:]' < "$TOKEN_FILE")" +fi FORGEJO_TOKEN="${FORGEJO_TOKEN:-CHANGE_ME_FORGEJO_TOKEN}" TAG="${1:-}" @@ -32,7 +37,7 @@ if [[ -z "$TAG" ]]; then exit 1 fi if [[ "$FORGEJO_TOKEN" == "CHANGE_ME_FORGEJO_TOKEN" ]]; then - echo "Set FORGEJO_TOKEN (edit release.sh or export it) first." >&2 + echo "Set FORGEJO_TOKEN (export it, or put it in $TOKEN_FILE) first." >&2 exit 1 fi for tool in podman jq curl git; do From 4420d41048bb9d953dfa349b0fc3195dc15faef1 Mon Sep 17 00:00:00 2001 From: Astra Date: Wed, 7 Oct 2026 09:47:58 +0100 Subject: [PATCH 3/3] zio fork 1.8.2b-zio17: no startup version check or banner Removes upstream's PluginInfo: CheckVersion, which fetched upstream's VERSION from GitHub on every load, and ShowAd, the console banner with two encoded lines of personal messages. EnableUpdateCheck stays in the config so existing files load, and does nothing. --- CS2-SimpleAdmin/CS2-SimpleAdmin.cs | 6 +-- CS2-SimpleAdmin/Helper.cs | 68 ------------------------------ README.md | 4 ++ 3 files changed, 7 insertions(+), 71 deletions(-) diff --git a/CS2-SimpleAdmin/CS2-SimpleAdmin.cs b/CS2-SimpleAdmin/CS2-SimpleAdmin.cs index 338a41f..63c23d1 100644 --- a/CS2-SimpleAdmin/CS2-SimpleAdmin.cs +++ b/CS2-SimpleAdmin/CS2-SimpleAdmin.cs @@ -22,7 +22,7 @@ public partial class CS2_SimpleAdmin : BasePlugin, IPluginConfig "CS2-SimpleAdmin" + (Helper.IsDebugBuild ? " (DEBUG)" : " (RELEASE)"); public override string ModuleDescription => "Simple admin plugin for Counter-Strike 2 :)"; public override string ModuleAuthor => "daffyy"; - public override string ModuleVersion => "1.8.2b-zio16"; + public override string ModuleVersion => "1.8.2b-zio17"; public override void Load(bool hotReload) { @@ -217,8 +217,8 @@ public partial class CS2_SimpleAdmin : BasePlugin, IPluginConfig await PluginInfo.CheckVersion(ModuleVersion, Logger)); + // zio: no update check or startup banner (upstream's PluginInfo, removed). EnableUpdateCheck + // is left in the config so existing files still load, and does nothing. PermissionManager = new PermissionManager(DatabaseProvider); BanManager = new BanManager(DatabaseProvider); diff --git a/CS2-SimpleAdmin/Helper.cs b/CS2-SimpleAdmin/Helper.cs index 1a04a9c..98d07a9 100644 --- a/CS2-SimpleAdmin/Helper.cs +++ b/CS2-SimpleAdmin/Helper.cs @@ -1006,74 +1006,6 @@ internal static class Helper } -public static class PluginInfo -{ - internal static async Task CheckVersion(string localVersion, ILogger logger) - { - ShowAd(localVersion); - const string versionUrl = "https://raw.githubusercontent.com/daffyyyy/CS2-SimpleAdmin/main/CS2-SimpleAdmin/VERSION"; - var client = CS2_SimpleAdmin.HttpClient; - client.Timeout = TimeSpan.FromSeconds(3); - - try - { - var response = await client.GetAsync(versionUrl); - - if (response.IsSuccessStatusCode) - { - var remoteVersion = await response.Content.ReadAsStringAsync(); - remoteVersion = remoteVersion.Trim(); - - var comparisonResult = string.CompareOrdinal(localVersion, remoteVersion); - - switch (comparisonResult) - { - case < 0: - logger.LogWarning("Plugin is outdated! Check https://github.com/daffyyyy/CS2-SimpleAdmin"); - break; - case > 0: - logger.LogInformation("Probably dev version detected"); - break; - default: - logger.LogInformation("Plugin is up to date"); - break; - } - } - else - { - logger.LogWarning($"Failed to check version. Status Code: {response.StatusCode}"); - } - } - catch (HttpRequestException ex) - { - logger.LogError(ex, "Failed to connect to the version server."); - } - catch (Exception ex) - { - logger.LogError(ex, "An error occurred while checking version."); - } - } - - private static void ShowAd(string moduleVersion) - { - Console.WriteLine(" "); - Console.WriteLine(" _______ ___ __ __ _______ ___ _______ _______ ______ __ __ ___ __ _ "); - Console.WriteLine("| || | | |_| || || | | || _ || | | |_| || | | | | |"); - Console.WriteLine("| _____|| | | || _ || | | ___|| |_| || _ || || | | |_| |"); - Console.WriteLine("| |_____ | | | || |_| || | | |___ | || | | || || | | |"); - Console.WriteLine("|_____ || | | || ___|| |___ | ___|| || |_| || || | | _ |"); - Console.WriteLine(" _____| || | | ||_|| || | | || |___ | _ || || ||_|| || | | | | |"); - Console.WriteLine("|_______||___| |_| |_||___| |_______||_______||__| |__||______| |_| |_||___| |_| |__|"); - Console.WriteLine(" >> Version: " + moduleVersion); - Console.WriteLine(" >> GitHub: https://github.com/daffyyyy/CS2-SimpleAdmin"); - var _m1 = new List { 70,117,99,107,32,121,111,117,32,114,97,118,105,100,44,32,119,104,121,32,121,111,117,32,108,121,105,110,103,32,97,98,111,117,116,32,110,111,32,99,111,110,116,97,99,116,32,119,105,116,104,32,109,101,63,32,74,117,115,116,32,116,101,108,108,32,116,114,117,116,104,44,32,110,105,99,101,32,116,114,121,32,116,111,32,112,114,111,106,101,99,116,32,116,97,107,101,111,118,101,114 }; - var _m2 = new List { 32,32,58,68,32,40,97,108,108,32,119,104,111,32,116,114,105,101,100,32,116,111,32,99,111,110,116,97,99,116,32,119,105,116,104,32,109,101,44,32,106,117,115,116,32,119,114,105,116,101,32,109,101,115,115,97,103,101,32,116,111,32,109,101,32,120,68,41 }; - Console.WriteLine(" >> " + new string(_m1.Select(c => (char)c).ToArray())); - Console.WriteLine(" >> " + new string(_m2.Select(c => (char)c).ToArray())); - Console.WriteLine(" "); - } -} - public class SchemaString(TSchemaClass instance, string member) : NativeObject(Schema.GetSchemaValue(instance.Handle, typeof(TSchemaClass).Name, member)) where TSchemaClass : NativeObject diff --git a/README.md b/README.md index 47b3a1b..6a3254e 100644 --- a/README.md +++ b/README.md @@ -41,6 +41,10 @@ from the `build-1.8.2b` tag). Changes, all marked `zio:` in the code: is picked, and otherwise the reply lists the matches. `css_ungag`, `css_unmute` and `css_unsilence` match online players by part of the name too (upstream needed the full name), falling back to the offline search only when no one online matches. +- No startup version check or banner: upstream's `PluginInfo` (`CheckVersion`, which asked upstream's + GitHub for its `VERSION` on every load, and `ShowAd`, the console banner with two encoded lines + of personal messages) is removed. `EnableUpdateCheck` stays in the config, so existing files + still load, and does nothing. Backported from upstream 1.9.0 (commit `530f603`), not marked `zio:`: