CSS's admin loaders only add, and the per-admin cleanup in CreateAdminsJsonFile reset AdminCache once per admin, so it only ever tracked the last one and a reload never took anyone else out of memory. ReloadAdmins now removes every admin it loaded last time and loads the new set in one tick. admins.json is keyed by SteamID instead of name, so admins sharing a name aren't merged, and a failed admins query keeps the loaded admins instead of removing them.
440 lines
18 KiB
C#
440 lines
18 KiB
C#
using CounterStrikeSharp.API;
|
|
using CounterStrikeSharp.API.Modules.Entities;
|
|
using Dapper;
|
|
using Microsoft.Extensions.Logging;
|
|
using System.Collections.Concurrent;
|
|
using System.Text.Json;
|
|
using CounterStrikeSharp.API.Modules.Admin;
|
|
using System.Diagnostics.CodeAnalysis;
|
|
using CS2_SimpleAdmin.Database;
|
|
|
|
namespace CS2_SimpleAdmin.Managers;
|
|
|
|
public class PermissionManager(IDatabaseProvider? databaseProvider)
|
|
{
|
|
public static readonly ConcurrentDictionary<SteamID, (DateTime? ExpirationTime, List<string> Flags)> AdminCache = new();
|
|
|
|
/// <summary>
|
|
/// Retrieves all players' flags and associated data asynchronously.
|
|
/// </summary>
|
|
/// <returns>A list of tuples containing player SteamID, name, flags, immunity, and expiration time, or null if the database query failed.</returns>
|
|
private async Task<List<(ulong, string ,List<string>, int, DateTime?)>?> GetAllPlayersFlags()
|
|
{
|
|
if (databaseProvider == null)
|
|
return new List<(ulong, string, List<string>, int, DateTime?)>();
|
|
|
|
var now = Time.ActualDateTime();
|
|
|
|
try
|
|
{
|
|
await using var connection = await databaseProvider.CreateConnectionAsync();
|
|
var sql = databaseProvider.GetAdminsQuery();
|
|
var admins = (await connection.QueryAsync(sql, new { CurrentTime = now, serverid = CS2_SimpleAdmin.ServerId })).ToList();
|
|
|
|
var groupedPlayers = admins
|
|
.GroupBy(r => new { playerSteamId = r.player_steamid, playerName = r.player_name, r.immunity, r.ends })
|
|
.Select(g =>
|
|
{
|
|
ulong steamId = g.Key.playerSteamId switch
|
|
{
|
|
long l => (ulong)l,
|
|
int i => (ulong)i,
|
|
string s when ulong.TryParse(s, out var parsed) => parsed,
|
|
_ => 0UL
|
|
};
|
|
|
|
int immunity = g.Key.immunity switch
|
|
{
|
|
int i => i,
|
|
string s when int.TryParse(s, out var parsed) => parsed,
|
|
_ => 0
|
|
};
|
|
|
|
DateTime? ends = g.Key.ends as DateTime?;
|
|
|
|
string playerName = g.Key.playerName as string ?? string.Empty;
|
|
|
|
// Dapper returns string here, not dynamic
|
|
var flags = g.Select(r => r.flag as string ?? string.Empty)
|
|
.Distinct()
|
|
.ToList();
|
|
|
|
return (steamId, playerName, flags, immunity, ends);
|
|
})
|
|
.ToList();
|
|
|
|
return groupedPlayers;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
CS2_SimpleAdmin._logger?.LogError("Unable to load admins from database! {exception}", ex.Message);
|
|
// zio: null rather than empty, so ReloadAdmins keeps the loaded admins instead of removing them all.
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Retrieves all groups' data including flags and immunity asynchronously.
|
|
/// </summary>
|
|
/// <returns>A dictionary with group names as keys and tuples of flags and immunity as values.</returns>
|
|
private async Task<Dictionary<string, (List<string>, int)>> GetAllGroupsData()
|
|
{
|
|
if (databaseProvider == null) return [];
|
|
|
|
try
|
|
{
|
|
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)
|
|
{
|
|
return [];
|
|
}
|
|
|
|
var groupInfoDictionary = new Dictionary<string, (List<string>, int)>();
|
|
foreach (var row in groupData)
|
|
{
|
|
var groupName = (string)row.group_name;
|
|
var flag = (string)row.flag;
|
|
var immunity = (int)row.immunity;
|
|
|
|
if (!groupInfoDictionary.TryGetValue(groupName, out (List<string>, int) value))
|
|
{
|
|
value = ([], immunity);
|
|
groupInfoDictionary[groupName] = value;
|
|
}
|
|
|
|
value.Item1.Add(flag);
|
|
}
|
|
|
|
return groupInfoDictionary;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
CS2_SimpleAdmin._logger?.LogError("Unable to load groups from database! {exception}", ex.Message);
|
|
}
|
|
|
|
return [];
|
|
}
|
|
|
|
/// <summary>
|
|
/// Creates a JSON file containing groups data asynchronously.
|
|
/// </summary>
|
|
[UnconditionalSuppressMessage("Trimming", "IL2026:Members annotated with 'RequiresUnreferencedCodeAttribute' require dynamic access otherwise can break functionality when trimming application code", Justification = "<Pending>")]
|
|
public async Task CreateGroupsJsonFile()
|
|
{
|
|
var groupsData = await GetAllGroupsData();
|
|
var jsonData = new Dictionary<string, object>();
|
|
|
|
foreach (var kvp in groupsData)
|
|
{
|
|
var groupData = new Dictionary<string, object>
|
|
{
|
|
["flags"] = kvp.Value.Item1,
|
|
["immunity"] = kvp.Value.Item2
|
|
};
|
|
|
|
jsonData[kvp.Key] = groupData;
|
|
}
|
|
|
|
var options = new JsonSerializerOptions
|
|
{
|
|
WriteIndented = true,
|
|
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
|
|
};
|
|
|
|
var json = JsonSerializer.Serialize(jsonData, options);
|
|
var filePath = Path.Combine(CS2_SimpleAdmin.Instance.ModuleDirectory, "data", "groups.json");
|
|
await File.WriteAllTextAsync(filePath, json);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Creates a JSON file containing admins data asynchronously.
|
|
/// </summary>
|
|
/// <returns>zio: every admin row loaded, for ReloadAdmins to rebuild AdminCache from, or null if the database query failed (the file is then left alone).</returns>
|
|
[UnconditionalSuppressMessage("Trimming", "IL2026:Members annotated with 'RequiresUnreferencedCodeAttribute' require dynamic access otherwise can break functionality when trimming application code", Justification = "<Pending>")]
|
|
public async Task<List<(SteamID SteamId, DateTime? Ends, List<string> Flags)>?> CreateAdminsJsonFile()
|
|
{
|
|
var allPlayers = await GetAllPlayersFlags();
|
|
if (allPlayers == null) return null;
|
|
|
|
var validPlayers = allPlayers
|
|
.Select(player => (player, steamId: SteamID.TryParse(player.Item1.ToString(), out var steamId) ? steamId : null))
|
|
.Where(p => p.steamId != null)
|
|
.ToList();
|
|
|
|
// zio: keyed by SteamID, not name: CSS ignores the key, and two admins with the same name were
|
|
// merged into one entry under the first one's SteamID. Upstream also cleared and refilled
|
|
// AdminCache here once per admin, so it only ever kept the last admin and a reload never
|
|
// removed anyone else; ReloadAdmins now does that once, from the list returned here.
|
|
var jsonData = validPlayers
|
|
.GroupBy(p => p.steamId!.SteamId64)
|
|
.ToDictionary(
|
|
group => group.Key.ToString(),
|
|
object (group) =>
|
|
{
|
|
var rows = group.Select(p => p.player).ToList();
|
|
var flags = rows.SelectMany(r => r.Item3).Distinct().ToList();
|
|
return new
|
|
{
|
|
identity = group.Key.ToString(),
|
|
immunity = rows.Max(r => r.Item4),
|
|
flags = flags.Where(flag => flag.StartsWith($"@")).ToList(),
|
|
groups = flags.Where(flag => flag.StartsWith($"#")).ToList()
|
|
};
|
|
});
|
|
|
|
var options = new JsonSerializerOptions
|
|
{
|
|
WriteIndented = true,
|
|
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
|
|
};
|
|
|
|
var json = JsonSerializer.Serialize(jsonData, options);
|
|
var filePath = Path.Combine(CS2_SimpleAdmin.Instance.ModuleDirectory, "data", "admins.json");
|
|
await File.WriteAllTextAsync(filePath, json);
|
|
|
|
return validPlayers.Select(p => (p.steamId!, p.player.Item5, p.player.Item3)).ToList();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Deletes an admin by their SteamID from the database asynchronously.
|
|
/// </summary>
|
|
/// <param name="playerSteamId">The SteamID of the admin to delete.</param>
|
|
/// <param name="globalDelete">Whether to delete the admin globally or only for the current server.</param>
|
|
/// <param name="adminSteamId">zio: SteamID64 of who removed them, 0 for Console, for the rank log.</param>
|
|
/// <param name="adminName">zio: name of who removed them, for the rank log.</param>
|
|
public async Task DeleteAdminBySteamId(string playerSteamId, bool globalDelete = false, ulong adminSteamId = 0, string adminName = "Console")
|
|
{
|
|
if (databaseProvider == null) return;
|
|
if (string.IsNullOrEmpty(playerSteamId)) return;
|
|
|
|
try
|
|
{
|
|
await using var connection = await databaseProvider.CreateConnectionAsync();
|
|
// zio: note the rank and name before they're gone, for the rank log.
|
|
var before = await GetRank(connection, playerSteamId);
|
|
var playerName = await connection.ExecuteScalarAsync<string?>(
|
|
"SELECT player_name FROM sa_admins WHERE player_steamid = @playerSteamId ORDER BY server_id IS NULL DESC, id LIMIT 1",
|
|
new { playerSteamId }) ?? "";
|
|
var sql = databaseProvider.GetDeleteAdminQuery(globalDelete);
|
|
await connection.ExecuteAsync(sql, new { PlayerSteamID = playerSteamId, CS2_SimpleAdmin.ServerId });
|
|
await LogRankChange(connection, playerSteamId, playerName, before, await GetRank(connection, playerSteamId), adminSteamId, adminName);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
CS2_SimpleAdmin._logger?.LogError(ex.Message);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Adds a new admin with specified details asynchronously.
|
|
/// </summary>
|
|
/// <param name="playerSteamId">SteamID of the admin.</param>
|
|
/// <param name="playerName">Name of the admin.</param>
|
|
/// <param name="flagsList">List of flags assigned to the admin.</param>
|
|
/// <param name="immunity">Immunity level.</param>
|
|
/// <param name="time">Duration in minutes for admin expiration; 0 means permanent.</param>
|
|
/// <param name="globalAdmin">Whether the admin is global or server-specific.</param>
|
|
/// <param name="adminSteamId">zio: SteamID64 of who added them, 0 for Console, for the rank log.</param>
|
|
/// <param name="adminName">zio: name of who added them, for the rank log.</param>
|
|
public async Task AddAdminBySteamId(string playerSteamId, string playerName, List<string> flagsList, int immunity = 0, int time = 0, bool globalAdmin = false, ulong adminSteamId = 0, string adminName = "Console")
|
|
{
|
|
if (databaseProvider == null) return;
|
|
|
|
if (string.IsNullOrEmpty(playerSteamId) || flagsList.Count == 0) return;
|
|
|
|
var now = Time.ActualDateTime();
|
|
DateTime? futureTime = time != 0 ? now.AddMinutes(time) : null;
|
|
|
|
try
|
|
{
|
|
await using var connection = await databaseProvider.CreateConnectionAsync();
|
|
var before = await GetRank(connection, playerSteamId); // zio: for the rank log
|
|
|
|
var insertAdminSql = databaseProvider.GetAddAdminQuery();
|
|
var adminId = await connection.ExecuteScalarAsync<int>(insertAdminSql, new
|
|
{
|
|
playerSteamId,
|
|
playerName,
|
|
immunity,
|
|
ends = futureTime,
|
|
created = now,
|
|
serverid = globalAdmin ? null : CS2_SimpleAdmin.ServerId
|
|
});
|
|
|
|
foreach (var flag in flagsList)
|
|
{
|
|
var insertFlagsSql = databaseProvider.GetAddAdminFlagsQuery();
|
|
await connection.ExecuteAsync(insertFlagsSql, new
|
|
{
|
|
adminId,
|
|
flag
|
|
});
|
|
}
|
|
|
|
await LogRankChange(connection, playerSteamId, playerName, before, await GetRank(connection, playerSteamId), adminSteamId, adminName);
|
|
|
|
await Server.NextWorldUpdateAsync(() =>
|
|
{
|
|
CS2_SimpleAdmin.Instance.ReloadAdmins(null);
|
|
});
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
CS2_SimpleAdmin._logger?.LogError(ex.ToString());
|
|
}
|
|
}
|
|
|
|
// zio: rank history in sa_rank_log, a table owned by our web panel (simpleadmin-web's
|
|
// sql/rank-log.sql), which logs its own changes there too. A player's rank is their
|
|
// highest-immunity group; a change is only logged when that changes.
|
|
private sealed class RankRow
|
|
{
|
|
public string Name { get; set; } = "";
|
|
public int Immunity { get; set; }
|
|
}
|
|
|
|
private static bool _rankLogMissingWarned;
|
|
|
|
// zio: the player's current rank, or null. Only unexpired admin rows and groups that apply to
|
|
// this server count, the same set SimpleAdmin loads.
|
|
private static async Task<RankRow?> GetRank(System.Data.Common.DbConnection connection, string playerSteamId)
|
|
{
|
|
return await connection.QueryFirstOrDefaultAsync<RankRow>("""
|
|
SELECT g.name AS Name, g.immunity AS Immunity
|
|
FROM sa_admins a
|
|
JOIN sa_admins_flags f ON f.admin_id = a.id
|
|
JOIN sa_groups g ON g.name = f.flag
|
|
JOIN sa_groups_servers gs ON gs.group_id = g.id AND (gs.server_id IS NULL OR gs.server_id = @serverid)
|
|
WHERE a.player_steamid = @playerSteamId AND (a.ends IS NULL OR a.ends > @now)
|
|
AND (a.server_id IS NULL OR a.server_id = @serverid)
|
|
ORDER BY g.immunity DESC, g.id LIMIT 1
|
|
""", new { playerSteamId, now = Time.ActualDateTime(), serverid = CS2_SimpleAdmin.ServerId });
|
|
}
|
|
|
|
// zio: records a rank change in sa_rank_log. Never fails the admin change itself: if the table
|
|
// is missing (the panel hasn't created it), it warns once and carries on.
|
|
private static async Task LogRankChange(System.Data.Common.DbConnection connection, string playerSteamId, string playerName,
|
|
RankRow? before, RankRow? after, ulong adminSteamId, string adminName)
|
|
{
|
|
if (before?.Name == after?.Name) return;
|
|
var kind = before == null ? "added"
|
|
: after == null ? "removed"
|
|
: after.Immunity > before.Immunity ? "promoted"
|
|
: after.Immunity < before.Immunity ? "demoted"
|
|
: "changed";
|
|
try
|
|
{
|
|
await connection.ExecuteAsync("""
|
|
INSERT INTO sa_rank_log (player_steamid, player_name, old_rank, new_rank, kind, admin_steamid, admin_name, source, created)
|
|
VALUES (@playerSteamId, @playerName, @oldRank, @newRank, @kind, @adminSteamId, @adminName, 'game', @created)
|
|
""", new
|
|
{
|
|
playerSteamId = ulong.Parse(playerSteamId),
|
|
playerName,
|
|
oldRank = before?.Name,
|
|
newRank = after?.Name,
|
|
kind,
|
|
adminSteamId,
|
|
adminName,
|
|
created = Time.ActualDateTime()
|
|
});
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
if (_rankLogMissingWarned) return;
|
|
_rankLogMissingWarned = true;
|
|
CS2_SimpleAdmin._logger?.LogWarning($"Couldn't write sa_rank_log (the web panel creates it): {ex.Message}");
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Adds a new group with flags and immunity asynchronously.
|
|
/// </summary>
|
|
/// <param name="groupName">Name of the group.</param>
|
|
/// <param name="flagsList">List of flags assigned to the group.</param>
|
|
/// <param name="immunity">Immunity level of the group.</param>
|
|
/// <param name="globalGroup">Whether the group is global or server-specific.</param>
|
|
public async Task AddGroup(string groupName, List<string> flagsList, int immunity = 0, bool globalGroup = false)
|
|
{
|
|
if (databaseProvider == null) return;
|
|
|
|
if (string.IsNullOrEmpty(groupName) || flagsList.Count == 0) return;
|
|
|
|
try
|
|
{
|
|
await using var connection = await databaseProvider.CreateConnectionAsync();
|
|
|
|
var insertGroup = databaseProvider.GetAddGroupQuery();
|
|
var groupId = await connection.ExecuteScalarAsync<int>(insertGroup, new
|
|
{
|
|
groupName,
|
|
immunity
|
|
});
|
|
|
|
foreach (var flag in flagsList)
|
|
{
|
|
var insertFlagsSql = databaseProvider.GetAddGroupFlagsQuery();
|
|
await connection.ExecuteAsync(insertFlagsSql, new
|
|
{
|
|
groupId,
|
|
flag
|
|
});
|
|
}
|
|
|
|
var insertGroupServer = databaseProvider.GetAddGroupServerQuery();
|
|
await connection.ExecuteAsync(insertGroupServer, new { groupId, server_id = globalGroup ? null : CS2_SimpleAdmin.ServerId });
|
|
await Server.NextWorldUpdateAsync(() =>
|
|
{
|
|
CS2_SimpleAdmin.Instance.ReloadAdmins(null);
|
|
});
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
CS2_SimpleAdmin._logger?.LogError("Problem with loading admins: {exception}", ex.Message);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Deletes a group by name asynchronously.
|
|
/// </summary>
|
|
/// <param name="groupName">Name of the group to delete.</param>
|
|
public async Task DeleteGroup(string groupName)
|
|
{
|
|
if (databaseProvider == null) return;
|
|
|
|
if (string.IsNullOrEmpty(groupName)) return;
|
|
|
|
try
|
|
{
|
|
await using var connection = await databaseProvider.CreateConnectionAsync();
|
|
var sql = databaseProvider.GetDeleteGroupQuery();
|
|
await connection.ExecuteAsync(sql, new { groupName });
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
CS2_SimpleAdmin._logger?.LogError(ex.ToString());
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Deletes admins whose permissions have expired asynchronously.
|
|
/// </summary>
|
|
public async Task DeleteOldAdmins()
|
|
{
|
|
if (databaseProvider == null) return;
|
|
|
|
try
|
|
{
|
|
await using var connection = await databaseProvider.CreateConnectionAsync();
|
|
|
|
var sql = databaseProvider.GetDeleteOldAdminsQuery();
|
|
await connection.ExecuteAsync(sql, new { CurrentTime = Time.ActualDateTime() });
|
|
}
|
|
catch (Exception)
|
|
{
|
|
CS2_SimpleAdmin._logger?.LogCritical("Unable to remove expired admins");
|
|
}
|
|
}
|
|
}
|