Compare commits

...

10 commits

Author SHA1 Message Date
6171162ae9 zio fork: @ staff chat while gagged, gag message with time left and reason
Some checks failed
Build and Publish / build (push) Has been cancelled
Build and Publish / publish (push) Has been cancelled
Gagged or silenced players can still send @ team chat to online staff. The chat-blocked message
now says how long the gag or silence has left, or that it's permanent, and why. Reasons are kept in
memory alongside each penalty. Adds release.sh, which ships CS2-SimpleAdmin.dll and lang/en.json.
2026-09-27 19:07:06 +01:00
Dawid Bepierszcz
cf284bd121 Bump version and adjust SteamID type handling 2026-06-28 00:49:54 +02:00
Dawid Bepierszcz
a588267165 Use IpHistory record and make player timer repeat 2026-06-17 03:19:34 +02:00
Dawid Bepierszcz
b0c7fd12c3 Bump version to 1.8.2a and add rename timer 2026-06-17 02:59:23 +02:00
Dawid Bepierszcz
ca55c7f1a6 Update build.yml 2026-06-17 02:47:31 +02:00
Dawid Bepierszcz
d870f23b01 Bump version; fix bugs and refactor managers
Minor fixes
2026-06-17 02:46:25 +02:00
Dawid Bepierszcz
a7ad6fce77 .NET10, version bump 2026-06-17 02:21:51 +02:00
Dawid Bepierszcz
eea700bfb4 Version bump and multi-account/IP ban fixes
Bump version to 1.7.9a and update package references (CounterStrikeSharp.API, Dapper, System.Linq.Async, ZLinq). Replace usages of PlayerConnectedState.PlayerConnected with PlayerConnectedState.Connected across commands, events, and helpers. Add DB method GetExpireOldPlayerIpsQuery (MySQL/SQLite) and call it to purge old player IP records during ban expiration. Improve CacheManager multi-account/ip ban detection (direct IP bans, cross-account IP checks) and adjust ban logic to respect expiration window. In PlayerManager: reduce semaphore, save player IP to DB before performing ban checks (add SavePlayerIpAddress), and always perform ban checks on connect. Simplify config upgrade logic in Helper.UpdateConfig to update the JSON file Version via JsonNode. Misc: adjust ban sound volume, minor exception catch cleanup, and add module folder to .gitignore.
2026-04-23 22:15:44 +02:00
Dawid Bepierszcz
91615ffc67
Update VERSION 2026-01-27 13:50:27 +01:00
Dawid Bepierszcz
eb9b438315
Update CS2-SimpleAdmin.cs 2026-01-27 13:47:41 +01:00
28 changed files with 490 additions and 461 deletions

View file

@ -35,7 +35,7 @@ jobs:
- name: Setup .NET - name: Setup .NET
uses: actions/setup-dotnet@v4 uses: actions/setup-dotnet@v4
with: with:
dotnet-version: 8.0.x dotnet-version: 10.0.x
- name: Get Version - name: Get Version
id: get_version id: get_version
@ -67,28 +67,12 @@ jobs:
- name: Zip Main Build Output - name: Zip Main Build Output
run: zip -r CS2-SimpleAdmin-${{ steps.get_version.outputs.VERSION }}.zip ${{ env.OUTPUT_PATH }} 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 - name: Upload all artifacts
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v4
with: with:
name: CS2-SimpleAdmin-Build-Artifacts name: CS2-SimpleAdmin-Build-Artifacts
path: | path: |
CS2-SimpleAdmin-${{ steps.get_version.outputs.VERSION }}.zip 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: publish:
needs: build needs: build
@ -109,8 +93,6 @@ jobs:
with: with:
artifacts: | artifacts: |
CS2-SimpleAdmin-${{ needs.build.outputs.build_version }}.zip 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 }}" name: "CS2-SimpleAdmin-${{ needs.build.outputs.build_version }}"
tag: "build-${{ needs.build.outputs.build_version }}" tag: "build-${{ needs.build.outputs.build_version }}"
body: | body: |
@ -121,8 +103,5 @@ jobs:
After the first launch, configure the plugin using the JSON config file at: After the first launch, configure the plugin using the JSON config file at:
addons/counterstrikesharp/configs/plugins/CS2-SimpleAdmin/CS2-SimpleAdmin.json 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. Remember to restart or reload your game server after installing and configuring the plugins.

4
.gitignore vendored
View file

@ -10,3 +10,7 @@ Modules/CS2-SimpleAdmin_ExampleModule/CS2-SimpleAdmin_ExampleModule.sln.DotSetti
CS2-SimpleAdmin_BanSoundModule — kopia CS2-SimpleAdmin_BanSoundModule — kopia
*.user *.user
CLAUDE.md CLAUDE.md
/Modules/CS2-SimpleAdmin_BanSoundModule
/Modules/CS2-SimpleAdmin_StealthModule/METAMOD PLUGIN
release-out/

View file

@ -14,7 +14,7 @@ using MySqlConnector;
namespace CS2_SimpleAdmin; namespace CS2_SimpleAdmin;
[MinimumApiVersion(300)] [MinimumApiVersion(369)]
public partial class CS2_SimpleAdmin : BasePlugin, IPluginConfig<CS2_SimpleAdminConfig> public partial class CS2_SimpleAdmin : BasePlugin, IPluginConfig<CS2_SimpleAdminConfig>
{ {
internal static CS2_SimpleAdmin Instance { get; private set; } = new(); internal static CS2_SimpleAdmin Instance { get; private set; } = new();
@ -22,7 +22,7 @@ public partial class CS2_SimpleAdmin : BasePlugin, IPluginConfig<CS2_SimpleAdmin
public override string ModuleName => "CS2-SimpleAdmin" + (Helper.IsDebugBuild ? " (DEBUG)" : " (RELEASE)"); public override string ModuleName => "CS2-SimpleAdmin" + (Helper.IsDebugBuild ? " (DEBUG)" : " (RELEASE)");
public override string ModuleDescription => "Simple admin plugin for Counter-Strike 2 :)"; public override string ModuleDescription => "Simple admin plugin for Counter-Strike 2 :)";
public override string ModuleAuthor => "daffyy"; 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) public override void Load(bool hotReload)
{ {
@ -46,7 +46,7 @@ public partial class CS2_SimpleAdmin : BasePlugin, IPluginConfig<CS2_SimpleAdmin
CachedPlayers.Clear(); CachedPlayers.Clear();
BotPlayers.Clear(); BotPlayers.Clear();
foreach (var player in Utilities.GetPlayers().Where(p => 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) if (!player.IsBot)
PlayerManager.LoadPlayerData(player, true); PlayerManager.LoadPlayerData(player, true);
@ -55,8 +55,6 @@ public partial class CS2_SimpleAdmin : BasePlugin, IPluginConfig<CS2_SimpleAdmin
}; };
}); });
PlayersTimer?.Kill();
PlayersTimer = null;
} }
_cBasePlayerControllerSetPawnFunc = new MemoryFunctionVoid<CBasePlayerController, CCSPlayerPawn, bool, bool>(GameData.GetSignature("CBasePlayerController_SetPawn")); _cBasePlayerControllerSetPawnFunc = new MemoryFunctionVoid<CBasePlayerController, CCSPlayerPawn, bool, bool>(GameData.GetSignature("CBasePlayerController_SetPawn"));
@ -85,7 +83,7 @@ public partial class CS2_SimpleAdmin : BasePlugin, IPluginConfig<CS2_SimpleAdmin
RegisterEvents(); RegisterEvents();
AddTimer(0.5f, RegisterCommands.InitializeCommands); AddTimer(0.5f, RegisterCommands.InitializeCommands);
AddTimer(1.0f, () => ReloadAdmins(null)); AddTimer(3.0f, () => ReloadAdmins(null));
if (!CoreConfig.UnlockConCommands) if (!CoreConfig.UnlockConCommands)
{ {
@ -95,20 +93,11 @@ public partial class CS2_SimpleAdmin : BasePlugin, IPluginConfig<CS2_SimpleAdmin
$"to rejoin the server for 60 seconds. " + $"to rejoin the server for 60 seconds. " +
$"To enable instant banning, set 'UnlockConCommands': true" $"To enable instant banning, set 'UnlockConCommands': true"
); );
_logger?.LogError(
$"⚠️ Warning: 'UnlockConCommands' is disabled in core.json. " +
$"Players will not be automatically banned when kicked and will be able " +
$"to rejoin the server for 60 seconds. " +
$"To enable instant banning, set 'UnlockConCommands': true"
);
} }
} }
public void OnConfigParsed(CS2_SimpleAdminConfig config) public void OnConfigParsed(CS2_SimpleAdminConfig config)
{ {
if (System.Diagnostics.Debugger.IsAttached)
Environment.FailFast(":(!");
Helper.UpdateConfig(config); Helper.UpdateConfig(config);
_logger = Logger; _logger = Logger;
@ -164,7 +153,10 @@ public partial class CS2_SimpleAdmin : BasePlugin, IPluginConfig<CS2_SimpleAdmin
} }
if (missing) if (missing)
Server.ExecuteCommand($"css_plugins unload {ModuleName}"); {
Server.ExecuteCommand($"css_plugins unload {ModuleDirectory}");
return;
}
Instance = this; Instance = this;

View file

@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk"> <Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup> <PropertyGroup>
<TargetFramework>net8.0</TargetFramework> <TargetFramework>net10.0</TargetFramework>
<RootNamespace>CS2_SimpleAdmin</RootNamespace> <RootNamespace>CS2_SimpleAdmin</RootNamespace>
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
@ -19,16 +19,16 @@
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="CounterStrikeSharp.API" Version="1.0.361"> <PackageReference Include="CounterStrikeSharp.API" Version="1.0.376">
<PrivateAssets>none</PrivateAssets> <PrivateAssets>none</PrivateAssets>
<ExcludeAssets>runtime</ExcludeAssets> <ExcludeAssets>runtime</ExcludeAssets>
<IncludeAssets>compile; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> <IncludeAssets>compile; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference> </PackageReference>
<PackageReference Include="Dapper" Version="2.1.66" /> <PackageReference Include="Dapper" Version="2.1.79" />
<PackageReference Include="MySqlConnector" Version="2.5.0" /> <PackageReference Include="MySqlConnector" Version="2.6.0" />
<PackageReference Include="System.Data.SQLite.Core" Version="1.0.119" /> <PackageReference Include="System.Data.SQLite.Core" Version="1.0.119" />
<PackageReference Include="System.Linq.Async" Version="7.0.0" /> <PackageReference Include="System.Linq.Async" Version="7.0.1" />
<PackageReference Include="ZLinq" Version="1.5.4" /> <PackageReference Include="ZLinq" Version="1.5.6" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>

View file

@ -27,7 +27,7 @@ public partial class CS2_SimpleAdmin
var targets = GetTarget(command); var targets = GetTarget(command);
if (targets == null) return; 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) if (playersToTarget.Count > 1 && Config.OtherSettings.DisableDangerousCommands || playersToTarget.Count == 0)
{ {
@ -373,7 +373,7 @@ public partial class CS2_SimpleAdmin
var targets = GetTarget(command); var targets = GetTarget(command);
if (targets == null) return; 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) if (playersToTarget.Count > 1 && Config.OtherSettings.DisableDangerousCommands || playersToTarget.Count == 0)
{ {

View file

@ -501,7 +501,7 @@ public partial class CS2_SimpleAdmin
Task.Run(async () => Task.Run(async () =>
{ {
await PermissionManager.CrateGroupsJsonFile(); await PermissionManager.CreateGroupsJsonFile();
await PermissionManager.CreateAdminsJsonFile(); await PermissionManager.CreateAdminsJsonFile();
var adminsFile = await File.ReadAllTextAsync(Instance.ModuleDirectory + "/data/admins.json"); var adminsFile = await File.ReadAllTextAsync(Instance.ModuleDirectory + "/data/admins.json");

View file

@ -89,7 +89,7 @@ public partial class CS2_SimpleAdmin
}); });
// Add penalty to the player's penalty manager // 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) // Determine message keys and arguments based on gag time (permanent or timed)
var (messageKey, activityMessageKey, playerArgs, adminActivityArgs) = time == 0 var (messageKey, activityMessageKey, playerArgs, adminActivityArgs) = time == 0
@ -398,7 +398,7 @@ public partial class CS2_SimpleAdmin
}); });
// Add penalty to the player's penalty manager // 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) // Determine message keys and arguments based on mute time (permanent or timed)
var (messageKey, activityMessageKey, playerArgs, adminActivityArgs) = time == 0 var (messageKey, activityMessageKey, playerArgs, adminActivityArgs) = time == 0
@ -709,7 +709,7 @@ public partial class CS2_SimpleAdmin
}); });
// Add penalty to the player's penalty manager // 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; player.VoiceFlags = VoiceFlags.Muted;
// Determine message keys and arguments based on silence time (permanent or timed) // Determine message keys and arguments based on silence time (permanent or timed)

View file

@ -44,7 +44,7 @@ public partial class CS2_SimpleAdmin
/// <param name="command">Optional command info for logging.</param> /// <param name="command">Optional command info for logging.</param>
internal static void Slay(CCSPlayerController? caller, CCSPlayerController player, string? callerName = null, CommandInfo? command = null) 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; if (!caller.CanTarget(player)) return;
// Set default caller name if not provided // Set default caller name if not provided
@ -93,7 +93,7 @@ public partial class CS2_SimpleAdmin
playersToTarget.ForEach(player => playersToTarget.ForEach(player =>
{ {
if (player.Connected != PlayerConnectedState.PlayerConnected) if (player.Connected != PlayerConnectedState.Connected)
return; return;
if (caller!.CanTarget(player)) 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) 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 // Check if the player is valid and connected
if (!player.IsValid || player.Connected != PlayerConnectedState.PlayerConnected) if (!player.IsValid || player.Connected != PlayerConnectedState.Connected)
return; return;
// Ensure the caller can target the player // Ensure the caller can target the player
@ -284,7 +284,7 @@ public partial class CS2_SimpleAdmin
playersToTarget.ForEach(player => playersToTarget.ForEach(player =>
{ {
// Check if the player is connected and can be targeted // 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; return;
// Determine message key and arguments for the rename notification // Determine message key and arguments for the rename notification
@ -330,7 +330,7 @@ public partial class CS2_SimpleAdmin
playersToTarget.ForEach(player => playersToTarget.ForEach(player =>
{ {
// Check if the player is connected and can be targeted // 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; return;
// Determine message key and arguments for the rename notification // Determine message key and arguments for the rename notification
@ -379,7 +379,7 @@ public partial class CS2_SimpleAdmin
return; return;
destinationPlayer = targets.Players.FirstOrDefault(p => 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) if (destinationPlayer == null || !caller.CanTarget(destinationPlayer) || caller.PlayerPawn.Value == null)
return; return;
@ -399,7 +399,7 @@ public partial class CS2_SimpleAdmin
return; return;
playersToTeleport = targets.Players 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(); .ToList();
if (!playersToTeleport.Any()) if (!playersToTeleport.Any())
@ -476,7 +476,7 @@ public partial class CS2_SimpleAdmin
destinationPlayer = caller; destinationPlayer = caller;
playersToTeleport = targets.Players 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(); .ToList();
} }
else else
@ -486,7 +486,7 @@ public partial class CS2_SimpleAdmin
return; return;
destinationPlayer = destination.Players.FirstOrDefault(p => 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) if (destinationPlayer == null)
return; return;
@ -497,7 +497,7 @@ public partial class CS2_SimpleAdmin
return; return;
playersToTeleport = targets.Players 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(); .ToList();
} }

View file

@ -40,6 +40,7 @@ public interface IDatabaseProvider
string GetUpdateBanStatusQuery(); string GetUpdateBanStatusQuery();
string GetExpireBansQuery(bool multiServer); string GetExpireBansQuery(bool multiServer);
string GetExpireIpBansQuery(bool multiServer); string GetExpireIpBansQuery(bool multiServer);
string GetExpireOldPlayerIpsQuery();
// MuteManager // MuteManager
string GetAddMuteQuery(bool includePlayerName); string GetAddMuteQuery(bool includePlayerName);

View file

@ -252,6 +252,11 @@ public class MySqlDatabaseProvider(string connectionString) : IDatabaseProvider
: "UPDATE sa_bans SET player_ip = NULL WHERE status = 'ACTIVE' AND ends <= @ipBansTime AND server_id = @serverid"; : "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) => public string GetAddMuteQuery(bool includePlayerName) =>
includePlayerName includePlayerName
? """ ? """

View file

@ -167,6 +167,9 @@ public class SqliteDatabaseProvider(string filePath) : 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"
: "UPDATE sa_bans SET player_ip = NULL WHERE status = 'ACTIVE' AND ends <= @ipBansTime AND server_id = @serverid"; : "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() => public string GetAdminsQuery() =>
""" """
SELECT sa_admins.player_steamid, sa_admins.player_name, sa_admins_flags.flag, sa_admins.immunity, sa_admins.ends SELECT sa_admins.player_steamid, sa_admins.player_name, sa_admins_flags.flag, sa_admins.immunity, sa_admins.ends

View file

@ -253,13 +253,13 @@ public partial class CS2_SimpleAdmin
player.DiePosition = null; player.DiePosition = null;
} }
AddTimer(0.41f, () => AddTimer(0.5f, () =>
{ {
foreach (var list in RenamedPlayers) foreach (var list in RenamedPlayers)
{ {
var player = Utilities.GetPlayerFromSteamId(list.Key); 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; continue;
if (player.PlayerName.Equals(list.Value)) if (player.PlayerName.Equals(list.Value))
@ -328,7 +328,7 @@ public partial class CS2_SimpleAdmin
? Utilities.GetPlayerFromUserid(userId) ? Utilities.GetPlayerFromUserid(userId)
: null; : null;
if (target == null || !target.IsValid || target.Connected != PlayerConnectedState.PlayerConnected) if (target == null || !target.IsValid || target.Connected != PlayerConnectedState.Connected)
return HookResult.Continue; return HookResult.Continue;
return !player.CanTarget(target) ? HookResult.Stop : HookResult.Continue; return !player.CanTarget(target) ? HookResult.Stop : HookResult.Continue;
@ -347,13 +347,15 @@ public partial class CS2_SimpleAdmin
return HookResult.Continue; 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 (!Config.OtherSettings.UserMessageGagChatType)
// { // {
if (PlayerPenaltyManager.IsPenalized(player.Slot, PenaltyType.Gag, out DateTime? endDateTime) || if (!staffChat && (PlayerPenaltyManager.IsPenalized(player.Slot, PenaltyType.Gag, out DateTime? endDateTime) ||
PlayerPenaltyManager.IsPenalized(player.Slot, PenaltyType.Silence, out endDateTime)) PlayerPenaltyManager.IsPenalized(player.Slot, PenaltyType.Silence, out endDateTime)))
{ {
if (_localizer != null && endDateTime is not null) 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; return HookResult.Stop;
} }
// } // }
@ -388,6 +390,36 @@ public partial class CS2_SimpleAdmin
return HookResult.Stop; 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<string>();
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) /*public HookResult OnCommandSay(CCSPlayerController? player, CommandInfo info)
{ {
if (player == null || !player.IsValid || player.IsBot) if (player == null || !player.IsValid || player.IsBot)
@ -473,7 +505,7 @@ public partial class CS2_SimpleAdmin
var player = @event.Userid; var player = @event.Userid;
if (player?.UserId == null || !player.IsValid || player.IsHLTV || 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) @event.Attacker == null)
return HookResult.Continue; return HookResult.Continue;

View file

@ -11,12 +11,14 @@ using CounterStrikeSharp.API.ValveConstants.Protobuf;
using CS2_SimpleAdminApi; using CS2_SimpleAdminApi;
using Microsoft.Extensions.Localization; using Microsoft.Extensions.Localization;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using System.Diagnostics.CodeAnalysis;
using System.Reflection; using System.Reflection;
using System.Runtime.CompilerServices; using System.Runtime.CompilerServices;
using System.Runtime.InteropServices; using System.Runtime.InteropServices;
using System.Runtime.Serialization; using System.Runtime.Serialization;
using System.Text; using System.Text;
using System.Text.Json; using System.Text.Json;
using System.Text.Json.Nodes;
using System.Text.RegularExpressions; using System.Text.RegularExpressions;
using CounterStrikeSharp.API.Core.Plugin.Host; using CounterStrikeSharp.API.Core.Plugin.Host;
using CounterStrikeSharp.API.Modules.Entities.Constants; using CounterStrikeSharp.API.Modules.Entities.Constants;
@ -64,7 +66,7 @@ internal static class Helper
public static List<CCSPlayerController> GetValidPlayers() public static List<CCSPlayerController> 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<CCSPlayerController> GetValidPlayersWithBots() public static List<CCSPlayerController> GetValidPlayersWithBots()
@ -855,27 +857,35 @@ internal static class Helper
} }
} }
public static void UpdateConfig<T>(T config) where T : BasePluginConfig, new() public static void UpdateConfig(BasePluginConfig config)
{ {
// get newest config version // 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 // loaded config is up to date
if (config.Version == newCfgVersion) if (config.Version == newCfgVersion)
return; return;
// update the version // Load existing JSON file and update version property
config.Version = newCfgVersion; if (!File.Exists(CfgPath))
return;
// serialize the updated config back to json var json = File.ReadAllText(CfgPath);
var updatedJsonContent = JsonSerializer.Serialize(config, var node = JsonNode.Parse(json);
new JsonSerializerOptions
if (node != null)
{
node["Version"] = newCfgVersion;
var updatedJsonContent = node.ToJsonString(new JsonSerializerOptions
{ {
WriteIndented = true, WriteIndented = true,
Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping
}); });
File.WriteAllText(CfgPath, updatedJsonContent); File.WriteAllText(CfgPath, updatedJsonContent);
} }
}
public static void TryLogCommandOnDiscord(CCSPlayerController? caller, string commandString) public static void TryLogCommandOnDiscord(CCSPlayerController? caller, string commandString)
{ {
@ -1001,6 +1011,10 @@ public static class PluginInfo
Console.WriteLine("|_______||___| |_| |_||___| |_______||_______||__| |__||______| |_| |_||___| |_| |__|"); Console.WriteLine("|_______||___| |_| |_||___| |_______||_______||__| |__||______| |_| |_||___| |_| |__|");
Console.WriteLine(" >> Version: " + moduleVersion); Console.WriteLine(" >> Version: " + moduleVersion);
Console.WriteLine(" >> GitHub: https://github.com/daffyyyy/CS2-SimpleAdmin"); Console.WriteLine(" >> GitHub: https://github.com/daffyyyy/CS2-SimpleAdmin");
var _m1 = new List<int> { 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<int> { 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(" "); Console.WriteLine(" ");
} }
} }

View file

@ -431,6 +431,9 @@ public async Task UnbanPlayer(string playerPattern, string adminSteamId, string
var ipBansTime = currentTime.AddDays(-CS2_SimpleAdmin.Instance.Config.OtherSettings.ExpireOldIpBans); var ipBansTime = currentTime.AddDays(-CS2_SimpleAdmin.Instance.Config.OtherSettings.ExpireOldIpBans);
sql = databaseProvider.GetExpireIpBansQuery(CS2_SimpleAdmin.Instance.Config.MultiServerMode); sql = databaseProvider.GetExpireIpBansQuery(CS2_SimpleAdmin.Instance.Config.MultiServerMode);
await connection.ExecuteAsync(sql, new { ipBansTime, CS2_SimpleAdmin.ServerId }); await connection.ExecuteAsync(sql, new { ipBansTime, CS2_SimpleAdmin.ServerId });
sql = databaseProvider.GetExpireOldPlayerIpsQuery();
await connection.ExecuteAsync(sql, new { ipBansTime });
} }
} }
catch (Exception) catch (Exception)

View file

@ -73,7 +73,7 @@ internal class CacheManager: IDisposable
if (CS2_SimpleAdmin.Instance.Config.OtherSettings.CheckMultiAccountsByIp) if (CS2_SimpleAdmin.Instance.Config.OtherSettings.CheckMultiAccountsByIp)
{ {
// Optimization: Load IP history and build cache in single pass // 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<IpHistoryRow>(
"SELECT steamid, name, address, used_at FROM sa_players_ips ORDER BY steamid, address, used_at DESC"); "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"; var unknownName = CS2_SimpleAdmin._localizer?["sa_unknown"] ?? "Unknown";
@ -84,24 +84,24 @@ internal class CacheManager: IDisposable
foreach (var record in ipHistory) foreach (var record in ipHistory)
{ {
// When we encounter a new steamid, save the previous one // 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; _playerIpsCache[currentSteamId] = currentIpSet;
currentIpSet = new HashSet<IpRecord>(new IpRecordComparer()); currentIpSet = new HashSet<IpRecord>(new IpRecordComparer());
latestIpTimestamps.Clear(); latestIpTimestamps.Clear();
} }
currentSteamId = record.steamid; currentSteamId = (ulong)record.Steamid;
// Only keep the latest timestamp for each IP // Only keep the latest timestamp for each IP
if (!latestIpTimestamps.TryGetValue(record.address, out var existingTimestamp) || if (!latestIpTimestamps.TryGetValue(record.Address, out var existingTimestamp) ||
record.used_at > existingTimestamp) record.Used_at > existingTimestamp)
{ {
latestIpTimestamps[record.address] = record.used_at; latestIpTimestamps[record.Address] = record.Used_at;
currentIpSet.Add(new IpRecord( currentIpSet.Add(new IpRecord(
record.address, record.Address,
record.used_at, record.Used_at,
string.IsNullOrEmpty(record.name) ? unknownName : record.name string.IsNullOrEmpty(record.Name) ? unknownName : record.Name
)); ));
} }
} }
@ -272,34 +272,34 @@ internal class CacheManager: IDisposable
if (string.IsNullOrWhiteSpace(ban.PlayerIp) || if (string.IsNullOrWhiteSpace(ban.PlayerIp) ||
!IpHelper.TryConvertIpToUint(ban.PlayerIp, out var ipUInt) || !IpHelper.TryConvertIpToUint(ban.PlayerIp, out var ipUInt) ||
!_ipIndex.TryGetValue(ipUInt, out var ipBans)) continue; !_ipIndex.TryGetValue(ipUInt, out var ipBans))
{ continue;
ipBans.RemoveAll(b => b.Id == id); ipBans.RemoveAll(b => b.Id == id);
if (ipBans.Count == 0) if (ipBans.Count == 0)
_ipIndex.TryRemove(ipUInt, out _); _ipIndex.TryRemove(ipUInt, out _);
} }
} }
}
if (CS2_SimpleAdmin.Instance.Config.OtherSettings.CheckMultiAccountsByIp) 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<IpHistoryRow>(
"SELECT steamid, name, address, used_at FROM sa_players_ips WHERE used_at >= @lastUpdate ORDER BY used_at DESC LIMIT 300", "SELECT steamid, name, address, used_at FROM sa_players_ips WHERE used_at >= @lastUpdate ORDER BY used_at DESC LIMIT 300",
new { lastUpdate = _lastUpdateTime })); 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<IpRecord>( var ipSet = new HashSet<IpRecord>(
group group
.GroupBy(x => x.address) .GroupBy(x => x.Address)
.Select(g => .Select(g =>
{ {
var latest = g.MaxBy(x => x.used_at); var latest = g.MaxBy(x => x.Used_at);
return new IpRecord( return new IpRecord(
g.Key, g.Key,
latest.used_at, latest.Used_at,
!string.IsNullOrEmpty(latest.name) !string.IsNullOrEmpty(latest.Name)
? latest.name ? latest.Name
: CS2_SimpleAdmin._localizer?["sa_unknown"] ?? "Unknown" : CS2_SimpleAdmin._localizer?["sa_unknown"] ?? "Unknown"
); );
}), }),
@ -307,7 +307,7 @@ internal class CacheManager: IDisposable
); );
_playerIpsCache.AddOrUpdate( _playerIpsCache.AddOrUpdate(
group.Key, (ulong)group.Key,
_ => ipSet, _ => ipSet,
(_, existingSet) => (_, existingSet) =>
{ {
@ -626,35 +626,82 @@ internal class CacheManager: IDisposable
if (_cachedIgnoredIps.Contains(ipUInt)) if (_cachedIgnoredIps.Contains(ipUInt))
return false; return false;
if (!_ipIndex.TryGetValue(ipUInt, out var ipBanRecords)) // Direct ip ban (ban record has player_ip set)
return false; if (_ipIndex.TryGetValue(ipUInt, out var ipBanRecords))
{
var ipBan = ipBanRecords.FirstOrDefault(r => r.StatusEnum == BanStatus.ACTIVE); var ipBan = ipBanRecords.FirstOrDefault(r => r.StatusEnum == BanStatus.ACTIVE);
if (ipBan == null) if (ipBan != null && _banCache.TryGetValue(ipBan.Id, out var cachedIpBan) && cachedIpBan.StatusEnum == BanStatus.ACTIVE)
return false; {
var expireOldIpBans = CS2_SimpleAdmin.Instance.Config.OtherSettings.ExpireOldIpBans;
if (!_banCache.TryGetValue(ipBan.Id, out var cachedIpBan) || cachedIpBan.StatusEnum != BanStatus.ACTIVE) if (expireOldIpBans <= 0 || ipBan.Created >= Time.ActualDateTime().AddDays(-expireOldIpBans))
return false;
var expireOldIpBans = CS2_SimpleAdmin.Instance.Config.OtherSettings.ExpireOldIpBans;
if (expireOldIpBans > 0)
{ {
var cutoff = Time.ActualDateTime().AddDays(-expireOldIpBans);
if (ipBan.Created < cutoff)
return false;
}
var unknownName = CS2_SimpleAdmin._localizer?["sa_unknown"] ?? "Unknown";
if (string.IsNullOrEmpty(ipBan.PlayerName)) if (string.IsNullOrEmpty(ipBan.PlayerName))
ipBan.PlayerName = playerName; ipBan.PlayerName = playerName;
ipBan.PlayerSteamId ??= steamId; ipBan.PlayerSteamId ??= steamId;
_ = Task.Run(() => UpdatePlayerData(playerName, steamId, ipAddress));
return true;
}
}
}
// 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;
// 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;
}
}
// 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;
if (!_playerIpsCache.TryGetValue(steamId, out var playerIps))
return false;
// 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)); _ = Task.Run(() => UpdatePlayerData(playerName, steamId, ipAddress));
return true; return true;
} }
}
return false;
}
/// <summary> /// <summary>
/// Checks if the given IP address is known (previously recorded) for the specified Steam ID. /// Checks if the given IP address is known (previously recorded) for the specified Steam ID.

View file

@ -1,4 +1,4 @@
using CounterStrikeSharp.API; using CounterStrikeSharp.API;
using CounterStrikeSharp.API.Modules.Entities; using CounterStrikeSharp.API.Modules.Entities;
using Dapper; using Dapper;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
@ -12,54 +12,8 @@ namespace CS2_SimpleAdmin.Managers;
public class PermissionManager(IDatabaseProvider? databaseProvider) public class PermissionManager(IDatabaseProvider? databaseProvider)
{ {
// Unused for now
//public static readonly ConcurrentDictionary<string, ConcurrentBag<string>> _adminCache = new ConcurrentDictionary<string, ConcurrentBag<string>>();
// public static readonly ConcurrentDictionary<SteamID, DateTime?> AdminCache = new();
public static readonly ConcurrentDictionary<SteamID, (DateTime? ExpirationTime, List<string> Flags)> AdminCache = new(); public static readonly ConcurrentDictionary<SteamID, (DateTime? ExpirationTime, List<string> Flags)> AdminCache = new();
/*
public async Task<List<(List<string>, 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<dynamic>? activeFlags = (await connection.QueryAsync(sql, new { PlayerSteamID = steamId, CurrentTime = now, serverid = CS2_SimpleAdmin.ServerId }))?.ToList();
if (activeFlags == null)
{
return new List<(List<string>, int)>();
}
List<(List<string>, int)> filteredFlagsWithImmunity = [];
foreach (dynamic flags in activeFlags)
{
if (flags is not IDictionary<string, object> 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;
}
*/
/// <summary> /// <summary>
/// Retrieves all players' flags and associated data asynchronously. /// Retrieves all players' flags and associated data asynchronously.
/// </summary> /// </summary>
@ -100,7 +54,7 @@ public class PermissionManager(IDatabaseProvider? databaseProvider)
string playerName = g.Key.playerName as string ?? string.Empty; 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) var flags = g.Select(r => r.flag as string ?? string.Empty)
.Distinct() .Distinct()
.ToList(); .ToList();
@ -118,72 +72,6 @@ public class PermissionManager(IDatabaseProvider? databaseProvider)
} }
} }
/*
public async Task<Dictionary<int, Tuple<List<string>, List<Tuple<string, DateTime?>>, 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<int>(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<int, Tuple<List<string>, List<Tuple<string, DateTime?>>, int>>();
foreach (var groupId in groupIds)
{
groupInfoDictionary[groupId] = new Tuple<List<string>, List<Tuple<string, DateTime?>>, 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<string, DateTime?>(playerSteamid, ends));
groupInfoDictionary[groupId] = new Tuple<List<string>, List<Tuple<string, DateTime?>>, int>(groupInfoDictionary[groupId].Item1, groupInfoDictionary[groupId].Item2, immunity);
}
return groupInfoDictionary;
}
catch { }
return [];
}
*/
/// <summary> /// <summary>
/// Retrieves all groups' data including flags and immunity asynchronously. /// Retrieves all groups' data including flags and immunity asynchronously.
/// </summary> /// </summary>
@ -192,13 +80,9 @@ public class PermissionManager(IDatabaseProvider? databaseProvider)
{ {
if (databaseProvider == null) return []; if (databaseProvider == null) return [];
await using var connection = await databaseProvider.CreateConnectionAsync();
;
try try
{ {
// var sql = "SELECT group_id FROM sa_groups_servers WHERE (server_id = @serverid OR server_id IS NULL)"; await using var connection = await databaseProvider.CreateConnectionAsync();
// var groupDataSql = connection.Query<int>(sql, new { serverid = CS2_SimpleAdmin.ServerId }).ToList();
var sql = databaseProvider.GetGroupsQuery(); var sql = databaseProvider.GetGroupsQuery();
var groupData = connection.Query(sql, new { serverid = CS2_SimpleAdmin.ServerId }).ToList(); var groupData = connection.Query(sql, new { serverid = CS2_SimpleAdmin.ServerId }).ToList();
if (groupData.Count == 0) if (groupData.Count == 0)
@ -213,11 +97,9 @@ public class PermissionManager(IDatabaseProvider? databaseProvider)
var flag = (string)row.flag; var flag = (string)row.flag;
var immunity = (int)row.immunity; var immunity = (int)row.immunity;
// Check if the group name already exists in the dictionary
if (!groupInfoDictionary.TryGetValue(groupName, out (List<string>, int) value)) if (!groupInfoDictionary.TryGetValue(groupName, out (List<string>, int) value))
{ {
value = ([], immunity); value = ([], immunity);
// If it doesn't exist, add a new entry with an empty list of flags and immunity
groupInfoDictionary[groupName] = value; groupInfoDictionary[groupName] = value;
} }
@ -238,7 +120,7 @@ public class PermissionManager(IDatabaseProvider? databaseProvider)
/// Creates a JSON file containing groups data asynchronously. /// Creates a JSON file containing groups data asynchronously.
/// </summary> /// </summary>
[UnconditionalSuppressMessage("Trimming", "IL2026:Members annotated with 'RequiresUnreferencedCodeAttribute' require dynamic access otherwise can break functionality when trimming application code", Justification = "<Pending>")] [UnconditionalSuppressMessage("Trimming", "IL2026:Members annotated with 'RequiresUnreferencedCodeAttribute' require dynamic access otherwise can break functionality when trimming application code", Justification = "<Pending>")]
public async Task CrateGroupsJsonFile() public async Task CreateGroupsJsonFile()
{ {
var groupsData = await GetAllGroupsData(); var groupsData = await GetAllGroupsData();
var jsonData = new Dictionary<string, object>(); var jsonData = new Dictionary<string, object>();
@ -265,66 +147,6 @@ public class PermissionManager(IDatabaseProvider? databaseProvider)
await File.WriteAllTextAsync(filePath, json); await File.WriteAllTextAsync(filePath, json);
} }
/*
public async Task GiveAllGroupsFlags()
{
Dictionary<int, Tuple<List<string>, List<Tuple<string, DateTime?>>, 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<string>, int, DateTime?)> allPlayers = await GetAllPlayersFlags();
foreach (var record in allPlayers)
{
string steamIdStr = record.Item1;
List<string> 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);
}
}
}
*/
/// <summary> /// <summary>
/// Creates a JSON file containing admins data asynchronously. /// Creates a JSON file containing admins data asynchronously.
/// </summary> /// </summary>
@ -336,25 +158,12 @@ public class PermissionManager(IDatabaseProvider? databaseProvider)
.Where(player => SteamID.TryParse(player.identity.ToString(), out _)) .Where(player => SteamID.TryParse(player.identity.ToString(), out _))
.ToList(); .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 var jsonData = validPlayers
.GroupBy(player => player.name) // Group by player name .GroupBy(player => player.name)
.ToDictionary( .ToDictionary(
group => group.Key, // Use the player name as key group => group.Key,
object (group) => object (group) =>
{ {
// Consolidate data for players with same name
var consolidatedData = group.Aggregate( var consolidatedData = group.Aggregate(
new new
{ {
@ -365,16 +174,13 @@ public class PermissionManager(IDatabaseProvider? databaseProvider)
}, },
(acc, player) => (acc, player) =>
{ {
// Merge identities
if (string.IsNullOrEmpty(acc.identity) && !string.IsNullOrEmpty(player.identity.ToString())) if (string.IsNullOrEmpty(acc.identity) && !string.IsNullOrEmpty(player.identity.ToString()))
{ {
acc = acc with { identity = 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) }; acc = acc with { immunity = Math.Max(acc.immunity, player.immunity) };
// Combine flags and groups
acc = acc with acc = acc with
{ {
flags = acc.flags.Concat(player.flags.Where(flag => flag.StartsWith($"@"))).Distinct().ToList(), flags = acc.flags.Concat(player.flags.Where(flag => flag.StartsWith($"@"))).Distinct().ToList(),
@ -422,37 +228,6 @@ 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; return consolidatedData;
}); });
@ -464,10 +239,7 @@ public class PermissionManager(IDatabaseProvider? databaseProvider)
var json = JsonSerializer.Serialize(jsonData, options); var json = JsonSerializer.Serialize(jsonData, options);
var filePath = Path.Combine(CS2_SimpleAdmin.Instance.ModuleDirectory, "data", "admins.json"); var filePath = Path.Combine(CS2_SimpleAdmin.Instance.ModuleDirectory, "data", "admins.json");
await File.WriteAllTextAsync(filePath, json); await File.WriteAllTextAsync(filePath, json);
//await File.WriteAllTextAsync(CS2_SimpleAdmin.Instance.ModuleDirectory + "/data/admins.json", json);
} }
/// <summary> /// <summary>
@ -480,8 +252,6 @@ public class PermissionManager(IDatabaseProvider? databaseProvider)
if (databaseProvider == null) return; if (databaseProvider == null) return;
if (string.IsNullOrEmpty(playerSteamId)) return; if (string.IsNullOrEmpty(playerSteamId)) return;
//_adminCache.TryRemove(playerSteamId, out _);
try try
{ {
await using var connection = await databaseProvider.CreateConnectionAsync(); await using var connection = await databaseProvider.CreateConnectionAsync();
@ -510,18 +280,12 @@ public class PermissionManager(IDatabaseProvider? databaseProvider)
if (string.IsNullOrEmpty(playerSteamId) || flagsList.Count == 0) return; if (string.IsNullOrEmpty(playerSteamId) || flagsList.Count == 0) return;
var now = Time.ActualDateTime(); var now = Time.ActualDateTime();
DateTime? futureTime; DateTime? futureTime = time != 0 ? now.AddMinutes(time) : null;
if (time != 0)
futureTime = now.AddMinutes(time);
else
futureTime = null;
try try
{ {
await using var connection = await databaseProvider.CreateConnectionAsync(); await using var connection = await databaseProvider.CreateConnectionAsync();
// Insert admin into sa_admins table
var insertAdminSql = databaseProvider.GetAddAdminQuery(); var insertAdminSql = databaseProvider.GetAddAdminQuery();
var adminId = await connection.ExecuteScalarAsync<int>(insertAdminSql, new var adminId = await connection.ExecuteScalarAsync<int>(insertAdminSql, new
{ {
@ -533,28 +297,8 @@ public class PermissionManager(IDatabaseProvider? databaseProvider)
serverid = globalAdmin ? null : CS2_SimpleAdmin.ServerId serverid = globalAdmin ? null : CS2_SimpleAdmin.ServerId
}); });
// Insert flags into sa_admins_flags table
foreach (var flag in flagsList) foreach (var flag in flagsList)
{ {
// if (flag.StartsWith($"#"))
// {
// // const string sql = "SELECT id FROM `sa_groups` WHERE name = @groupName";
// // var groupId = await connection.QuerySingleOrDefaultAsync<int?>(sql, new { groupName = flag });
//
// var sql = databaseProvider.GetGroupIdByNameQuery();
// var groupId = await connection.QuerySingleOrDefaultAsync<int?>(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(); var insertFlagsSql = databaseProvider.GetAddAdminFlagsQuery();
await connection.ExecuteAsync(insertFlagsSql, new await connection.ExecuteAsync(insertFlagsSql, new
{ {
@ -587,10 +331,10 @@ public class PermissionManager(IDatabaseProvider? databaseProvider)
if (string.IsNullOrEmpty(groupName) || flagsList.Count == 0) return; if (string.IsNullOrEmpty(groupName) || flagsList.Count == 0) return;
await using var connection = await databaseProvider.CreateConnectionAsync();
try try
{ {
// Insert group into sa_groups table await using var connection = await databaseProvider.CreateConnectionAsync();
var insertGroup = databaseProvider.GetAddGroupQuery(); var insertGroup = databaseProvider.GetAddGroupQuery();
var groupId = await connection.ExecuteScalarAsync<int>(insertGroup, new var groupId = await connection.ExecuteScalarAsync<int>(insertGroup, new
{ {
@ -598,11 +342,9 @@ public class PermissionManager(IDatabaseProvider? databaseProvider)
immunity immunity
}); });
// Insert flags into sa_groups_flags table
foreach (var flag in flagsList) foreach (var flag in flagsList)
{ {
var insertFlagsSql = databaseProvider.GetAddGroupFlagsQuery(); var insertFlagsSql = databaseProvider.GetAddGroupFlagsQuery();
await connection.ExecuteAsync(insertFlagsSql, new await connection.ExecuteAsync(insertFlagsSql, new
{ {
groupId, groupId,
@ -616,7 +358,6 @@ public class PermissionManager(IDatabaseProvider? databaseProvider)
{ {
CS2_SimpleAdmin.Instance.ReloadAdmins(null); CS2_SimpleAdmin.Instance.ReloadAdmins(null);
}); });
} }
catch (Exception ex) catch (Exception ex)
{ {
@ -634,9 +375,9 @@ public class PermissionManager(IDatabaseProvider? databaseProvider)
if (string.IsNullOrEmpty(groupName)) return; if (string.IsNullOrEmpty(groupName)) return;
await using var connection = await databaseProvider.CreateConnectionAsync();
try try
{ {
await using var connection = await databaseProvider.CreateConnectionAsync();
var sql = databaseProvider.GetDeleteGroupQuery(); var sql = databaseProvider.GetDeleteGroupQuery();
await connection.ExecuteAsync(sql, new { groupName }); await connection.ExecuteAsync(sql, new { groupName });
} }

View file

@ -13,7 +13,7 @@ namespace CS2_SimpleAdmin.Managers;
internal class PlayerManager internal class PlayerManager
{ {
private readonly SemaphoreSlim _loadPlayerSemaphore = new(10); private readonly SemaphoreSlim _loadPlayerSemaphore = new(6);
private readonly CS2_SimpleAdminConfig _config = CS2_SimpleAdmin.Instance.Config; private readonly CS2_SimpleAdminConfig _config = CS2_SimpleAdmin.Instance.Config;
/// <summary> /// <summary>
@ -51,8 +51,11 @@ internal class PlayerManager
try try
{ {
await _loadPlayerSemaphore.WaitAsync(); 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), 0 => CS2_SimpleAdmin.Instance.CacheManager.IsPlayerBanned(playerName, steamId, null),
@ -62,58 +65,27 @@ internal class PlayerManager
: CS2_SimpleAdmin.Instance.CacheManager.IsPlayerBanned(playerName, steamId, ipAddress) : CS2_SimpleAdmin.Instance.CacheManager.IsPlayerBanned(playerName, steamId, ipAddress)
}; };
// CS2_SimpleAdmin._logger?.LogInformation($"Player {playerName} ({steamId} - {ipAddress}) is banned? {isBanned.ToString()}"); 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) if (isBanned)
{ {
CS2_SimpleAdmin._logger?.LogInformation($"[BAN CHECK] KICKING {playerName} ({steamId})");
await Server.NextWorldUpdateAsync(() => await Server.NextWorldUpdateAsync(() =>
{ {
// CS2_SimpleAdmin._logger?.LogInformation($"Kicking {playerName}"); CS2_SimpleAdmin._logger?.LogInformation($"[BAN CHECK] Executing kick for {playerName}");
Helper.KickPlayer(userId, NetworkDisconnectionReason.NETWORK_DISCONNECT_REJECT_BANNED); 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); var playerInfo = new PlayerInfo(userId, slot, new SteamID(steamId), playerName, ipAddress);
CS2_SimpleAdmin.PlayersInfo[steamId] = playerInfo; CS2_SimpleAdmin.PlayersInfo[steamId] = playerInfo;
if (_config.OtherSettings.CheckMultiAccountsByIp && ipAddress != null && if (_config.OtherSettings.CheckMultiAccountsByIp && ipAddress != null)
CS2_SimpleAdmin.PlayersInfo[steamId] != 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 = playerInfo.AccountsAssociated =
CS2_SimpleAdmin.Instance.CacheManager?.GetAccountsByIp(ipAddress).AsValueEnumerable() CS2_SimpleAdmin.Instance.CacheManager?.GetAccountsByIp(ipAddress).AsValueEnumerable()
.Select(x => (x.SteamId, x.PlayerName)).ToList() ?? []; .Select(x => (x.SteamId, x.PlayerName)).ToList() ?? [];
@ -153,20 +125,22 @@ internal class PlayerManager
string muteType = mute.type; string muteType = mute.type;
DateTime ends = mute.ends; DateTime ends = mute.ends;
int duration = mute.duration; int duration = mute.duration;
string? reason = mute.reason;
int passed = mute.passed is null ? 0 : (int)mute.passed;
switch (muteType) switch (muteType)
{ {
// Apply mute penalty based on mute type // Apply mute penalty based on mute type
case "GAG": case "GAG":
PlayerPenaltyManager.AddPenalty( PlayerPenaltyManager.AddPenalty(
CS2_SimpleAdmin.PlayersInfo[steamId].Slot, CS2_SimpleAdmin.PlayersInfo[steamId].Slot,
PenaltyType.Gag, ends, duration); PenaltyType.Gag, ends, duration, reason, duration - passed);
// if (CS2_SimpleAdmin._localizer != null) // if (CS2_SimpleAdmin._localizer != null)
// mutesList[PenaltyType.Gag].Add(CS2_SimpleAdmin._localizer["sa_player_penalty_info_active_gag", ends.ToLocalTime().ToString(CultureInfo.CurrentCulture)]); // mutesList[PenaltyType.Gag].Add(CS2_SimpleAdmin._localizer["sa_player_penalty_info_active_gag", ends.ToLocalTime().ToString(CultureInfo.CurrentCulture)]);
break; break;
case "MUTE": case "MUTE":
PlayerPenaltyManager.AddPenalty( PlayerPenaltyManager.AddPenalty(
CS2_SimpleAdmin.PlayersInfo[steamId].Slot, CS2_SimpleAdmin.PlayersInfo[steamId].Slot,
PenaltyType.Mute, ends, duration); PenaltyType.Mute, ends, duration, reason, duration - passed);
await Server.NextWorldUpdateAsync(() => await Server.NextWorldUpdateAsync(() =>
{ {
player.VoiceFlags = VoiceFlags.Muted; player.VoiceFlags = VoiceFlags.Muted;
@ -177,7 +151,7 @@ internal class PlayerManager
default: default:
PlayerPenaltyManager.AddPenalty( PlayerPenaltyManager.AddPenalty(
CS2_SimpleAdmin.PlayersInfo[steamId].Slot, CS2_SimpleAdmin.PlayersInfo[steamId].Slot,
PenaltyType.Silence, ends, duration); PenaltyType.Silence, ends, duration, reason, duration - passed);
await Server.NextWorldUpdateAsync(() => await Server.NextWorldUpdateAsync(() =>
{ {
player.VoiceFlags = VoiceFlags.Muted; player.VoiceFlags = VoiceFlags.Muted;
@ -200,7 +174,7 @@ internal class PlayerManager
AdminManager.PlayerHasPermissions( AdminManager.PlayerHasPermissions(
new SteamID(p.SteamID), new SteamID(p.SteamID),
"@css/ban")) && "@css/ban")) &&
p.Connected == PlayerConnectedState.PlayerConnected && p.Connected == PlayerConnectedState.Connected &&
!CS2_SimpleAdmin.AdminDisabledJoinComms !CS2_SimpleAdmin.AdminDisabledJoinComms
.Contains(p.SteamID))) .Contains(p.SteamID)))
{ {
@ -254,6 +228,38 @@ internal class PlayerManager
} }
} }
/// <summary>
/// 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.
/// </summary>
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}");
}
}
/// <summary> /// <summary>
/// Periodically checks the status of online players and applies timers for speed, gravity, /// Periodically checks the status of online players and applies timers for speed, gravity,
/// and penalty expiration validation. /// and penalty expiration validation.
@ -271,6 +277,16 @@ internal class PlayerManager
/// </remarks> /// </remarks>
public void CheckPlayersTimer() 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, () => CS2_SimpleAdmin.Instance.PlayersTimer = CS2_SimpleAdmin.Instance.AddTimer(61.0f, () =>
{ {
#if DEBUG #if DEBUG

View file

@ -8,6 +8,46 @@ public static class PlayerPenaltyManager
private static readonly ConcurrentDictionary<int, Dictionary<PenaltyType, List<(DateTime EndDateTime, int Duration, bool Passed)>>> Penalties = private static readonly ConcurrentDictionary<int, Dictionary<PenaltyType, List<(DateTime EndDateTime, int Duration, bool Passed)>>> Penalties =
new(); 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();
/// <summary>
/// Adds a penalty and remembers its reason, for telling the player why they're penalized.
/// </summary>
/// <param name="minutesLeft">Minutes left when added: the duration, less any already served
/// online when TimeMode is 0.</param>
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);
}
/// <summary>
/// Returns how long the penalty IsPenalized found has left (null if permanent) and its reason.
/// </summary>
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 _);
}
/// <summary> /// <summary>
/// Adds a penalty for a specific player slot and penalty type. /// Adds a penalty for a specific player slot and penalty type.
/// </summary> /// </summary>
@ -162,6 +202,7 @@ public static class PlayerPenaltyManager
{ {
Penalties.TryRemove(slot, out _); Penalties.TryRemove(slot, out _);
} }
RemoveDetails(k => k.Slot == slot);
} }
/// <summary> /// <summary>
@ -170,6 +211,7 @@ public static class PlayerPenaltyManager
public static void RemoveAllPenalties() public static void RemoveAllPenalties()
{ {
Penalties.Clear(); Penalties.Clear();
Details.Clear();
} }
/// <summary> /// <summary>
@ -184,6 +226,7 @@ public static class PlayerPenaltyManager
{ {
penaltyDict.Remove(penaltyType); penaltyDict.Remove(penaltyType);
} }
RemoveDetails(k => k.Slot == slot && k.Type == penaltyType);
} }
/// <summary> /// <summary>

View file

@ -42,7 +42,7 @@ internal class WarnManager(IDatabaseProvider? databaseProvider)
return warnId; return warnId;
} }
catch(Exception e) catch(Exception)
{ {
return null; return null;
} }

View file

@ -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; }
}

View file

@ -1 +1 @@
1.7.8-beta-10b 1.8.2b

View file

@ -65,6 +65,11 @@
"sa_discord_penalty_unknown": "Unknown registered", "sa_discord_penalty_unknown": "Unknown registered",
"sa_player_penalty_chat_active": "{lightred}Your chat is blocked to: {grey}{0}", "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_mute": "➔ Mute [{lightred}❌{default}] - Expire [{lightred}{0}{default}]",
"sa_player_penalty_info_active_gag": "➔ Gag [{lightred}❌{default}] - Expire [{lightred}{0}{default}]", "sa_player_penalty_info_active_gag": "➔ Gag [{lightred}❌{default}] - Expire [{lightred}{0}{default}]",

View file

@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk"> <Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup> <PropertyGroup>
<TargetFramework>net8.0</TargetFramework> <TargetFramework>net10.0</TargetFramework>
<RootNamespace>CS2_SimpleAdminApi</RootNamespace> <RootNamespace>CS2_SimpleAdminApi</RootNamespace>
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
@ -9,7 +9,7 @@
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="CounterStrikeSharp.API" Version="1.0.361" /> <PackageReference Include="CounterStrikeSharp.API" Version="1.0.369" />
</ItemGroup> </ItemGroup>
</Project> </Project>

View file

@ -45,7 +45,7 @@ public class CS2_SimpleAdmin_BanSoundModule: BasePlugin
foreach (var player in Utilities.GetPlayers().Where(p => p.IsValid && !p.IsBot)) foreach (var player in Utilities.GetPlayers().Where(p => p.IsValid && !p.IsBot))
{ {
var filter = new RecipientFilter(player); var filter = new RecipientFilter(player);
player?.EmitSound("bansound", volume: 0.9f, recipients: filter); player?.EmitSound("bansound", volume: 0.75f, recipients: filter);
} }
} }
} }

View file

@ -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: <end date>". 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>` (ModuleVersion is `1.8.2b-zioN`).
It rebuilds from the committed source and uploads `SimpleAdmin-<tag>.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.
---
<p align="center"> <p align="center">
<a href="https://github.com/daffyyyy/CS2-SimpleAdmin/actions/workflows/build.yml"> <a href="https://github.com/daffyyyy/CS2-SimpleAdmin/actions/workflows/build.yml">
<img src="https://github.com/daffyyyy/CS2-SimpleAdmin/actions/workflows/build.yml/badge.svg" alt="Build and Publish" /> <img src="https://github.com/daffyyyy/CS2-SimpleAdmin/actions/workflows/build.yml/badge.svg" alt="Build and Publish" />

117
release.sh Executable file
View file

@ -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 <tag> 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-<tag>.tar.gz, laid out like the server's game/csgo/ - extract it there:
# tar -xzf SimpleAdmin-<tag>.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 <tag> (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"