GroupVerify/Database.cs
Astra 10e5fe9f3c GroupVerify 0.3.0: check players with the Steam Web API as they join, clan tag for other plugins
Steam rate-limits its community member lists by IP (429), so the whole-list download is gone.
Each player is checked as they join with ISteamUser/GetUserGroupList: a member's 'group' row is
added or refreshed, a non-member's deleted, and when Steam won't say (private profile, bad key,
outage) the database stands. A player is asked about at most once per
min_forced_refresh_seconds; css_verify rechecks its targets, offline SteamIDs too. Changing the
group clears the old group's rows. refresh_minutes is gone.

The Web API key goes in configs/plugins/GroupVerify/GroupVerify.json (SteamWebApiKey); every
other setting stays in the database.

New clan_tag setting and css_gv_clantag <tag|-> (@css/root): the scoreboard tag for verified
players without a rank, applied by CS2-Tags. The API gains ClanTag and OnClanTagChanged, so
shared/GroupVerifyApi changes: needs a server restart.
2026-10-02 18:42:41 +01:00

207 lines
9.7 KiB
C#

using MySqlConnector;
namespace GroupVerify;
// Everything in CS2-SimpleAdmin's MySQL database: our gv_* tables, and its admin and group tables
// (read only). Every method opens its own connection and runs off the game thread.
internal sealed class Database(string connectionString)
{
private const string MigrationPrefix = "GroupVerify.Migrations.";
// What a reload hands back to the game thread.
public sealed record Snapshot(
Settings Settings,
Dictionary<ulong, DateTime?> Overrides,
HashSet<ulong> GroupRows,
int Expired);
private async Task<MySqlConnection> OpenAsync()
{
var connection = new MySqlConnection(connectionString);
await connection.OpenAsync();
return connection;
}
private static MySqlCommand Command(MySqlConnection connection, string sql, params (string Name, object? Value)[] parameters)
{
var command = new MySqlCommand(sql, connection);
foreach (var (name, value) in parameters)
command.Parameters.AddWithValue(name, value ?? DBNull.Value);
return command;
}
// Applies the embedded Migrations/*.sql in name order, each once, the way SimpleAdmin's
// Migration class does with sa_migrations. Returns the versions applied.
public async Task<List<string>> MigrateAsync()
{
await using var connection = await OpenAsync();
await using (var create = Command(connection, """
CREATE TABLE IF NOT EXISTS `gv_migrations` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`version` varchar(128) NOT NULL,
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
"""))
await create.ExecuteNonQueryAsync();
string last;
await using (var query = Command(connection, "SELECT version FROM gv_migrations ORDER BY id DESC LIMIT 1"))
last = (await query.ExecuteScalarAsync())?.ToString() ?? "";
var assembly = typeof(Database).Assembly;
var applied = new List<string>();
foreach (var resource in assembly.GetManifestResourceNames()
.Where(n => n.StartsWith(MigrationPrefix) && n.EndsWith(".sql"))
.Order(StringComparer.Ordinal))
{
var version = resource[MigrationPrefix.Length..^".sql".Length];
if (string.CompareOrdinal(version, last) <= 0)
continue;
using var reader = new StreamReader(assembly.GetManifestResourceStream(resource)!);
await using (var migrate = Command(connection, await reader.ReadToEndAsync()))
await migrate.ExecuteNonQueryAsync();
await using (var record = Command(connection, "INSERT INTO gv_migrations (version) VALUES (@version)", ("@version", version)))
await record.ExecuteNonQueryAsync();
applied.Add(version);
}
return applied;
}
// Rereads settings and overrides, deletes expired rows, and reads the last saved group list.
public async Task<Snapshot> LoadAsync(Action<string> warn)
{
await using var connection = await OpenAsync();
// Missing settings get their defaults, so new ones show up in the table to be edited.
var insert = "INSERT IGNORE INTO gv_settings (name, value) VALUES " +
string.Join(", ", Settings.Defaults.Select((_, i) => $"(@n{i}, @v{i})"));
var defaults = Settings.Defaults
.SelectMany((d, i) => new (string, object?)[] { ($"@n{i}", d.Name), ($"@v{i}", d.Value) })
.ToArray();
await using (var command = Command(connection, insert, defaults))
await command.ExecuteNonQueryAsync();
var values = new Dictionary<string, string>();
await using (var command = Command(connection, "SELECT name, value FROM gv_settings"))
await using (var reader = await command.ExecuteReaderAsync())
{
while (await reader.ReadAsync())
values[reader.GetString(0)] = reader.GetString(1);
}
var settings = Settings.Parse(values, warn);
var now = settings.Now();
int expired;
await using (var command = Command(connection,
"DELETE FROM gv_verified WHERE expires_at IS NOT NULL AND expires_at <= @now", ("@now", now)))
expired = await command.ExecuteNonQueryAsync();
// A player with several override rows keeps the latest end; NULL (never) beats any date.
var overrides = new Dictionary<ulong, DateTime?>();
await using (var command = Command(connection, """
SELECT steamid64, expires_at FROM gv_verified
WHERE `override` = 1 AND (expires_at IS NULL OR expires_at > @now)
""", ("@now", now)))
await using (var reader = await command.ExecuteReaderAsync())
{
while (await reader.ReadAsync())
{
var steamId = (ulong)reader.GetInt64(0);
DateTime? expires = reader.IsDBNull(1) ? null : reader.GetDateTime(1);
if (!overrides.TryGetValue(steamId, out var current))
overrides[steamId] = expires;
else if (current != null && (expires == null || expires > current))
overrides[steamId] = expires;
}
}
var groupRows = new HashSet<ulong>();
await using (var command = Command(connection, "SELECT steamid64 FROM gv_verified WHERE source = 'group'"))
await using (var reader = await command.ExecuteReaderAsync())
{
while (await reader.ReadAsync())
groupRows.Add((ulong)reader.GetInt64(0));
}
return new Snapshot(settings, overrides, groupRows, expired);
}
public async Task SetSettingAsync(string name, string value)
{
await using var connection = await OpenAsync();
await using var command = Command(connection, """
INSERT INTO gv_settings (name, value) VALUES (@name, @value)
ON DUPLICATE KEY UPDATE value = @value
""", ("@name", name), ("@value", value));
await command.ExecuteNonQueryAsync();
}
// Records one player's group membership from a Steam check: a member gets or refreshes their
// 'group' row (keeping created_at, the first time they were seen in it); anyone else loses it.
public async Task SetGroupMemberAsync(ulong steamId, bool member, DateTime now)
{
await using var connection = await OpenAsync();
var sql = member
? """
INSERT INTO gv_verified (steamid64, source, `override`, expires_at, added_by, created_at, updated_at)
VALUES (@steamid, 'group', 0, NULL, 'steam-group', @now, @now)
ON DUPLICATE KEY UPDATE updated_at = @now
"""
: "DELETE FROM gv_verified WHERE steamid64 = @steamid AND source = 'group'";
await using var command = Command(connection, sql, ("@steamid", (long)steamId), ("@now", now));
await command.ExecuteNonQueryAsync();
}
// Forgets every 'group' row, when the group changes: they belong to the old one.
public async Task ClearGroupRowsAsync()
{
await using var connection = await OpenAsync();
await using var command = Command(connection, "DELETE FROM gv_verified WHERE source = 'group'");
await command.ExecuteNonQueryAsync();
}
// Whether a player may use css_verify: one of their SimpleAdmin groups has at least minRank's
// immunity, or is one of extraRanks. Groups are read the way our SimpleAdmin fork's
// PermissionManager.GetRank reads ranks: an unexpired sa_admins row for this server whose
// sa_admins_flags entry names an sa_groups row that applies to this server. Problem is set when
// minRank isn't in sa_groups (then only extraRanks pass).
public async Task<(bool Allowed, string? Problem)> CanVerifyAsync(ulong steamId, string minRank, string[] extraRanks,
int? serverId, DateTime now)
{
await using var connection = await OpenAsync();
int? minImmunity = null;
await using (var command = Command(connection, "SELECT immunity FROM sa_groups WHERE name = @name ORDER BY immunity LIMIT 1",
("@name", minRank)))
{
var result = await command.ExecuteScalarAsync();
if (result != null && result != DBNull.Value)
minImmunity = Convert.ToInt32(result);
}
var allowed = false;
await using (var command = Command(connection, """
SELECT DISTINCT g.name, g.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 = @steamid AND (a.ends IS NULL OR a.ends > @now)
AND (a.server_id IS NULL OR a.server_id = @serverid)
""", ("@steamid", (long)steamId), ("@serverid", serverId), ("@now", now)))
await using (var reader = await command.ExecuteReaderAsync())
{
while (await reader.ReadAsync())
{
var name = reader.GetString(0);
var immunity = reader.GetInt32(1);
if ((minImmunity != null && immunity >= minImmunity) ||
extraRanks.Contains(name, StringComparer.OrdinalIgnoreCase))
allowed = true;
}
}
return (allowed, minImmunity == null ? $"verify_min_rank \"{minRank}\" isn't a group in sa_groups" : null);
}
}