diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index c2434cd..d1b0b19 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -35,7 +35,7 @@ jobs: - name: Setup .NET uses: actions/setup-dotnet@v4 with: - dotnet-version: 8.0.x + dotnet-version: 10.0.x - name: Get Version id: get_version @@ -67,28 +67,12 @@ jobs: - name: Zip Main Build Output run: zip -r CS2-SimpleAdmin-${{ steps.get_version.outputs.VERSION }}.zip ${{ env.OUTPUT_PATH }} - - name: Extract & Zip StatusBlocker Linux - run: | - mkdir -p statusblocker-linux && - tar -xzf Modules/CS2-SimpleAdmin_StealthModule/METAMOD\ PLUGIN/StatusBlocker-v*-linux.tar.gz -C statusblocker-linux && - cd statusblocker-linux && - zip -r ../StatusBlocker-linux-${{ steps.get_version.outputs.VERSION }}.zip ./* - - - name: Extract & Zip StatusBlocker Windows - run: | - mkdir -p statusblocker-windows && - tar -xzf Modules/CS2-SimpleAdmin_StealthModule/METAMOD\ PLUGIN/StatusBlocker-v*-windows.tar.gz -C statusblocker-windows && - cd statusblocker-windows && - zip -r ../StatusBlocker-windows-${{ steps.get_version.outputs.VERSION }}.zip ./* - - name: Upload all artifacts uses: actions/upload-artifact@v4 with: name: CS2-SimpleAdmin-Build-Artifacts path: | CS2-SimpleAdmin-${{ steps.get_version.outputs.VERSION }}.zip - StatusBlocker-linux-${{ steps.get_version.outputs.VERSION }}.zip - StatusBlocker-windows-${{ steps.get_version.outputs.VERSION }}.zip publish: needs: build @@ -109,8 +93,6 @@ jobs: with: artifacts: | CS2-SimpleAdmin-${{ needs.build.outputs.build_version }}.zip - StatusBlocker-linux-${{ needs.build.outputs.build_version }}.zip - StatusBlocker-windows-${{ needs.build.outputs.build_version }}.zip name: "CS2-SimpleAdmin-${{ needs.build.outputs.build_version }}" tag: "build-${{ needs.build.outputs.build_version }}" body: | @@ -121,8 +103,5 @@ jobs: After the first launch, configure the plugin using the JSON config file at: addons/counterstrikesharp/configs/plugins/CS2-SimpleAdmin/CS2-SimpleAdmin.json - - StatusBlocker: - Place the plugin files directly into the addons directory. - This plugin is a Metamod module for the StealthModule and does not require a subfolder. Remember to restart or reload your game server after installing and configuring the plugins. diff --git a/.gitignore b/.gitignore index 28e335d..55358e7 100644 --- a/.gitignore +++ b/.gitignore @@ -10,3 +10,7 @@ Modules/CS2-SimpleAdmin_ExampleModule/CS2-SimpleAdmin_ExampleModule.sln.DotSetti CS2-SimpleAdmin_BanSoundModule — kopia *.user CLAUDE.md +/Modules/CS2-SimpleAdmin_BanSoundModule +/Modules/CS2-SimpleAdmin_StealthModule/METAMOD PLUGIN + +release-out/ diff --git a/CS2-SimpleAdmin/CS2-SimpleAdmin.cs b/CS2-SimpleAdmin/CS2-SimpleAdmin.cs index be476fe..19a5546 100644 --- a/CS2-SimpleAdmin/CS2-SimpleAdmin.cs +++ b/CS2-SimpleAdmin/CS2-SimpleAdmin.cs @@ -14,7 +14,7 @@ using MySqlConnector; namespace CS2_SimpleAdmin; -[MinimumApiVersion(300)] +[MinimumApiVersion(369)] public partial class CS2_SimpleAdmin : BasePlugin, IPluginConfig { internal static CS2_SimpleAdmin Instance { get; private set; } = new(); @@ -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.7.8-beta-10b"; + public override string ModuleVersion => "1.8.2b-zio1"; public override void Load(bool hotReload) { @@ -46,7 +46,7 @@ public partial class CS2_SimpleAdmin : BasePlugin, IPluginConfig p.IsValid && p.Connected == PlayerConnectedState.PlayerConnected && !p.IsHLTV).ToArray()) + foreach (var player in Utilities.GetPlayers().Where(p => p.IsValid && p is { Connected: PlayerConnectedState.Connected, IsHLTV: false }).ToArray()) { if (!player.IsBot) PlayerManager.LoadPlayerData(player, true); @@ -55,14 +55,12 @@ public partial class CS2_SimpleAdmin : BasePlugin, IPluginConfig(GameData.GetSignature("CBasePlayerController_SetPawn")); SimpleAdminApi = new Api.CS2_SimpleAdminApi(); Capabilities.RegisterPluginCapability(ICS2_SimpleAdminApi.PluginCapability, () => SimpleAdminApi); - + PlayersTimer?.Kill(); PlayersTimer = null; PlayerManager.CheckPlayersTimer(); @@ -85,7 +83,7 @@ public partial class CS2_SimpleAdmin : BasePlugin, IPluginConfig ReloadAdmins(null)); + AddTimer(3.0f, () => ReloadAdmins(null)); if (!CoreConfig.UnlockConCommands) { @@ -95,20 +93,11 @@ public partial class CS2_SimpleAdmin : BasePlugin, IPluginConfig - net8.0 + net10.0 CS2_SimpleAdmin enable enable @@ -19,16 +19,16 @@ - + none runtime compile; build; native; contentfiles; analyzers; buildtransitive - - + + - - + + diff --git a/CS2-SimpleAdmin/Commands/basebans.cs b/CS2-SimpleAdmin/Commands/basebans.cs index 761ecbd..f0a20ba 100644 --- a/CS2-SimpleAdmin/Commands/basebans.cs +++ b/CS2-SimpleAdmin/Commands/basebans.cs @@ -27,7 +27,7 @@ public partial class CS2_SimpleAdmin var targets = GetTarget(command); if (targets == null) return; - var playersToTarget = targets.Players.Where(player => player is { IsValid: true, Connected: PlayerConnectedState.PlayerConnected, IsHLTV: false }).ToList(); + var playersToTarget = targets.Players.Where(player => player is { IsValid: true, Connected: PlayerConnectedState.Connected, IsHLTV: false }).ToList(); if (playersToTarget.Count > 1 && Config.OtherSettings.DisableDangerousCommands || playersToTarget.Count == 0) { @@ -373,7 +373,7 @@ public partial class CS2_SimpleAdmin var targets = GetTarget(command); if (targets == null) return; - var playersToTarget = targets.Players.Where(player => player.IsValid && player.Connected == PlayerConnectedState.PlayerConnected && !player.IsHLTV).ToList(); + var playersToTarget = targets.Players.Where(player => player.IsValid && player.Connected == PlayerConnectedState.Connected && !player.IsHLTV).ToList(); if (playersToTarget.Count > 1 && Config.OtherSettings.DisableDangerousCommands || playersToTarget.Count == 0) { diff --git a/CS2-SimpleAdmin/Commands/basecommands.cs b/CS2-SimpleAdmin/Commands/basecommands.cs index 64e828a..9513cbc 100644 --- a/CS2-SimpleAdmin/Commands/basecommands.cs +++ b/CS2-SimpleAdmin/Commands/basecommands.cs @@ -501,7 +501,7 @@ public partial class CS2_SimpleAdmin Task.Run(async () => { - await PermissionManager.CrateGroupsJsonFile(); + await PermissionManager.CreateGroupsJsonFile(); await PermissionManager.CreateAdminsJsonFile(); var adminsFile = await File.ReadAllTextAsync(Instance.ModuleDirectory + "/data/admins.json"); diff --git a/CS2-SimpleAdmin/Commands/basecomms.cs b/CS2-SimpleAdmin/Commands/basecomms.cs index e202c1e..6305f40 100644 --- a/CS2-SimpleAdmin/Commands/basecomms.cs +++ b/CS2-SimpleAdmin/Commands/basecomms.cs @@ -89,7 +89,7 @@ public partial class CS2_SimpleAdmin }); // Add penalty to the player's penalty manager - PlayerPenaltyManager.AddPenalty(player.Slot, PenaltyType.Gag, Time.ActualDateTime().AddMinutes(time), time); + PlayerPenaltyManager.AddPenalty(player.Slot, PenaltyType.Gag, Time.ActualDateTime().AddMinutes(time), time, reason); // Determine message keys and arguments based on gag time (permanent or timed) var (messageKey, activityMessageKey, playerArgs, adminActivityArgs) = time == 0 @@ -398,7 +398,7 @@ public partial class CS2_SimpleAdmin }); // Add penalty to the player's penalty manager - PlayerPenaltyManager.AddPenalty(player.Slot, PenaltyType.Mute, Time.ActualDateTime().AddMinutes(time), time); + PlayerPenaltyManager.AddPenalty(player.Slot, PenaltyType.Mute, Time.ActualDateTime().AddMinutes(time), time, reason); // Determine message keys and arguments based on mute time (permanent or timed) var (messageKey, activityMessageKey, playerArgs, adminActivityArgs) = time == 0 @@ -709,7 +709,7 @@ public partial class CS2_SimpleAdmin }); // Add penalty to the player's penalty manager - PlayerPenaltyManager.AddPenalty(player.Slot, PenaltyType.Silence, Time.ActualDateTime().AddMinutes(time), time); + PlayerPenaltyManager.AddPenalty(player.Slot, PenaltyType.Silence, Time.ActualDateTime().AddMinutes(time), time, reason); player.VoiceFlags = VoiceFlags.Muted; // Determine message keys and arguments based on silence time (permanent or timed) diff --git a/CS2-SimpleAdmin/Commands/playercommands.cs b/CS2-SimpleAdmin/Commands/playercommands.cs index 698c3f3..2330ccf 100644 --- a/CS2-SimpleAdmin/Commands/playercommands.cs +++ b/CS2-SimpleAdmin/Commands/playercommands.cs @@ -44,7 +44,7 @@ public partial class CS2_SimpleAdmin /// Optional command info for logging. internal static void Slay(CCSPlayerController? caller, CCSPlayerController player, string? callerName = null, CommandInfo? command = null) { - if (!player.IsValid || player.Connected != PlayerConnectedState.PlayerConnected) return; + if (!player.IsValid || player.Connected != PlayerConnectedState.Connected) return; if (!caller.CanTarget(player)) return; // Set default caller name if not provided @@ -93,7 +93,7 @@ public partial class CS2_SimpleAdmin playersToTarget.ForEach(player => { - if (player.Connected != PlayerConnectedState.PlayerConnected) + if (player.Connected != PlayerConnectedState.Connected) return; if (caller!.CanTarget(player)) @@ -207,7 +207,7 @@ public partial class CS2_SimpleAdmin internal static void ChangeTeam(CCSPlayerController? caller, CCSPlayerController player, string teamName, CsTeam teamNum, bool kill, CommandInfo? command = null) { // Check if the player is valid and connected - if (!player.IsValid || player.Connected != PlayerConnectedState.PlayerConnected) + if (!player.IsValid || player.Connected != PlayerConnectedState.Connected) return; // Ensure the caller can target the player @@ -284,7 +284,7 @@ public partial class CS2_SimpleAdmin playersToTarget.ForEach(player => { // Check if the player is connected and can be targeted - if (player.Connected != PlayerConnectedState.PlayerConnected || !caller!.CanTarget(player)) + if (player.Connected != PlayerConnectedState.Connected || !caller!.CanTarget(player)) return; // Determine message key and arguments for the rename notification @@ -330,7 +330,7 @@ public partial class CS2_SimpleAdmin playersToTarget.ForEach(player => { // Check if the player is connected and can be targeted - if (player.Connected != PlayerConnectedState.PlayerConnected || !caller!.CanTarget(player)) + if (player.Connected != PlayerConnectedState.Connected || !caller!.CanTarget(player)) return; // Determine message key and arguments for the rename notification @@ -379,7 +379,7 @@ public partial class CS2_SimpleAdmin return; destinationPlayer = targets.Players.FirstOrDefault(p => - p is { IsValid: true, IsHLTV: false, Connected: PlayerConnectedState.PlayerConnected, PlayerPawn.Value.LifeState: (int)LifeState_t.LIFE_ALIVE }); + p is { IsValid: true, IsHLTV: false, Connected: PlayerConnectedState.Connected, PlayerPawn.Value.LifeState: (int)LifeState_t.LIFE_ALIVE }); if (destinationPlayer == null || !caller.CanTarget(destinationPlayer) || caller.PlayerPawn.Value == null) return; @@ -399,7 +399,7 @@ public partial class CS2_SimpleAdmin return; playersToTeleport = targets.Players - .Where(p => p is { IsValid: true, IsHLTV: false, Connected: PlayerConnectedState.PlayerConnected, PlayerPawn.Value.LifeState: (int)LifeState_t.LIFE_ALIVE } && caller.CanTarget(p)) + .Where(p => p is { IsValid: true, IsHLTV: false, Connected: PlayerConnectedState.Connected, PlayerPawn.Value.LifeState: (int)LifeState_t.LIFE_ALIVE } && caller.CanTarget(p)) .ToList(); if (!playersToTeleport.Any()) @@ -476,7 +476,7 @@ public partial class CS2_SimpleAdmin destinationPlayer = caller; playersToTeleport = targets.Players - .Where(p => p is { IsValid: true, IsHLTV: false, Connected: PlayerConnectedState.PlayerConnected, PlayerPawn.Value.LifeState: (int)LifeState_t.LIFE_ALIVE } && caller.CanTarget(p)) + .Where(p => p is { IsValid: true, IsHLTV: false, Connected: PlayerConnectedState.Connected, PlayerPawn.Value.LifeState: (int)LifeState_t.LIFE_ALIVE } && caller.CanTarget(p)) .ToList(); } else @@ -486,7 +486,7 @@ public partial class CS2_SimpleAdmin return; destinationPlayer = destination.Players.FirstOrDefault(p => - p is { IsValid: true, IsHLTV: false, Connected: PlayerConnectedState.PlayerConnected, PlayerPawn.Value.LifeState: (int)LifeState_t.LIFE_ALIVE }); + p is { IsValid: true, IsHLTV: false, Connected: PlayerConnectedState.Connected, PlayerPawn.Value.LifeState: (int)LifeState_t.LIFE_ALIVE }); if (destinationPlayer == null) return; @@ -497,7 +497,7 @@ public partial class CS2_SimpleAdmin return; playersToTeleport = targets.Players - .Where(p => p is { IsValid: true, IsHLTV: false, Connected: PlayerConnectedState.PlayerConnected, PlayerPawn.Value.LifeState: (int)LifeState_t.LIFE_ALIVE } && caller!.CanTarget(p)) + .Where(p => p is { IsValid: true, IsHLTV: false, Connected: PlayerConnectedState.Connected, PlayerPawn.Value.LifeState: (int)LifeState_t.LIFE_ALIVE } && caller!.CanTarget(p)) .ToList(); } diff --git a/CS2-SimpleAdmin/Database/IDatabaseProvider.cs b/CS2-SimpleAdmin/Database/IDatabaseProvider.cs index 02ff3e8..e2a4ae4 100644 --- a/CS2-SimpleAdmin/Database/IDatabaseProvider.cs +++ b/CS2-SimpleAdmin/Database/IDatabaseProvider.cs @@ -40,6 +40,7 @@ public interface IDatabaseProvider string GetUpdateBanStatusQuery(); string GetExpireBansQuery(bool multiServer); string GetExpireIpBansQuery(bool multiServer); + string GetExpireOldPlayerIpsQuery(); // MuteManager string GetAddMuteQuery(bool includePlayerName); diff --git a/CS2-SimpleAdmin/Database/MysqlDatabaseProvider.cs b/CS2-SimpleAdmin/Database/MysqlDatabaseProvider.cs index 6d733b9..fa9a27e 100644 --- a/CS2-SimpleAdmin/Database/MysqlDatabaseProvider.cs +++ b/CS2-SimpleAdmin/Database/MysqlDatabaseProvider.cs @@ -251,6 +251,11 @@ public class MySqlDatabaseProvider(string connectionString) : IDatabaseProvider ? "UPDATE sa_bans SET player_ip = NULL WHERE status = 'ACTIVE' AND ends <= @ipBansTime" : "UPDATE sa_bans SET player_ip = NULL WHERE status = 'ACTIVE' AND ends <= @ipBansTime AND server_id = @serverid"; } + + public string GetExpireOldPlayerIpsQuery() + { + return "DELETE FROM sa_players_ips WHERE used_at <= @ipBansTime"; + } public string GetAddMuteQuery(bool includePlayerName) => includePlayerName diff --git a/CS2-SimpleAdmin/Database/SqliteDatabaseProvider.cs b/CS2-SimpleAdmin/Database/SqliteDatabaseProvider.cs index 93772be..d6186fb 100644 --- a/CS2-SimpleAdmin/Database/SqliteDatabaseProvider.cs +++ b/CS2-SimpleAdmin/Database/SqliteDatabaseProvider.cs @@ -166,6 +166,9 @@ public class SqliteDatabaseProvider(string filePath) : IDatabaseProvider multiServer ? "UPDATE sa_bans SET player_ip = NULL WHERE status = 'ACTIVE' AND ends <= @ipBansTime" : "UPDATE sa_bans SET player_ip = NULL WHERE status = 'ACTIVE' AND ends <= @ipBansTime AND server_id = @serverid"; + + public string GetExpireOldPlayerIpsQuery() => + "DELETE FROM sa_players_ips WHERE used_at <= @ipBansTime"; public string GetAdminsQuery() => """ diff --git a/CS2-SimpleAdmin/Events.cs b/CS2-SimpleAdmin/Events.cs index d9f8772..ede2121 100644 --- a/CS2-SimpleAdmin/Events.cs +++ b/CS2-SimpleAdmin/Events.cs @@ -253,13 +253,13 @@ public partial class CS2_SimpleAdmin player.DiePosition = null; } - AddTimer(0.41f, () => + AddTimer(0.5f, () => { foreach (var list in RenamedPlayers) { var player = Utilities.GetPlayerFromSteamId(list.Key); - if (player == null || !player.IsValid || player.Connected != PlayerConnectedState.PlayerConnected) + if (player == null || !player.IsValid || player.Connected != PlayerConnectedState.Connected) continue; if (player.PlayerName.Equals(list.Value)) @@ -328,7 +328,7 @@ public partial class CS2_SimpleAdmin ? Utilities.GetPlayerFromUserid(userId) : null; - if (target == null || !target.IsValid || target.Connected != PlayerConnectedState.PlayerConnected) + if (target == null || !target.IsValid || target.Connected != PlayerConnectedState.Connected) return HookResult.Continue; return !player.CanTarget(target) ? HookResult.Stop : HookResult.Continue; @@ -347,13 +347,15 @@ public partial class CS2_SimpleAdmin return HookResult.Continue; } + // zio: gagged players can still reach staff with @ in team chat, handled below. + var staffChat = command == "say_team" && info.GetArg(1).StartsWith('@'); // if (!Config.OtherSettings.UserMessageGagChatType) // { - if (PlayerPenaltyManager.IsPenalized(player.Slot, PenaltyType.Gag, out DateTime? endDateTime) || - PlayerPenaltyManager.IsPenalized(player.Slot, PenaltyType.Silence, out endDateTime)) + if (!staffChat && (PlayerPenaltyManager.IsPenalized(player.Slot, PenaltyType.Gag, out DateTime? endDateTime) || + PlayerPenaltyManager.IsPenalized(player.Slot, PenaltyType.Silence, out endDateTime))) { if (_localizer != null && endDateTime is not null) - player.SendLocalizedMessage(_localizer, "sa_player_penalty_chat_active", endDateTime.Value.ToString("g", player.GetLanguage())); + SendChatPenaltyMessage(player, endDateTime.Value); return HookResult.Stop; } // } @@ -388,6 +390,36 @@ public partial class CS2_SimpleAdmin return HookResult.Stop; } + // zio: says how long the gag (or silence) has left and why, in place of its end date. + private void SendChatPenaltyMessage(CCSPlayerController player, DateTime endDateTime) + { + var type = PlayerPenaltyManager.IsPenalized(player.Slot, PenaltyType.Gag, out var gagEnd) && gagEnd == endDateTime + ? PenaltyType.Gag + : PenaltyType.Silence; + var (remaining, reason) = PlayerPenaltyManager.GetPenaltyDetails(player.Slot, type, endDateTime); + var kind = type == PenaltyType.Gag ? "gag" : "silence"; + using (new WithTemporaryCulture(player.GetLanguage())) + { + if (string.IsNullOrWhiteSpace(reason)) + reason = _localizer!["sa_player_penalty_no_reason"]; + if (remaining is null) + player.SendLocalizedMessage(_localizer, $"sa_player_penalty_{kind}_active_perm", reason); + else + player.SendLocalizedMessage(_localizer, $"sa_player_penalty_{kind}_active", FormatRemaining(remaining.Value), reason); + } + } + + private static string FormatRemaining(TimeSpan remaining) + { + if (remaining.TotalMinutes < 1) + return "less than a minute"; + var parts = new List(); + if (remaining.Days > 0) parts.Add($"{remaining.Days}d"); + if (remaining.Hours > 0) parts.Add($"{remaining.Hours}h"); + if (remaining.Minutes > 0 && remaining.Days == 0) parts.Add($"{remaining.Minutes}m"); + return string.Join(" ", parts); + } + /*public HookResult OnCommandSay(CCSPlayerController? player, CommandInfo info) { if (player == null || !player.IsValid || player.IsBot) @@ -473,7 +505,7 @@ public partial class CS2_SimpleAdmin var player = @event.Userid; if (player?.UserId == null || !player.IsValid || player.IsHLTV || - player.Connected != PlayerConnectedState.PlayerConnected || !PlayersInfo.ContainsKey(player.SteamID) || + player.Connected != PlayerConnectedState.Connected || !PlayersInfo.ContainsKey(player.SteamID) || @event.Attacker == null) return HookResult.Continue; diff --git a/CS2-SimpleAdmin/Helper.cs b/CS2-SimpleAdmin/Helper.cs index a15660c..aff807d 100644 --- a/CS2-SimpleAdmin/Helper.cs +++ b/CS2-SimpleAdmin/Helper.cs @@ -11,12 +11,14 @@ using CounterStrikeSharp.API.ValveConstants.Protobuf; using CS2_SimpleAdminApi; using Microsoft.Extensions.Localization; using Microsoft.Extensions.Logging; +using System.Diagnostics.CodeAnalysis; using System.Reflection; using System.Runtime.CompilerServices; using System.Runtime.InteropServices; using System.Runtime.Serialization; using System.Text; using System.Text.Json; +using System.Text.Json.Nodes; using System.Text.RegularExpressions; using CounterStrikeSharp.API.Core.Plugin.Host; using CounterStrikeSharp.API.Modules.Entities.Constants; @@ -64,7 +66,7 @@ internal static class Helper public static List GetValidPlayers() { - return CS2_SimpleAdmin.CachedPlayers.AsValueEnumerable().Where(p => p.IsValid && p.Connected == PlayerConnectedState.PlayerConnected).ToList(); + return CS2_SimpleAdmin.CachedPlayers.AsValueEnumerable().Where(p => p.IsValid && p.Connected == PlayerConnectedState.Connected).ToList(); } public static List GetValidPlayersWithBots() @@ -855,26 +857,34 @@ internal static class Helper } } - public static void UpdateConfig(T config) where T : BasePluginConfig, new() + public static void UpdateConfig(BasePluginConfig config) { // get newest config version - var newCfgVersion = new T().Version; + var configType = config.GetType(); + var newCfgVersion = ((BasePluginConfig)Activator.CreateInstance(configType)!).Version; // loaded config is up to date if (config.Version == newCfgVersion) return; - // update the version - config.Version = newCfgVersion; + // Load existing JSON file and update version property + if (!File.Exists(CfgPath)) + return; - // serialize the updated config back to json - var updatedJsonContent = JsonSerializer.Serialize(config, - new JsonSerializerOptions + var json = File.ReadAllText(CfgPath); + var node = JsonNode.Parse(json); + + if (node != null) + { + node["Version"] = newCfgVersion; + var updatedJsonContent = node.ToJsonString(new JsonSerializerOptions { WriteIndented = true, Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping }); - File.WriteAllText(CfgPath, updatedJsonContent); + + File.WriteAllText(CfgPath, updatedJsonContent); + } } public static void TryLogCommandOnDiscord(CCSPlayerController? caller, string commandString) @@ -1001,6 +1011,10 @@ public static class PluginInfo 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(" "); } } diff --git a/CS2-SimpleAdmin/Managers/BanManager.cs b/CS2-SimpleAdmin/Managers/BanManager.cs index 2aac526..5863b59 100644 --- a/CS2-SimpleAdmin/Managers/BanManager.cs +++ b/CS2-SimpleAdmin/Managers/BanManager.cs @@ -431,6 +431,9 @@ public async Task UnbanPlayer(string playerPattern, string adminSteamId, string var ipBansTime = currentTime.AddDays(-CS2_SimpleAdmin.Instance.Config.OtherSettings.ExpireOldIpBans); sql = databaseProvider.GetExpireIpBansQuery(CS2_SimpleAdmin.Instance.Config.MultiServerMode); await connection.ExecuteAsync(sql, new { ipBansTime, CS2_SimpleAdmin.ServerId }); + + sql = databaseProvider.GetExpireOldPlayerIpsQuery(); + await connection.ExecuteAsync(sql, new { ipBansTime }); } } catch (Exception) diff --git a/CS2-SimpleAdmin/Managers/CacheManager.cs b/CS2-SimpleAdmin/Managers/CacheManager.cs index 9462f1e..d02e036 100644 --- a/CS2-SimpleAdmin/Managers/CacheManager.cs +++ b/CS2-SimpleAdmin/Managers/CacheManager.cs @@ -73,7 +73,7 @@ internal class CacheManager: IDisposable if (CS2_SimpleAdmin.Instance.Config.OtherSettings.CheckMultiAccountsByIp) { // Optimization: Load IP history and build cache in single pass - var ipHistory = await connection.QueryAsync<(ulong steamid, string? name, uint address, DateTime used_at)>( + var ipHistory = await connection.QueryAsync( "SELECT steamid, name, address, used_at FROM sa_players_ips ORDER BY steamid, address, used_at DESC"); var unknownName = CS2_SimpleAdmin._localizer?["sa_unknown"] ?? "Unknown"; @@ -84,24 +84,24 @@ internal class CacheManager: IDisposable foreach (var record in ipHistory) { // When we encounter a new steamid, save the previous one - if (record.steamid != currentSteamId && currentSteamId != 0) + if ((ulong)record.Steamid != currentSteamId && currentSteamId != 0) { _playerIpsCache[currentSteamId] = currentIpSet; currentIpSet = new HashSet(new IpRecordComparer()); latestIpTimestamps.Clear(); } - currentSteamId = record.steamid; + currentSteamId = (ulong)record.Steamid; // Only keep the latest timestamp for each IP - if (!latestIpTimestamps.TryGetValue(record.address, out var existingTimestamp) || - record.used_at > existingTimestamp) + if (!latestIpTimestamps.TryGetValue(record.Address, out var existingTimestamp) || + record.Used_at > existingTimestamp) { - latestIpTimestamps[record.address] = record.used_at; + latestIpTimestamps[record.Address] = record.Used_at; currentIpSet.Add(new IpRecord( - record.address, - record.used_at, - string.IsNullOrEmpty(record.name) ? unknownName : record.name + record.Address, + record.Used_at, + string.IsNullOrEmpty(record.Name) ? unknownName : record.Name )); } } @@ -272,34 +272,34 @@ internal class CacheManager: IDisposable if (string.IsNullOrWhiteSpace(ban.PlayerIp) || !IpHelper.TryConvertIpToUint(ban.PlayerIp, out var ipUInt) || - !_ipIndex.TryGetValue(ipUInt, out var ipBans)) continue; - { - ipBans.RemoveAll(b => b.Id == id); - if (ipBans.Count == 0) - _ipIndex.TryRemove(ipUInt, out _); - } + !_ipIndex.TryGetValue(ipUInt, out var ipBans)) + continue; + + ipBans.RemoveAll(b => b.Id == id); + if (ipBans.Count == 0) + _ipIndex.TryRemove(ipUInt, out _); } } if (CS2_SimpleAdmin.Instance.Config.OtherSettings.CheckMultiAccountsByIp) { - var ipHistory = (await connection.QueryAsync<(ulong steamid, string? name, uint address, DateTime used_at)>( + var ipHistory = (await connection.QueryAsync( "SELECT steamid, name, address, used_at FROM sa_players_ips WHERE used_at >= @lastUpdate ORDER BY used_at DESC LIMIT 300", new { lastUpdate = _lastUpdateTime })); - foreach (var group in ipHistory.AsValueEnumerable().GroupBy(x => x.steamid)) + foreach (var group in ipHistory.AsValueEnumerable().GroupBy(x => x.Steamid)) { var ipSet = new HashSet( group - .GroupBy(x => x.address) + .GroupBy(x => x.Address) .Select(g => { - var latest = g.MaxBy(x => x.used_at); + var latest = g.MaxBy(x => x.Used_at); return new IpRecord( g.Key, - latest.used_at, - !string.IsNullOrEmpty(latest.name) - ? latest.name + latest.Used_at, + !string.IsNullOrEmpty(latest.Name) + ? latest.Name : CS2_SimpleAdmin._localizer?["sa_unknown"] ?? "Unknown" ); }), @@ -307,7 +307,7 @@ internal class CacheManager: IDisposable ); _playerIpsCache.AddOrUpdate( - group.Key, + (ulong)group.Key, _ => ipSet, (_, existingSet) => { @@ -626,34 +626,81 @@ internal class CacheManager: IDisposable if (_cachedIgnoredIps.Contains(ipUInt)) return false; - if (!_ipIndex.TryGetValue(ipUInt, out var ipBanRecords)) - return false; - - var ipBan = ipBanRecords.FirstOrDefault(r => r.StatusEnum == BanStatus.ACTIVE); - if (ipBan == null) - return false; - - if (!_banCache.TryGetValue(ipBan.Id, out var cachedIpBan) || cachedIpBan.StatusEnum != BanStatus.ACTIVE) - return false; - - var expireOldIpBans = CS2_SimpleAdmin.Instance.Config.OtherSettings.ExpireOldIpBans; - if (expireOldIpBans > 0) + // Direct ip ban (ban record has player_ip set) + if (_ipIndex.TryGetValue(ipUInt, out var ipBanRecords)) { - var cutoff = Time.ActualDateTime().AddDays(-expireOldIpBans); - if (ipBan.Created < cutoff) - return false; + var ipBan = ipBanRecords.FirstOrDefault(r => r.StatusEnum == BanStatus.ACTIVE); + if (ipBan != null && _banCache.TryGetValue(ipBan.Id, out var cachedIpBan) && cachedIpBan.StatusEnum == BanStatus.ACTIVE) + { + var expireOldIpBans = CS2_SimpleAdmin.Instance.Config.OtherSettings.ExpireOldIpBans; + if (expireOldIpBans <= 0 || ipBan.Created >= Time.ActualDateTime().AddDays(-expireOldIpBans)) + { + if (string.IsNullOrEmpty(ipBan.PlayerName)) + ipBan.PlayerName = playerName; + ipBan.PlayerSteamId ??= steamId; + _ = Task.Run(() => UpdatePlayerData(playerName, steamId, ipAddress)); + return true; + } + } } - var unknownName = CS2_SimpleAdmin._localizer?["sa_unknown"] ?? "Unknown"; + // Multiaccount ban - check if other accounts using current ip are banned + if (!_playerIpsCache.IsEmpty) + { + foreach (var (otherSteamId, ipSet) in _playerIpsCache) + { + // Skip current player + if (otherSteamId == steamId) + continue; - if (string.IsNullOrEmpty(ipBan.PlayerName)) - ipBan.PlayerName = playerName; + // Check if this ip is in the other accounts ip history + if (ipSet.All(record => record.Ip != ipUInt)) continue; + // Found another account using this ip - check if its banned + if (!_steamIdIndex.TryGetValue(otherSteamId, out var otherSteamBans)) continue; + var activeBan = otherSteamBans.FirstOrDefault(b => b.StatusEnum == BanStatus.ACTIVE); + if (activeBan == null || !_banCache.TryGetValue(activeBan.Id, out var cachedBan) || + cachedBan.StatusEnum != BanStatus.ACTIVE) continue; + _ = Task.Run(() => UpdatePlayerData(playerName, steamId, ipAddress)); + return true; + } + } - ipBan.PlayerSteamId ??= steamId; + // Multiaccount ban - check if this player used any ip where other banned accounts are connected + // Search sa_players_ips for all accounts sharing the same ips as current player + if (!CS2_SimpleAdmin.Instance.Config.OtherSettings.CheckMultiAccountsByIp) + return false; - _ = Task.Run(() => UpdatePlayerData(playerName, steamId, ipAddress)); + if (!_playerIpsCache.TryGetValue(steamId, out var playerIps)) + return false; - return true; + // For each ip the player used (current or historical) + foreach (var playerIpRecord in playerIps) + { + // Search sa_players_ips for other accounts using this same ip (as uint) + foreach (var (otherSteamId, otherIpSet) in _playerIpsCache) + { + if (otherSteamId == steamId) + continue; + + // Check if this other account used the player ip + if (otherIpSet.All(record => record.Ip != playerIpRecord.Ip)) + continue; + + // Check if this other account is banned + if (!_steamIdIndex.TryGetValue(otherSteamId, out var otherSteamBans)) + continue; + + var activeBan = otherSteamBans.FirstOrDefault(b => b.StatusEnum == BanStatus.ACTIVE); + if (activeBan == null || !_banCache.TryGetValue(activeBan.Id, out var cachedBan) || + cachedBan.StatusEnum != BanStatus.ACTIVE) + continue; + + _ = Task.Run(() => UpdatePlayerData(playerName, steamId, ipAddress)); + return true; + } + } + + return false; } /// diff --git a/CS2-SimpleAdmin/Managers/PermissionManager.cs b/CS2-SimpleAdmin/Managers/PermissionManager.cs index c9f2b41..5826e74 100644 --- a/CS2-SimpleAdmin/Managers/PermissionManager.cs +++ b/CS2-SimpleAdmin/Managers/PermissionManager.cs @@ -1,4 +1,4 @@ -using CounterStrikeSharp.API; +using CounterStrikeSharp.API; using CounterStrikeSharp.API.Modules.Entities; using Dapper; using Microsoft.Extensions.Logging; @@ -12,54 +12,8 @@ namespace CS2_SimpleAdmin.Managers; public class PermissionManager(IDatabaseProvider? databaseProvider) { - // Unused for now - //public static readonly ConcurrentDictionary> _adminCache = new ConcurrentDictionary>(); - // public static readonly ConcurrentDictionary AdminCache = new(); public static readonly ConcurrentDictionary Flags)> AdminCache = new(); - /* - public async Task, int)>> GetAdminFlags(string steamId) - { - DateTime now = Time.ActualDateTime(); - - await using MySqlConnection connection = await _database.GetConnectionAsync(); - - string sql = "SELECT flags, immunity, ends FROM sa_admins WHERE player_steamid = @PlayerSteamID AND (ends IS NULL OR ends > @CurrentTime) AND (server_id IS NULL OR server_id = @serverid)"; - List? activeFlags = (await connection.QueryAsync(sql, new { PlayerSteamID = steamId, CurrentTime = now, serverid = CS2_SimpleAdmin.ServerId }))?.ToList(); - - if (activeFlags == null) - { - return new List<(List, int)>(); - } - - List<(List, int)> filteredFlagsWithImmunity = []; - - foreach (dynamic flags in activeFlags) - { - if (flags is not IDictionary flagsDict) - { - continue; - } - - if (!flagsDict.TryGetValue("flags", out var flagsValueObj) || !flagsDict.TryGetValue("immunity", out var immunityValueObj)) - { - continue; - } - - if (!(flagsValueObj is string flagsValue) || !int.TryParse(immunityValueObj.ToString(), out var immunityValue)) - { - continue; - } - - //Console.WriteLine($"Flags: {flagsValue}, Immunity: {immunityValue}"); - - filteredFlagsWithImmunity.Add((flagsValue.Split(',').ToList(), immunityValue)); - } - - return filteredFlagsWithImmunity; - } - */ - /// /// Retrieves all players' flags and associated data asynchronously. /// @@ -100,7 +54,7 @@ public class PermissionManager(IDatabaseProvider? databaseProvider) string playerName = g.Key.playerName as string ?? string.Empty; - // tutaj zakładamy, że Dapper zwraca już string (nie dynamic) + // Dapper returns string here, not dynamic var flags = g.Select(r => r.flag as string ?? string.Empty) .Distinct() .ToList(); @@ -118,72 +72,6 @@ public class PermissionManager(IDatabaseProvider? databaseProvider) } } - - /* - public async Task, List>, int>>> GetAllGroupsFlags() - { - try - { - await using MySqlConnection connection = await _database.GetConnectionAsync(); - - string sql = "SELECT group_id FROM sa_groups_servers WHERE server_id = @serverid"; - var groupIds = connection.Query(sql, new { serverid = CS2_SimpleAdmin.ServerId }).ToList(); - - sql = @" - SELECT g.group_id, f.flag - FROM sa_groups_flags f - JOIN sa_groups_servers g ON f.group_id = g.group_id - WHERE g.server_id = @serverid"; - - var groupFlagData = connection.Query(sql, new { serverid = CS2_SimpleAdmin.ServerId }).ToList(); - - if (groupIds.Count == 0 || groupFlagData.Count == 0) - { - return []; - } - - var groupInfoDictionary = new Dictionary, List>, int>>(); - - foreach (var groupId in groupIds) - { - groupInfoDictionary[groupId] = new Tuple, List>, int>([], [], 0); - } - - foreach (var row in groupFlagData) - { - var groupId = (int)row.group_id; - var flag = (string)row.flag; - - groupInfoDictionary[groupId].Item1.Add(flag); - } - - sql = @" - SELECT a.group_id, a.player_steamid, a.ends, g.immunity, g.name - FROM sa_admins a - JOIN sa_groups g ON a.group_id = g.id - WHERE a.group_id IN @groupIds"; - - var playerData = (await connection.QueryAsync(sql, new { groupIds })).ToList(); - - foreach (var row in playerData) - { - var groupId = (int)row.group_id; - var playerSteamid = (string)row.player_steamid; - var ends = row.ends as DateTime?; - var immunity = (int)row.immunity; - - groupInfoDictionary[groupId].Item2.Add(new Tuple(playerSteamid, ends)); - groupInfoDictionary[groupId] = new Tuple, List>, int>(groupInfoDictionary[groupId].Item1, groupInfoDictionary[groupId].Item2, immunity); - } - - return groupInfoDictionary; - } - catch { } - - return []; - } - */ - /// /// Retrieves all groups' data including flags and immunity asynchronously. /// @@ -192,13 +80,9 @@ public class PermissionManager(IDatabaseProvider? databaseProvider) { if (databaseProvider == null) return []; - await using var connection = await databaseProvider.CreateConnectionAsync(); - ; try { - // var sql = "SELECT group_id FROM sa_groups_servers WHERE (server_id = @serverid OR server_id IS NULL)"; - // var groupDataSql = connection.Query(sql, new { serverid = CS2_SimpleAdmin.ServerId }).ToList(); - + await using var connection = await databaseProvider.CreateConnectionAsync(); var sql = databaseProvider.GetGroupsQuery(); var groupData = connection.Query(sql, new { serverid = CS2_SimpleAdmin.ServerId }).ToList(); if (groupData.Count == 0) @@ -213,11 +97,9 @@ public class PermissionManager(IDatabaseProvider? databaseProvider) var flag = (string)row.flag; var immunity = (int)row.immunity; - // Check if the group name already exists in the dictionary if (!groupInfoDictionary.TryGetValue(groupName, out (List, int) value)) { value = ([], immunity); - // If it doesn't exist, add a new entry with an empty list of flags and immunity groupInfoDictionary[groupName] = value; } @@ -238,7 +120,7 @@ public class PermissionManager(IDatabaseProvider? databaseProvider) /// Creates a JSON file containing groups data asynchronously. /// [UnconditionalSuppressMessage("Trimming", "IL2026:Members annotated with 'RequiresUnreferencedCodeAttribute' require dynamic access otherwise can break functionality when trimming application code", Justification = "")] - public async Task CrateGroupsJsonFile() + public async Task CreateGroupsJsonFile() { var groupsData = await GetAllGroupsData(); var jsonData = new Dictionary(); @@ -257,7 +139,7 @@ public class PermissionManager(IDatabaseProvider? databaseProvider) var options = new JsonSerializerOptions { WriteIndented = true, - PropertyNamingPolicy = JsonNamingPolicy.CamelCase + PropertyNamingPolicy = JsonNamingPolicy.CamelCase }; var json = JsonSerializer.Serialize(jsonData, options); @@ -265,66 +147,6 @@ public class PermissionManager(IDatabaseProvider? databaseProvider) await File.WriteAllTextAsync(filePath, json); } - /* - public async Task GiveAllGroupsFlags() - { - Dictionary, List>, int>> groupFlags = await GetAllGroupsFlags(); - - foreach (var kvp in groupFlags) - { - var flags = kvp.Value.Item1; - var players = kvp.Value.Item2; - int immunity = kvp.Value.Item3; - - foreach (var playerTuple in players) - { - var steamIdStr = playerTuple.Item1; - var ends = playerTuple.Item2; - - if (!string.IsNullOrEmpty(steamIdStr) && SteamID.TryParse(steamIdStr, out var steamId) && steamId != null) - { - if (!_adminCache.ContainsKey(steamId)) - { - _adminCache.TryAdd(steamId, ends); - } - - Helper.GivePlayerFlags(steamId, flags, (uint)immunity); - // Often need to call 2 times - Helper.GivePlayerFlags(steamId, flags, (uint)immunity); - } - } - } - } - */ - /* - public async Task GiveAllFlags() - { - List<(string, string, List, int, DateTime?)> allPlayers = await GetAllPlayersFlags(); - - foreach (var record in allPlayers) - { - string steamIdStr = record.Item1; - List flags = record.Item2; - int immunity = record.Item3; - - DateTime? ends = record.Item4; - - if (!string.IsNullOrEmpty(steamIdStr) && SteamID.TryParse(steamIdStr, out var steamId) && steamId != null) - { - if (!_adminCache.ContainsKey(steamId)) - { - _adminCache.TryAdd(steamId, ends); - //_adminCacheTimestamps.Add(steamId, ends); - } - - Helper.GivePlayerFlags(steamId, flags, (uint)immunity); - // Often need to call 2 times - Helper.GivePlayerFlags(steamId, flags, (uint)immunity); - } - } - } - */ - /// /// Creates a JSON file containing admins data asynchronously. /// @@ -336,25 +158,12 @@ public class PermissionManager(IDatabaseProvider? databaseProvider) .Where(player => SteamID.TryParse(player.identity.ToString(), out _)) .ToList(); - // foreach (var player in allPlayers) - // { - // var (steamId, name, flags, immunity, ends) = player; - // - // Console.WriteLine($"Player SteamID: {steamId}"); - // Console.WriteLine($"Player Name: {name}"); - // Console.WriteLine($"Flags: {string.Join(", ", flags)}"); - // Console.WriteLine($"Immunity: {immunity}"); - // Console.WriteLine($"Ends: {(ends.HasValue ? ends.Value.ToString("yyyy-MM-dd HH:mm:ss") : "Never")}"); - // Console.WriteLine(); - // } - var jsonData = validPlayers - .GroupBy(player => player.name) // Group by player name + .GroupBy(player => player.name) .ToDictionary( - group => group.Key, // Use the player name as key + group => group.Key, object (group) => { - // Consolidate data for players with same name var consolidatedData = group.Aggregate( new { @@ -365,16 +174,13 @@ public class PermissionManager(IDatabaseProvider? databaseProvider) }, (acc, player) => { - // Merge identities if (string.IsNullOrEmpty(acc.identity) && !string.IsNullOrEmpty(player.identity.ToString())) { acc = acc with { identity = player.identity.ToString() }; } - // Combine immunities by maximum value acc = acc with { immunity = Math.Max(acc.immunity, player.immunity) }; - // Combine flags and groups acc = acc with { flags = acc.flags.Concat(player.flags.Where(flag => flag.StartsWith($"@"))).Distinct().ToList(), @@ -383,12 +189,12 @@ public class PermissionManager(IDatabaseProvider? databaseProvider) return acc; }); - + Server.NextWorldUpdate(() => { var keysToRemove = new List(); - foreach (var steamId in AdminCache.Keys.ToList()) + foreach (var steamId in AdminCache.Keys.ToList()) { var data = AdminManager.GetPlayerAdminData(steamId); if (data != null) @@ -422,40 +228,9 @@ public class PermissionManager(IDatabaseProvider? databaseProvider) } }); - // Server.NextFrameAsync(() => - // { - // for (var index = 0; index < AdminCache.Keys.ToList().Count; index++) - // { - // var steamId = AdminCache.Keys.ToList()[index]; - // - // var data = AdminManager.GetPlayerAdminData(steamId); - // if (data != null) - // { - // AdminManager.RemovePlayerPermissions(steamId, AdminCache[steamId].Flags.ToArray()); - // AdminManager.RemovePlayerFromGroup(steamId, true, AdminCache[steamId].Flags.ToArray()); - // } - // - // if (!AdminCache.TryRemove(steamId, out _)) continue; - // - // if (data == null) continue; - // if (data.Flags.ToList().Count != 0 && data.Groups.ToList().Count != 0) - // continue; - // - // AdminManager.ClearPlayerPermissions(steamId); - // AdminManager.RemovePlayerAdminData(steamId); - // } - // - // foreach (var player in group) - // { - // SteamID.TryParse(player.identity, out var steamId); - // if (steamId == null) continue; - // AdminCache.TryAdd(steamId, (player.ends, player.flags)); - // } - // }); - return consolidatedData; }); - + var options = new JsonSerializerOptions { WriteIndented = true, @@ -464,10 +239,7 @@ public class PermissionManager(IDatabaseProvider? databaseProvider) var json = JsonSerializer.Serialize(jsonData, options); var filePath = Path.Combine(CS2_SimpleAdmin.Instance.ModuleDirectory, "data", "admins.json"); - await File.WriteAllTextAsync(filePath, json); - - //await File.WriteAllTextAsync(CS2_SimpleAdmin.Instance.ModuleDirectory + "/data/admins.json", json); } /// @@ -480,8 +252,6 @@ public class PermissionManager(IDatabaseProvider? databaseProvider) if (databaseProvider == null) return; if (string.IsNullOrEmpty(playerSteamId)) return; - //_adminCache.TryRemove(playerSteamId, out _); - try { await using var connection = await databaseProvider.CreateConnectionAsync(); @@ -510,18 +280,12 @@ public class PermissionManager(IDatabaseProvider? databaseProvider) if (string.IsNullOrEmpty(playerSteamId) || flagsList.Count == 0) return; var now = Time.ActualDateTime(); - DateTime? futureTime; - - if (time != 0) - futureTime = now.AddMinutes(time); - else - futureTime = null; + DateTime? futureTime = time != 0 ? now.AddMinutes(time) : null; try { await using var connection = await databaseProvider.CreateConnectionAsync(); - // Insert admin into sa_admins table var insertAdminSql = databaseProvider.GetAddAdminQuery(); var adminId = await connection.ExecuteScalarAsync(insertAdminSql, new { @@ -533,28 +297,8 @@ public class PermissionManager(IDatabaseProvider? databaseProvider) serverid = globalAdmin ? null : CS2_SimpleAdmin.ServerId }); - // Insert flags into sa_admins_flags table foreach (var flag in flagsList) { - // if (flag.StartsWith($"#")) - // { - // // const string sql = "SELECT id FROM `sa_groups` WHERE name = @groupName"; - // // var groupId = await connection.QuerySingleOrDefaultAsync(sql, new { groupName = flag }); - // - // var sql = databaseProvider.GetGroupIdByNameQuery(); - // var groupId = await connection.QuerySingleOrDefaultAsync(sql, new { groupName = flag, CS2_SimpleAdmin.ServerId }); - // - // if (groupId != null) - // { - // var updateAdminGroup = "UPDATE `sa_admins` SET group_id = @groupId WHERE id = @adminId"; - // await connection.ExecuteAsync(updateAdminGroup, new - // { - // groupId, - // adminId - // }); - // } - // } - var insertFlagsSql = databaseProvider.GetAddAdminFlagsQuery(); await connection.ExecuteAsync(insertFlagsSql, new { @@ -587,10 +331,10 @@ public class PermissionManager(IDatabaseProvider? databaseProvider) if (string.IsNullOrEmpty(groupName) || flagsList.Count == 0) return; - await using var connection = await databaseProvider.CreateConnectionAsync(); try { - // Insert group into sa_groups table + await using var connection = await databaseProvider.CreateConnectionAsync(); + var insertGroup = databaseProvider.GetAddGroupQuery(); var groupId = await connection.ExecuteScalarAsync(insertGroup, new { @@ -598,11 +342,9 @@ public class PermissionManager(IDatabaseProvider? databaseProvider) immunity }); - // Insert flags into sa_groups_flags table foreach (var flag in flagsList) { var insertFlagsSql = databaseProvider.GetAddGroupFlagsQuery(); - await connection.ExecuteAsync(insertFlagsSql, new { groupId, @@ -616,7 +358,6 @@ public class PermissionManager(IDatabaseProvider? databaseProvider) { CS2_SimpleAdmin.Instance.ReloadAdmins(null); }); - } catch (Exception ex) { @@ -634,9 +375,9 @@ public class PermissionManager(IDatabaseProvider? databaseProvider) if (string.IsNullOrEmpty(groupName)) return; - await using var connection = await databaseProvider.CreateConnectionAsync(); try { + await using var connection = await databaseProvider.CreateConnectionAsync(); var sql = databaseProvider.GetDeleteGroupQuery(); await connection.ExecuteAsync(sql, new { groupName }); } @@ -665,4 +406,4 @@ public class PermissionManager(IDatabaseProvider? databaseProvider) CS2_SimpleAdmin._logger?.LogCritical("Unable to remove expired admins"); } } -} \ No newline at end of file +} diff --git a/CS2-SimpleAdmin/Managers/PlayerManager.cs b/CS2-SimpleAdmin/Managers/PlayerManager.cs index e8c5c6a..f2aba1d 100644 --- a/CS2-SimpleAdmin/Managers/PlayerManager.cs +++ b/CS2-SimpleAdmin/Managers/PlayerManager.cs @@ -13,7 +13,7 @@ namespace CS2_SimpleAdmin.Managers; internal class PlayerManager { - private readonly SemaphoreSlim _loadPlayerSemaphore = new(10); + private readonly SemaphoreSlim _loadPlayerSemaphore = new(6); private readonly CS2_SimpleAdminConfig _config = CS2_SimpleAdmin.Instance.Config; /// @@ -51,69 +51,41 @@ internal class PlayerManager try { await _loadPlayerSemaphore.WaitAsync(); - if (!CS2_SimpleAdmin.PlayersInfo.ContainsKey(steamId)) + + // Save ip address before ban check + await SavePlayerIpAddress(steamId, playerName, ipAddress); + + // Always check bans first, regardless of PlayersInfo state + var isBanned = CS2_SimpleAdmin.Instance.Config.OtherSettings.BanType switch { - var isBanned = CS2_SimpleAdmin.Instance.Config.OtherSettings.BanType switch + 0 => CS2_SimpleAdmin.Instance.CacheManager.IsPlayerBanned(playerName, steamId, null), + _ => CS2_SimpleAdmin.Instance.Config.OtherSettings.CheckMultiAccountsByIp + ? CS2_SimpleAdmin.Instance.CacheManager.IsPlayerOrAnyIpBanned(playerName, steamId, + ipAddress) + : CS2_SimpleAdmin.Instance.CacheManager.IsPlayerBanned(playerName, steamId, ipAddress) + }; + + CS2_SimpleAdmin._logger?.LogInformation($"[BAN CHECK] Player {playerName} ({steamId}) IP: {ipAddress} - BanType: {CS2_SimpleAdmin.Instance.Config.OtherSettings.BanType} - CheckMultiAccounts: {CS2_SimpleAdmin.Instance.Config.OtherSettings.CheckMultiAccountsByIp} - isBanned: {isBanned}"); + + if (isBanned) + { + CS2_SimpleAdmin._logger?.LogInformation($"[BAN CHECK] KICKING {playerName} ({steamId})"); + await Server.NextWorldUpdateAsync(() => { - 0 => CS2_SimpleAdmin.Instance.CacheManager.IsPlayerBanned(playerName, steamId, null), - _ => CS2_SimpleAdmin.Instance.Config.OtherSettings.CheckMultiAccountsByIp - ? CS2_SimpleAdmin.Instance.CacheManager.IsPlayerOrAnyIpBanned(playerName, steamId, - ipAddress) - : CS2_SimpleAdmin.Instance.CacheManager.IsPlayerBanned(playerName, steamId, ipAddress) - }; + CS2_SimpleAdmin._logger?.LogInformation($"[BAN CHECK] Executing kick for {playerName}"); + Helper.KickPlayer(userId, NetworkDisconnectionReason.NETWORK_DISCONNECT_REJECT_BANNED); + }); - // CS2_SimpleAdmin._logger?.LogInformation($"Player {playerName} ({steamId} - {ipAddress}) is banned? {isBanned.ToString()}"); - - if (isBanned) - { - await Server.NextWorldUpdateAsync(() => - { - // CS2_SimpleAdmin._logger?.LogInformation($"Kicking {playerName}"); - Helper.KickPlayer(userId, NetworkDisconnectionReason.NETWORK_DISCONNECT_REJECT_BANNED); - }); - - return; - } + return; } - if (fullConnect) + if (!CS2_SimpleAdmin.PlayersInfo.ContainsKey(steamId)) { var playerInfo = new PlayerInfo(userId, slot, new SteamID(steamId), playerName, ipAddress); CS2_SimpleAdmin.PlayersInfo[steamId] = playerInfo; - - if (_config.OtherSettings.CheckMultiAccountsByIp && ipAddress != null && - CS2_SimpleAdmin.PlayersInfo[steamId] != null) + + if (_config.OtherSettings.CheckMultiAccountsByIp && ipAddress != null) { - try - { - await using var connection = await CS2_SimpleAdmin.DatabaseProvider.CreateConnectionAsync(); - - // Eliminates the need for SELECT COUNT and duplicate UPDATE queries - var steamId64 = CS2_SimpleAdmin.PlayersInfo[steamId].SteamId.SteamId64; - var ipUint = IpHelper.IpToUint(ipAddress); - - // Use database-specific UPSERT query (handles MySQL vs SQLite syntax differences) - var upsertQuery = CS2_SimpleAdmin.DatabaseProvider.GetUpsertPlayerIpQuery(); - - await connection.ExecuteAsync(upsertQuery, new - { - SteamID = steamId64, - playerName, - IPAddress = ipUint - }); - - // // Cache will be updated on next refresh cycle - // if (!CS2_SimpleAdmin.Instance.CacheManager.HasIpForPlayer(steamId, ipAddress)) - // { - // // IP association will be reflected after cache refresh - // } - } - catch (Exception ex) - { - CS2_SimpleAdmin._logger?.LogError( - $"Unable to save ip address for {playerInfo.Name} ({ipAddress}): {ex.Message}"); - } - playerInfo.AccountsAssociated = CS2_SimpleAdmin.Instance.CacheManager?.GetAccountsByIp(ipAddress).AsValueEnumerable() .Select(x => (x.SteamId, x.PlayerName)).ToList() ?? []; @@ -153,20 +125,22 @@ internal class PlayerManager string muteType = mute.type; DateTime ends = mute.ends; int duration = mute.duration; + string? reason = mute.reason; + int passed = mute.passed is null ? 0 : (int)mute.passed; switch (muteType) { // Apply mute penalty based on mute type case "GAG": PlayerPenaltyManager.AddPenalty( CS2_SimpleAdmin.PlayersInfo[steamId].Slot, - PenaltyType.Gag, ends, duration); + PenaltyType.Gag, ends, duration, reason, duration - passed); // if (CS2_SimpleAdmin._localizer != null) // mutesList[PenaltyType.Gag].Add(CS2_SimpleAdmin._localizer["sa_player_penalty_info_active_gag", ends.ToLocalTime().ToString(CultureInfo.CurrentCulture)]); break; case "MUTE": PlayerPenaltyManager.AddPenalty( CS2_SimpleAdmin.PlayersInfo[steamId].Slot, - PenaltyType.Mute, ends, duration); + PenaltyType.Mute, ends, duration, reason, duration - passed); await Server.NextWorldUpdateAsync(() => { player.VoiceFlags = VoiceFlags.Muted; @@ -177,7 +151,7 @@ internal class PlayerManager default: PlayerPenaltyManager.AddPenalty( CS2_SimpleAdmin.PlayersInfo[steamId].Slot, - PenaltyType.Silence, ends, duration); + PenaltyType.Silence, ends, duration, reason, duration - passed); await Server.NextWorldUpdateAsync(() => { player.VoiceFlags = VoiceFlags.Muted; @@ -200,7 +174,7 @@ internal class PlayerManager AdminManager.PlayerHasPermissions( new SteamID(p.SteamID), "@css/ban")) && - p.Connected == PlayerConnectedState.PlayerConnected && + p.Connected == PlayerConnectedState.Connected && !CS2_SimpleAdmin.AdminDisabledJoinComms .Contains(p.SteamID))) { @@ -247,13 +221,45 @@ internal class PlayerManager _loadPlayerSemaphore.Release(); } }); - + if (CS2_SimpleAdmin.RenamedPlayers.TryGetValue(player.SteamID, out var name)) { player.Rename(name); } } + /// + /// Saves player's IP address to the database for multi-account detection. + /// This is called before ban checks to ensure IP is recorded even if player is banned. + /// + private async Task SavePlayerIpAddress(ulong steamId, string playerName, string? ipAddress) + { + if (!_config.OtherSettings.CheckMultiAccountsByIp || ipAddress == null || CS2_SimpleAdmin.DatabaseProvider == null) + return; + + try + { + await using var connection = await CS2_SimpleAdmin.DatabaseProvider.CreateConnectionAsync(); + + var steamId64 = steamId; + var ipUint = IpHelper.IpToUint(ipAddress); + + var upsertQuery = CS2_SimpleAdmin.DatabaseProvider.GetUpsertPlayerIpQuery(); + + await connection.ExecuteAsync(upsertQuery, new + { + SteamID = steamId64, + playerName, + IPAddress = ipUint + }); + } + catch (Exception ex) + { + CS2_SimpleAdmin._logger?.LogError( + $"Unable to save ip address for {playerName} ({ipAddress}): {ex.Message}"); + } + } + /// /// Periodically checks the status of online players and applies timers for speed, gravity, /// and penalty expiration validation. @@ -271,6 +277,16 @@ internal class PlayerManager /// public void CheckPlayersTimer() { + CS2_SimpleAdmin.Instance.AddTimer(5f, () => + { + foreach (var (steamid, name) in CS2_SimpleAdmin.RenamedPlayers) + { + var player = Helper.GetPlayerFromSteamid64(steamid); + if (player == null || !player.IsValid || player.PlayerName == name) continue; + player.Rename(name); + } + }, TimerFlags.REPEAT); + CS2_SimpleAdmin.Instance.PlayersTimer = CS2_SimpleAdmin.Instance.AddTimer(61.0f, () => { #if DEBUG diff --git a/CS2-SimpleAdmin/Managers/PlayerPenaltyManager.cs b/CS2-SimpleAdmin/Managers/PlayerPenaltyManager.cs index 2aab682..7931fe3 100644 --- a/CS2-SimpleAdmin/Managers/PlayerPenaltyManager.cs +++ b/CS2-SimpleAdmin/Managers/PlayerPenaltyManager.cs @@ -8,6 +8,46 @@ public static class PlayerPenaltyManager private static readonly ConcurrentDictionary>> Penalties = new(); + // zio: the reason and online-time bookkeeping for each penalty, kept beside Penalties so the + // tuple type the API exposes doesn't change. + private static readonly ConcurrentDictionary<(int Slot, PenaltyType Type, DateTime EndDateTime), (string? Reason, DateTime AddedAt, int MinutesLeft)> Details = + new(); + + /// + /// Adds a penalty and remembers its reason, for telling the player why they're penalized. + /// + /// Minutes left when added: the duration, less any already served + /// online when TimeMode is 0. + public static void AddPenalty(int slot, PenaltyType penaltyType, DateTime endDateTime, int durationInMinutes, string? reason, int? minutesLeft = null) + { + AddPenalty(slot, penaltyType, endDateTime, durationInMinutes); + Details[(slot, penaltyType, endDateTime)] = (reason, Time.ActualDateTime(), minutesLeft ?? durationInMinutes); + } + + /// + /// Returns how long the penalty IsPenalized found has left (null if permanent) and its reason. + /// + public static (TimeSpan? Remaining, string? Reason) GetPenaltyDetails(int slot, PenaltyType penaltyType, DateTime endDateTime) + { + var penalty = GetPlayerPenalties(slot, penaltyType).FirstOrDefault(p => p.EndDateTime == endDateTime); + var hasDetails = Details.TryGetValue((slot, penaltyType, endDateTime), out var details); + if (penalty.Duration == 0) + return (null, details.Reason); + + var now = Time.ActualDateTime(); + // TimeMode 0 only counts time online, and the player has been online since it was added. + var remaining = CS2_SimpleAdmin.Instance.Config.OtherSettings.TimeMode == 0 && hasDetails + ? TimeSpan.FromMinutes(details.MinutesLeft) - (now - details.AddedAt) + : endDateTime - now; + return (remaining < TimeSpan.Zero ? TimeSpan.Zero : remaining, details.Reason); + } + + private static void RemoveDetails(Func<(int Slot, PenaltyType Type, DateTime EndDateTime), bool> match) + { + foreach (var key in Details.Keys.Where(match).ToList()) + Details.TryRemove(key, out _); + } + /// /// Adds a penalty for a specific player slot and penalty type. /// @@ -162,6 +202,7 @@ public static class PlayerPenaltyManager { Penalties.TryRemove(slot, out _); } + RemoveDetails(k => k.Slot == slot); } /// @@ -170,6 +211,7 @@ public static class PlayerPenaltyManager public static void RemoveAllPenalties() { Penalties.Clear(); + Details.Clear(); } /// @@ -184,6 +226,7 @@ public static class PlayerPenaltyManager { penaltyDict.Remove(penaltyType); } + RemoveDetails(k => k.Slot == slot && k.Type == penaltyType); } /// diff --git a/CS2-SimpleAdmin/Managers/WarnManager.cs b/CS2-SimpleAdmin/Managers/WarnManager.cs index 5b87831..7db3d1d 100644 --- a/CS2-SimpleAdmin/Managers/WarnManager.cs +++ b/CS2-SimpleAdmin/Managers/WarnManager.cs @@ -42,7 +42,7 @@ internal class WarnManager(IDatabaseProvider? databaseProvider) return warnId; } - catch(Exception e) + catch(Exception) { return null; } diff --git a/CS2-SimpleAdmin/Models/IpHistoryRow.cs b/CS2-SimpleAdmin/Models/IpHistoryRow.cs new file mode 100644 index 0000000..5c794a7 --- /dev/null +++ b/CS2-SimpleAdmin/Models/IpHistoryRow.cs @@ -0,0 +1,9 @@ +namespace CS2_SimpleAdmin.Models; + +public record IpHistoryRow +{ + public long Steamid { get; init; } + public string? Name { get; init; } + public uint Address { get; init; } + public DateTime Used_at { get; init; } +} diff --git a/CS2-SimpleAdmin/VERSION b/CS2-SimpleAdmin/VERSION index a4932a5..c7a73ab 100644 --- a/CS2-SimpleAdmin/VERSION +++ b/CS2-SimpleAdmin/VERSION @@ -1 +1 @@ -1.7.8-beta-10b \ No newline at end of file +1.8.2b \ No newline at end of file diff --git a/CS2-SimpleAdmin/lang/en.json b/CS2-SimpleAdmin/lang/en.json index f875b82..99ee96d 100644 --- a/CS2-SimpleAdmin/lang/en.json +++ b/CS2-SimpleAdmin/lang/en.json @@ -65,6 +65,11 @@ "sa_discord_penalty_unknown": "Unknown registered", "sa_player_penalty_chat_active": "{lightred}Your chat is blocked to: {grey}{0}", + "sa_player_penalty_gag_active": "{lightred}You are gagged for {grey}{0}{lightred}. Reason: {grey}{1}\n{grey}Team chat starting with {lightred}@{grey} still reaches online staff.", + "sa_player_penalty_gag_active_perm": "{lightred}You are gagged permanently. Reason: {grey}{0}\n{grey}Team chat starting with {lightred}@{grey} still reaches online staff.", + "sa_player_penalty_silence_active": "{lightred}You are silenced for {grey}{0}{lightred}. Reason: {grey}{1}\n{grey}Team chat starting with {lightred}@{grey} still reaches online staff.", + "sa_player_penalty_silence_active_perm": "{lightred}You are silenced permanently. Reason: {grey}{0}\n{grey}Team chat starting with {lightred}@{grey} still reaches online staff.", + "sa_player_penalty_no_reason": "none given", "sa_player_penalty_info_active_mute": "➔ Mute [{lightred}❌{default}] - Expire [{lightred}{0}{default}]", "sa_player_penalty_info_active_gag": "➔ Gag [{lightred}❌{default}] - Expire [{lightred}{0}{default}]", diff --git a/CS2-SimpleAdminApi/CS2-SimpleAdminApi.csproj b/CS2-SimpleAdminApi/CS2-SimpleAdminApi.csproj index b3f699e..bbd5d85 100644 --- a/CS2-SimpleAdminApi/CS2-SimpleAdminApi.csproj +++ b/CS2-SimpleAdminApi/CS2-SimpleAdminApi.csproj @@ -1,7 +1,7 @@  - net8.0 + net10.0 CS2_SimpleAdminApi enable enable @@ -9,7 +9,7 @@ - + diff --git a/Modules/CS2-SimpleAdmin_BanSoundModule/CS2-SimpleAdmin_BanSoundModule.cs b/Modules/CS2-SimpleAdmin_BanSoundModule/CS2-SimpleAdmin_BanSoundModule.cs index 9379f33..03b2495 100644 --- a/Modules/CS2-SimpleAdmin_BanSoundModule/CS2-SimpleAdmin_BanSoundModule.cs +++ b/Modules/CS2-SimpleAdmin_BanSoundModule/CS2-SimpleAdmin_BanSoundModule.cs @@ -45,7 +45,7 @@ public class CS2_SimpleAdmin_BanSoundModule: BasePlugin foreach (var player in Utilities.GetPlayers().Where(p => p.IsValid && !p.IsBot)) { var filter = new RecipientFilter(player); - player?.EmitSound("bansound", volume: 0.9f, recipients: filter); + player?.EmitSound("bansound", volume: 0.75f, recipients: filter); } } } \ No newline at end of file diff --git a/Modules/CS2-SimpleAdmin_StealthModule/METAMOD PLUGIN/StatusBlocker-v1.1.4-linux.tar.gz b/Modules/CS2-SimpleAdmin_StealthModule/METAMOD PLUGIN/StatusBlocker-v1.1.4-linux.tar.gz deleted file mode 100644 index 8535338..0000000 Binary files a/Modules/CS2-SimpleAdmin_StealthModule/METAMOD PLUGIN/StatusBlocker-v1.1.4-linux.tar.gz and /dev/null differ diff --git a/Modules/CS2-SimpleAdmin_StealthModule/METAMOD PLUGIN/StatusBlocker-v1.1.4-windows.tar.gz b/Modules/CS2-SimpleAdmin_StealthModule/METAMOD PLUGIN/StatusBlocker-v1.1.4-windows.tar.gz deleted file mode 100644 index 4990d69..0000000 Binary files a/Modules/CS2-SimpleAdmin_StealthModule/METAMOD PLUGIN/StatusBlocker-v1.1.4-windows.tar.gz and /dev/null differ diff --git a/README.md b/README.md index 2a73b8e..2ad3635 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,21 @@ +# zio.sh fork + +This is zio.sh's fork of CS2-SimpleAdmin 1.8.2b (`upstream` is daffyyyy/CS2-SimpleAdmin, branched +from the `build-1.8.2b` tag). Changes, all marked `zio:` in the code: + +- A gagged or silenced player can still send `@message` in team chat to reach online staff. +- The gag/silence chat message says how long is left (or that it's permanent) and the reason, in + place of "Your chat is blocked to: ". New lang keys `sa_player_penalty_gag_active`, + `..._gag_active_perm`, `..._silence_active`, `..._silence_active_perm` and + `sa_player_penalty_no_reason`, in `lang/en.json` only (other languages fall back to it). + +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`), holding only `CS2-SimpleAdmin.dll` and `lang/en.json`. The rest of the plugin +and `shared/CS2-SimpleAdminApi` stay as the upstream 1.8.2b release installed them. + +--- +

Build and Publish diff --git a/release.sh b/release.sh new file mode 100755 index 0000000..c2b1e46 --- /dev/null +++ b/release.sh @@ -0,0 +1,117 @@ +#!/usr/bin/env bash +# Build our CS2-SimpleAdmin fork, package it as one tarball and publish it as a Forgejo release. +# +# ./release.sh e.g. ./release.sh v1.8.2b-zio1 +# +# Commit first: the plugin is rebuilt here from the committed source, so the release always matches +# the tag. The tag is created on HEAD and pushed to origin if it doesn't exist yet. Re-running with +# the same tag replaces that release's attachment. +# +# Asset: SimpleAdmin-.tar.gz, laid out like the server's game/csgo/ - extract it there: +# tar -xzf SimpleAdmin-.tar.gz -C /srv/cs2/game/csgo +# +# addons/counterstrikesharp/plugins/CS2-SimpleAdmin/ CS2-SimpleAdmin.dll, lang/en.json +# +# Only the files the fork changes. Everything else in the plugin folder (its libraries, other lang +# files, migrations) and shared/CS2-SimpleAdminApi stay as the upstream 1.8.2b release installed +# them; the fork doesn't touch the API. No configs/: those stay the server's. +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. +FORGEJO_TOKEN="${FORGEJO_TOKEN:-CHANGE_ME_FORGEJO_TOKEN}" + +# Only these ship from the publish output. CounterStrikeSharp.API.dll is excluded by the csproj +# (ExcludeAssets=runtime); the server already provides it. +PLUGIN_FILES=( + CS2-SimpleAdmin.dll + lang/en.json +) + +TAG="${1:-}" +if [[ -z "$TAG" ]]; then + echo "usage: $0 (e.g. v1.8.2b-zio1)" >&2 + exit 1 +fi +if [[ "$FORGEJO_TOKEN" == "CHANGE_ME_FORGEJO_TOKEN" ]]; then + echo "Set FORGEJO_TOKEN (edit release.sh or export it) first." >&2 + exit 1 +fi +for tool in podman jq curl git; do + command -v "$tool" >/dev/null || { echo "'$tool' is required." >&2; exit 1; } +done + +cd "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")" + +if [[ -n "$(git status --porcelain --untracked-files=no)" ]]; then + echo "Tracked files have uncommitted changes; commit them so the tag matches the release." >&2 + exit 1 +fi + +# The tag should name the version the plugin reports in `css_plugins list`. +version="$(sed -n 's/.*ModuleVersion => "\(.*\)";.*/\1/p' CS2-SimpleAdmin/CS2-SimpleAdmin.cs)" +if [[ "${TAG#v}" != "$version" ]]; then + echo "Tag $TAG doesn't match ModuleVersion $version in CS2-SimpleAdmin/CS2-SimpleAdmin.cs." >&2 + exit 1 +fi + +# --- Build ----------------------------------------------------------------------- +OUT="release-out" +PUBLISH="$OUT/publish" +rm -rf CS2-SimpleAdmin/bin CS2-SimpleAdmin/obj CS2-SimpleAdminApi/bin CS2-SimpleAdminApi/obj "$OUT" +# dotnet build, as upstream's CI does: the csproj sets PublishTrimmed, which a plugin mustn't be. +podman run --rm -v "$(pwd)":/src:Z -w /src \ + mcr.microsoft.com/dotnet/sdk:10.0 dotnet build CS2-SimpleAdmin/CS2-SimpleAdmin.csproj -c Release -o "/src/$PUBLISH" + +# --- Stage (layout of game/csgo/) and package ---------------------------------------- +STAGE="$OUT/stage" +PLUGIN_DIR="$STAGE/addons/counterstrikesharp/plugins/CS2-SimpleAdmin" +mkdir -p "$PLUGIN_DIR" +for file in "${PLUGIN_FILES[@]}"; do + mkdir -p "$PLUGIN_DIR/$(dirname "$file")" + cp "$PUBLISH/$file" "$PLUGIN_DIR/$file" +done + +TARBALL="SimpleAdmin-${TAG}.tar.gz" +tar -czf "$OUT/$TARBALL" -C "$STAGE" addons +rm -rf "$STAGE" "$PUBLISH" + +echo "Packaged:" +tar -tzf "$OUT/$TARBALL" + +# --- Tag --------------------------------------------------------------------------- +if ! git rev-parse -q --verify "refs/tags/$TAG" >/dev/null; then + git tag -a "$TAG" -m "$TAG" +fi +git push origin "refs/tags/$TAG" + +# --- Release ----------------------------------------------------------------------- +API="$FORGEJO_URL/api/v1/repos/$OWNER/$REPO" +AUTH=(-H "Authorization: token $FORGEJO_TOKEN") + +release_json="$(curl -fsS "${AUTH[@]}" "$API/releases/tags/$TAG" 2>/dev/null || true)" +if [[ -z "$release_json" ]]; then + body="$(git log -1 --format=%B "$TAG")" + release_json="$(jq -n --arg tag "$TAG" --arg body "$body" \ + '{tag_name: $tag, name: $tag, body: $body, draft: false, prerelease: false}' | + curl -fsS "${AUTH[@]}" -H "Content-Type: application/json" -X POST --data @- "$API/releases")" + echo "Created release $TAG" +else + echo "Release $TAG already exists, replacing its attachment" +fi +release_id="$(jq -r '.id' <<<"$release_json")" + +existing_id="$(jq -r --arg n "$TARBALL" '.assets[]? | select(.name == $n) | .id' <<<"$release_json")" +if [[ -n "$existing_id" ]]; then + curl -fsS "${AUTH[@]}" -X DELETE "$API/releases/$release_id/assets/$existing_id" >/dev/null +fi +curl -fsS "${AUTH[@]}" -X POST -F "attachment=@$OUT/$TARBALL" \ + "$API/releases/$release_id/assets?name=$TARBALL" >/dev/null +echo "Uploaded $TARBALL" + +echo +echo "Download URL:" +echo " $FORGEJO_URL/$OWNER/$REPO/releases/download/$TAG/$TARBALL"