GroupVerify 0.2.0: Steam group / override verification with an API for other plugins

Players are verified when they're in the configured Steam group (public memberslistxml,
refetched every 10 minutes, kept on failure) or have an unexpired override row in
gv_verified, written by the web panel. Settings and data live in CS2-SimpleAdmin's MySQL
database (gv_verified, gv_settings, gv_migrations), reached through its API.

- css_gv_group <groupId64|0>, css_gv_enable, css_gv_disable (@css/root): verification runs
  only when enabled with a group set.
- css_verify <target>: why a player is or isn't verified. Admin and above (by immunity from
  SimpleAdmin's groups) plus Guardian, unless admin_overrides.json says otherwise.
- groupverify:api (shared/GroupVerifyApi): IsVerified, GetStatus, IsActive and
  OnVerifiedChanged. The API object lives in the shared assembly, so it survives hot
  reloads of GroupVerify.
This commit is contained in:
Astra 2026-10-02 15:37:47 +01:00
commit 1f61958099
13 changed files with 1711 additions and 0 deletions

215
Database.cs Normal file
View file

@ -0,0 +1,215 @@
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.";
private const int UpsertBatch = 500;
// 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();
}
// Saves a fetched group list: upserts a 'group' row for every member (keeping created_at), then
// deletes the 'group' rows of anyone no longer in it. One transaction, so a failure keeps the
// previous list whole.
public async Task SaveGroupAsync(IReadOnlyCollection<ulong> members, DateTime now)
{
await using var connection = await OpenAsync();
await using var transaction = await connection.BeginTransactionAsync();
foreach (var batch in members.Chunk(UpsertBatch))
{
var rows = string.Join(", ", batch.Select((_, i) => $"(@s{i}, 'group', 0, NULL, 'steam-group', @now, @now)"));
var parameters = batch.Select((id, i) => ($"@s{i}", (object?)(long)id)).Append(("@now", now)).ToArray();
await using var command = Command(connection, $"""
INSERT INTO gv_verified (steamid64, source, `override`, expires_at, added_by, created_at, updated_at)
VALUES {rows}
ON DUPLICATE KEY UPDATE updated_at = @now
""", parameters);
command.Transaction = transaction;
await command.ExecuteNonQueryAsync();
}
await using (var command = Command(connection,
"DELETE FROM gv_verified WHERE source = 'group' AND updated_at < @now", ("@now", now)))
{
command.Transaction = transaction;
await command.ExecuteNonQueryAsync();
}
await transaction.CommitAsync();
}
// 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);
}
}