diff --git a/Config.cs b/Config.cs new file mode 100644 index 0000000..48f9830 --- /dev/null +++ b/Config.cs @@ -0,0 +1,15 @@ +using System.Text.Json.Serialization; +using CounterStrikeSharp.API.Core; + +namespace GroupVerify; + +// configs/plugins/GroupVerify/GroupVerify.json, which CSS writes with these defaults on first load. +// Only the Steam Web API key lives here, since it's a secret; every other setting is in the +// database (gv_settings). Changes need the plugin reloaded: css_plugins reload GroupVerify. +public class GroupVerifyConfig : BasePluginConfig +{ + // From https://steamcommunity.com/dev/apikey. Without it, nobody's group membership is + // checked, and only what's already in the database counts. + [JsonPropertyName("SteamWebApiKey")] + public string SteamWebApiKey { get; set; } = ""; +} diff --git a/Database.cs b/Database.cs index 575c19f..c729101 100644 --- a/Database.cs +++ b/Database.cs @@ -7,7 +7,6 @@ namespace GroupVerify; 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( @@ -138,35 +137,28 @@ internal sealed class Database(string connectionString) 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 members, DateTime now) + // 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(); - await using var transaction = await connection.BeginTransactionAsync(); + 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(); + } - 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(); + // 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 diff --git a/GroupVerify.cs b/GroupVerify.cs index 0797478..92b1c18 100644 --- a/GroupVerify.cs +++ b/GroupVerify.cs @@ -17,14 +17,15 @@ namespace GroupVerify; // Marks players as verified when they're in a Steam group, or have a manual override in the // gv_verified table (written by the web panel), and tells other plugins through GroupVerifyApi. -// Everything is kept in CS2-SimpleAdmin's database, over its connection string. +// Group membership is checked per player with the Steam Web API when they join, and kept in +// CS2-SimpleAdmin's database, over its connection string, like everything except the API key. // // Threads: every field below is only touched on the game thread. HTTP and database work runs in // Task.Run and hands its result back with Server.NextFrame(Async). -public class GroupVerify : BasePlugin, IGroupVerifyBackend +public class GroupVerify : BasePlugin, IGroupVerifyBackend, IPluginConfig { public override string ModuleName => "GroupVerify"; - public override string ModuleVersion => "0.2.0"; + public override string ModuleVersion => "0.3.0"; public override string ModuleAuthor => "astra"; public override string ModuleDescription => "Verifies players by Steam group membership or a manual override"; @@ -46,12 +47,21 @@ public class GroupVerify : BasePlugin, IGroupVerifyBackend // doesn't put the old value back. private int _settingsVersion; - // The group's members. Seeded from gv_verified's 'group' rows at load, so a Steam outage at - // startup doesn't unverify everyone, then replaced by each successful fetch. - private HashSet _groupMembers = []; - private DateTime? _groupFetchedAt; // UTC, the last successful fetch this session - private DateTime? _groupAttemptAt; // UTC, the last fetch started - private Task? _groupRefresh; // in flight, shared by everyone who asks + public GroupVerifyConfig Config { get; set; } = new(); + + // Group members as of the database: gv_verified's 'group' rows, reread with every reload. + private HashSet _groupRows = []; + // This session's Steam checks, which win over the database until the next restart: SteamID64 -> + // whether they're a member. Players Steam wouldn't answer for aren't in here. + private readonly Dictionary _membership = []; + // SteamID64 -> when they were last asked about (UTC), answered or not, so rejoining or + // css_verify doesn't ask more often than min_forced_refresh_seconds. + private readonly Dictionary _checkedAt = []; + // SteamID64 -> why Steam couldn't say, for css_verify. + private readonly Dictionary _checkProblem = []; + // Checks in flight, shared by everyone who asks about the same player. + private readonly Dictionary _checks = []; + private bool _noKeyWarned; // SteamID64 -> override end (null: never), from gv_verified. private Dictionary _overrides = []; @@ -63,7 +73,6 @@ public class GroupVerify : BasePlugin, IGroupVerifyBackend // only about changes. private readonly Dictionary _evaluated = []; - private Timer? _groupTimer; private Timer? _dbTimer; // A css_verify target: an online player (Slot set) or a SteamID looked up offline. @@ -71,6 +80,12 @@ public class GroupVerify : BasePlugin, IGroupVerifyBackend private sealed record Caller(int Slot, ulong SteamId, string Name); + public void OnConfigParsed(GroupVerifyConfig config) + { + config.SteamWebApiKey = config.SteamWebApiKey.Trim(); + Config = config; + } + public override void Load(bool hotReload) { RegisterEventHandler(OnPlayerConnectFull); @@ -142,7 +157,6 @@ public class GroupVerify : BasePlugin, IGroupVerifyBackend { GroupVerifyBridge.Instance.Detach(this); _unloading.Cancel(); - _groupTimer?.Kill(); _dbTimer?.Kill(); _evaluated.Clear(); } @@ -151,11 +165,13 @@ public class GroupVerify : BasePlugin, IGroupVerifyBackend public bool IsActive => _ready && _settings.Active; + public string ClanTag => _settings.ClanTag; + public VerifyStatus GetStatus(ulong steamId64) { if (!IsActive) return default; - var inGroup = _groupMembers.Contains(steamId64); + var inGroup = _membership.TryGetValue(steamId64, out var member) ? member : _groupRows.Contains(steamId64); if (!_overrides.TryGetValue(steamId64, out var ends)) return new VerifyStatus(inGroup, false, null); // Overrides can run out between reloads. @@ -196,31 +212,46 @@ public class GroupVerify : BasePlugin, IGroupVerifyBackend if (snapshot == null) return; _overrides = snapshot.Overrides; + _groupRows = snapshot.GroupRows; _dbLoadedAt = DateTime.UtcNow; if (snapshot.Expired > 0) Logger.LogInformation("Removed {Count} expired gv_verified rows", snapshot.Expired); - ApplySettings(version == _settingsVersion ? snapshot.Settings : _settings, snapshot.GroupRows); + ApplySettings(version == _settingsVersion ? snapshot.Settings : _settings); }); }); } - // Switches to new settings (or just re-evaluates, if they're the same). savedGroupRows is the - // group list saved in the database, used only at the first load. - private void ApplySettings(Settings next, HashSet? savedGroupRows = null) + // Switches to new settings (or just re-evaluates, if they're the same). + private void ApplySettings(Settings next) { var old = _settings; var first = !_ready; _settings = next; _ready = true; - var groupChanged = old.GroupId64 != next.GroupId64; - if (first || groupChanged) + var groupChanged = !first && old.GroupId64 != next.GroupId64; + if (groupChanged) { - // At load, the saved rows stand in for the list until the first fetch below. They're - // whichever group was last fetched, so after a change of group they're not used. - _groupMembers = first && savedGroupRows != null ? savedGroupRows : []; - _groupFetchedAt = null; - _groupAttemptAt = null; + // Everything known so far is about the old group. + _groupRows = []; + _membership.Clear(); + _checkedAt.Clear(); + _checkProblem.Clear(); + var db = _db; + if (db != null) + { + Task.Run(async () => + { + try + { + await db.ClearGroupRowsAsync(); + } + catch (Exception ex) + { + Logger.LogError("Couldn't clear the old group's rows from gv_verified: {Error}", ex.Message); + } + }); + } } if (first || old.Active != next.Active) @@ -232,69 +263,92 @@ public class GroupVerify : BasePlugin, IGroupVerifyBackend next.Enabled ? "no Steam group set, css_gv_group" : "disabled, css_gv_enable"); } - if (first || old.RefreshMinutes != next.RefreshMinutes || old.DbReloadSeconds != next.DbReloadSeconds) - RestartTimers(); + if (first || old.DbReloadSeconds != next.DbReloadSeconds) + { + _dbTimer?.Kill(); + _dbTimer = AddTimer(_settings.DbReloadSeconds, () => ReloadDatabase(), TimerFlags.REPEAT); + } EvaluateAll(); + if ((first ? "" : old.EffectiveClanTag) != next.EffectiveClanTag) + { + Logger.LogInformation("Clan tag for verified players: {Tag}", next.EffectiveClanTag.Length > 0 ? next.EffectiveClanTag : "(none)"); + foreach (var ex in GroupVerifyBridge.Instance.RaiseClanTagChanged()) + Logger.LogError(ex, "An OnClanTagChanged handler threw"); + } + + // Ask Steam about everyone on the server when there's something new to ask about. if (next.Active && (first || groupChanged || !old.Active)) - RefreshGroup(); + CheckAll(); } - private void RestartTimers() + private void CheckAll() { - _groupTimer?.Kill(); - _dbTimer?.Kill(); - _groupTimer = AddTimer(_settings.RefreshMinutes * 60f, () => RefreshGroup(), TimerFlags.REPEAT); - _dbTimer = AddTimer(_settings.DbReloadSeconds, () => ReloadDatabase(), TimerFlags.REPEAT); + foreach (var player in Utilities.GetPlayers()) + { + if (player is { IsValid: true, IsBot: false, IsHLTV: false } && player.AuthorizedSteamID is { } id) + CheckMembership(id.SteamId64); + } } - // Fetches the group's member list and saves it to gv_verified. A failed or empty fetch keeps the - // old list. Does nothing while verification is off. Concurrent calls share one. - private Task RefreshGroup() + // Asks Steam whether the player is in the group, then records the answer in gv_verified (a + // member's row is added or refreshed, a non-member's removed) and re-evaluates them. When Steam + // won't say, nothing changes and what the database has stands. Skipped while verification is + // off, without an API key, or if they were asked about within min_forced_refresh_seconds. + private Task CheckMembership(ulong steamId) { if (_db == null || !IsActive) return Task.CompletedTask; - if (_groupRefresh != null) - return _groupRefresh; + if (Config.SteamWebApiKey.Length == 0) + { + if (!_noKeyWarned) + Logger.LogWarning("No SteamWebApiKey in configs/plugins/GroupVerify/GroupVerify.json, so nobody's group membership is checked"); + _noKeyWarned = true; + return Task.CompletedTask; + } + if (_checks.TryGetValue(steamId, out var running)) + return running; + if (_checkedAt.TryGetValue(steamId, out var at) && + DateTime.UtcNow - at < TimeSpan.FromSeconds(_settings.MinForcedRefreshSeconds)) + return Task.CompletedTask; var db = _db; var settings = _settings; - var groupId = settings.GroupId64; + var key = Config.SteamWebApiKey; var cancel = _unloading.Token; - _groupAttemptAt = DateTime.UtcNow; - return _groupRefresh = Task.Run(async () => + _checkedAt[steamId] = DateTime.UtcNow; + var task = Task.Run(async () => { - HashSet? members = null; + var (result, problem) = (Membership.Unknown, (string?)null); try { - members = await SteamGroup.FetchMembersAsync(groupId, cancel); - if (members.Count == 0) - { - Logger.LogWarning("Steam group {Group}'s member list came back empty, keeping the old one", groupId); - members = null; - } + (result, problem) = await SteamApi.CheckAsync(key, steamId, settings.GroupId64, cancel); } - catch (Exception ex) when (!cancel.IsCancellationRequested) - { - Logger.LogWarning("Couldn't fetch Steam group {Group}'s member list, keeping the old one: {Error}", - groupId, ex.Message); - } - catch (OperationCanceledException) + catch (OperationCanceledException) when (cancel.IsCancellationRequested) { return; } + catch (Exception ex) + { + // Only the type: messages could quote the request, key and all. + (result, problem) = (Membership.Unknown, $"unexpected {ex.GetType().Name}"); + } - if (members != null) + if (result == Membership.Unknown) + { + Logger.LogWarning("Couldn't check {SteamId} with Steam, keeping what the database has: {Problem}", steamId, problem); + } + else { try { - await db.SaveGroupAsync(members, settings.Now()); + await db.SetGroupMemberAsync(steamId, result == Membership.Member, settings.Now()); } catch (Exception ex) { - // The fetched list is still used; it just won't survive a restart. - Logger.LogError("Couldn't save the Steam group list to gv_verified: {Error}", ex.Message); + // The answer still counts until a restart; it just isn't saved. + Logger.LogError("Couldn't save {SteamId}'s group membership to gv_verified: {Error}", steamId, ex.Message); } } @@ -302,15 +356,26 @@ public class GroupVerify : BasePlugin, IGroupVerifyBackend return; await Server.NextFrameAsync(() => { - _groupRefresh = null; - // Dropped if the group changed or verification was turned off while fetching. - if (members == null || groupId != _settings.GroupId64 || !IsActive) + _checks.Remove(steamId); + // Dropped if the group changed or verification was turned off while asking. + if (settings.GroupId64 != _settings.GroupId64 || !IsActive) return; - _groupMembers = members; - _groupFetchedAt = DateTime.UtcNow; - EvaluateAll(); + if (result == Membership.Unknown) + { + _checkProblem[steamId] = problem ?? "no reason given"; + return; + } + _checkProblem.Remove(steamId); + _membership[steamId] = result == Membership.Member; + foreach (var player in Utilities.GetPlayers()) + { + if (player.AuthorizedSteamID?.SteamId64 == steamId) + Evaluate(player); + } }); }); + _checks[steamId] = task; + return task; } // --- Verification (game thread) ---------------------------------------------------------- @@ -355,9 +420,13 @@ public class GroupVerify : BasePlugin, IGroupVerifyBackend return HookResult.Continue; } + // Evaluated at once from what the database knows, then checked with Steam, which may change it. private void OnClientAuthorized(int playerSlot, SteamID steamId) { - Evaluate(Utilities.GetPlayerFromSlot(playerSlot)); + var player = Utilities.GetPlayerFromSlot(playerSlot); + Evaluate(player); + if (player is { IsValid: true, IsBot: false, IsHLTV: false }) + CheckMembership(steamId.SteamId64); } private void OnClientDisconnect(int playerSlot) @@ -442,18 +511,15 @@ public class GroupVerify : BasePlugin, IGroupVerifyBackend }); } - // Brings the group list and database up to date if they're older than + // Rechecks each target with Steam and rereads the database, where those are older than // min_forced_refresh_seconds, then answers. private void Run(Caller? caller, string commandString, List targets) { LogCommand(caller, commandString); var maxAge = TimeSpan.FromSeconds(_settings.MinForcedRefreshSeconds); - var now = DateTime.UtcNow; - var pending = new List(); - if (_groupAttemptAt == null || now - _groupAttemptAt > maxAge) - pending.Add(RefreshGroup()); - if (_dbAttemptAt == null || now - _dbAttemptAt > maxAge) + var pending = targets.Select(t => CheckMembership(t.SteamId)).ToList(); + if (_dbAttemptAt == null || DateTime.UtcNow - _dbAttemptAt > maxAge) pending.Add(ReloadDatabase()); if (pending.All(t => t.IsCompleted)) @@ -493,7 +559,10 @@ public class GroupVerify : BasePlugin, IGroupVerifyBackend { var name = online?.PlayerName ?? target.Name; var where = online == null ? " (offline)" : ""; - lines.Add($"{ChatColors.Gold}{name}{ChatColors.Default} [{target.SteamId}]{where}: {Describe(status)}"); + var unchecked_ = _checkProblem.TryGetValue(target.SteamId, out var problem) + ? $" {ChatColors.Grey}(couldn't check with Steam: {problem}; using the database){ChatColors.Default}" + : ""; + lines.Add($"{ChatColors.Gold}{name}{ChatColors.Default} [{target.SteamId}]{where}: {Describe(status)}{unchecked_}"); } } @@ -531,11 +600,9 @@ public class GroupVerify : BasePlugin, IGroupVerifyBackend if (!_settings.Active) return DescribeState(); - string group; - if (_groupFetchedAt is { } fetched) - group = $"group list {_groupMembers.Count} members, {FormatSpan(DateTime.UtcNow - fetched)} old"; - else - group = $"group list {_groupMembers.Count} members from the database (Steam not reached yet)"; + var group = Config.SteamWebApiKey.Length == 0 + ? "no Steam Web API key (GroupVerify.json), so group membership isn't being checked" + : $"group membership checked with Steam on join; {_groupRows.Count} known members"; var db = _dbLoadedAt is { } loaded ? $"overrides {FormatSpan(DateTime.UtcNow - loaded)} old" : "overrides not loaded"; return $"{group}; {db}."; @@ -558,7 +625,10 @@ public class GroupVerify : BasePlugin, IGroupVerifyBackend private string DescribeState() { if (_settings.Active) - return $"{ChatColors.Green}On{ChatColors.Default}, Steam group {_settings.GroupId64}."; + { + var tag = _settings.ClanTag.Length > 0 ? $"clan tag \"{_settings.ClanTag}\"" : "no clan tag"; + return $"{ChatColors.Green}On{ChatColors.Default}, Steam group {_settings.GroupId64}, {tag}."; + } if (!_settings.Enabled) { var group = _settings.GroupId64 == 0 ? "no Steam group set" : $"Steam group {_settings.GroupId64}"; @@ -649,6 +719,29 @@ public class GroupVerify : BasePlugin, IGroupVerifyBackend ChangeSetting(player, command, "group_id64", groupId.ToString(), s => s with { GroupId64 = groupId }); } + [ConsoleCommand("css_gv_clantag", "Shows or sets the scoreboard tag verified players without a rank get; - clears it")] + [CommandHelper(usage: "[tag|-]")] + [RequiresPermissions(SettingsPermission)] + public void OnClanTag(CCSPlayerController? player, CommandInfo command) + { + if (command.ArgCount < 2) + { + var current = _settings.ClanTag.Length > 0 ? $"\"{_settings.ClanTag}\"" : "not set"; + command.ReplyToCommand($"[Verify] Clan tag for verified players: {current}. {StripColors(DescribeState())}"); + return; + } + + var tag = command.ArgString.Trim().Trim('"'); + if (tag == "-") + tag = ""; + if (!Settings.ValidClanTag(tag)) + { + command.ReplyToCommand($"[Verify] A clan tag is at most {Settings.ClanTagMaxLength} characters, without quotes, ; or control characters. - clears it."); + return; + } + ChangeSetting(player, command, "clan_tag", tag, s => s with { ClanTag = tag }); + } + private void ChangeSetting(CCSPlayerController? player, CommandInfo command, string name, string value, Func change) { diff --git a/GroupVerifyApi/GroupVerifyBridge.cs b/GroupVerifyApi/GroupVerifyBridge.cs index 7458536..db34da1 100644 --- a/GroupVerifyApi/GroupVerifyBridge.cs +++ b/GroupVerifyApi/GroupVerifyBridge.cs @@ -12,6 +12,9 @@ public interface IGroupVerifyBackend /// See . VerifyStatus GetStatus(ulong steamId64); + + /// The configured clan tag; the bridge blanks it while inactive. + string ClanTag { get; } } /// @@ -34,9 +37,15 @@ public sealed class GroupVerifyBridge : IGroupVerifyApi /// public event Action? OnVerifiedChanged; + /// + public event Action? OnClanTagChanged; + /// public bool IsActive => _backend?.IsActive ?? false; + /// + public string ClanTag => _backend is { IsActive: true } backend ? backend.ClanTag : ""; + /// public bool IsVerified(CCSPlayerController player) { @@ -56,11 +65,36 @@ public sealed class GroupVerifyBridge : IGroupVerifyApi /// Called by GroupVerify on load. public void Attach(IGroupVerifyBackend backend) => _backend = backend; - /// Called by GroupVerify on unload; ignored if a newer backend has attached since. + /// + /// Called by GroupVerify on unload; ignored if a newer backend has attached since. Tells + /// subscribers the clan tag is gone, if there was one. + /// public void Detach(IGroupVerifyBackend backend) { - if (ReferenceEquals(_backend, backend)) - _backend = null; + if (!ReferenceEquals(_backend, backend)) + return; + var had = ClanTag; + _backend = null; + if (had.Length > 0) + RaiseClanTagChanged(); + } + + /// Called by GroupVerify on the game thread; returns handlers' exceptions, like RaiseVerifiedChanged. + public List RaiseClanTagChanged() + { + var errors = new List(); + foreach (var handler in OnClanTagChanged?.GetInvocationList() ?? []) + { + try + { + ((Action)handler)(); + } + catch (Exception ex) + { + errors.Add(ex); + } + } + return errors; } /// diff --git a/GroupVerifyApi/IGroupVerifyApi.cs b/GroupVerifyApi/IGroupVerifyApi.cs index 142af35..d5bb316 100644 --- a/GroupVerifyApi/IGroupVerifyApi.cs +++ b/GroupVerifyApi/IGroupVerifyApi.cs @@ -43,6 +43,19 @@ public interface IGroupVerifyApi /// so handlers should be idempotent. /// event Action? OnVerifiedChanged; + + /// + /// The scoreboard clan tag for verified players who have no rank tag of their own (gv_settings + /// clan_tag, set with css_gv_clantag or the web panel), up to 12 characters, without brackets. + /// Empty when none is set, and while is false. + /// + string ClanTag { get; } + + /// + /// Fired on the game thread when changes, including to or from empty as + /// GroupVerify is turned on or off, loaded or unloaded. Re-tag everyone when it fires. + /// + event Action? OnClanTagChanged; } /// A player's verification, and why. diff --git a/Migrations/001_CreateTables.sql b/Migrations/001_CreateTables.sql index 5a0a68a..5742a5f 100644 --- a/Migrations/001_CreateTables.sql +++ b/Migrations/001_CreateTables.sql @@ -2,12 +2,12 @@ -- gv_migrations (like SimpleAdmin's sa_migrations, kept apart so neither confuses the other). -- Times are wall-clock values in gv_settings' timezone, which should match SimpleAdmin's Timezone. --- One row per player and source. A player counts as verified if they're in the Steam group's --- member list, or have a row with `override` = 1 whose expires_at is NULL (never) or still ahead. --- source 'group': written by the plugin for everyone in the group list, `override` 0. Removed --- when they leave the group. The live list decides, not these rows. +-- One row per player and source. A player counts as verified if they have a 'group' row, or a +-- row with `override` = 1 whose expires_at is NULL (never) or still ahead. +-- source 'group': written by the plugin when Steam's Web API says they're in the group (checked +-- as they join), `override` 0. Deleted when Steam says they aren't. -- source 'manual': written by the web panel, `override` 1, expires_at NULL or a date. --- updated_at is when the plugin last saw a group row in the list. +-- updated_at is when Steam last confirmed a group row. CREATE TABLE IF NOT EXISTS `gv_verified` ( `steamid64` bigint(20) NOT NULL, `source` enum('group','manual') NOT NULL, diff --git a/README.md b/README.md index b32e772..c0ddd8f 100644 --- a/README.md +++ b/README.md @@ -4,9 +4,21 @@ Marks players as **verified** when they're in our Steam group, or when staff hav manual override on the web panel. It gives out nothing itself: other plugins ask it through its API (`GroupVerifyApi`, see [For other plugins](#for-other-plugins)) and hand out their own perks. -Everything lives in CS2-SimpleAdmin's MySQL database, reached through SimpleAdmin's API -(`GetConnectionString()`), so there's no config file. Needs CS2-SimpleAdmin on MySQL; on SQLite, -or without SimpleAdmin, it logs an error and verifies nobody. +Its data and settings live in CS2-SimpleAdmin's MySQL database, reached through SimpleAdmin's API +(`GetConnectionString()`). Needs CS2-SimpleAdmin on MySQL; on SQLite, or without SimpleAdmin, it +logs an error and verifies nobody. The one exception is the Steam Web API key, a secret, which goes +in `configs/plugins/GroupVerify/GroupVerify.json`: + +```json +{ + "SteamWebApiKey": "", + "ConfigVersion": 1 +} +``` + +CSS writes that file with a blank key on first load. Fill it in, then `css_plugins reload +GroupVerify` (or restart). Without a key nobody's group membership is checked: players already +saved as members stay verified, overrides still work, and the log says so once. ## Turning it on @@ -15,17 +27,19 @@ verified, including players with an override, and other plugins see `IsActive == install is enabled with no group, so it's off until you set one: ``` -css_gv_group 103582791429521408 set the group (its 64-bit ID) and fetch its members now +css_gv_group 103582791429521408 set the group (its 64-bit ID) and check everyone online css_gv_group 0 clear the group: verification is off -css_gv_group show whether it's on, the group, and how old the list is +css_gv_group show whether it's on and the group css_gv_disable turn it off, keeping the group css_gv_enable turn it back on +css_gv_clantag zio the scoreboard tag for verified players without a rank (- clears it) ``` These need `@css/root` (an `admin_overrides.json` entry for a command changes that) and work from the server console and RCON. Changes are saved to `gv_settings` and take effect at once: turning it off tells other plugins every verified player stopped being verified, and turning it on (or -changing the group) fetches the member list straight away. +changing the group) checks everyone on the server with Steam straight away. Changing the group +deletes the saved members of the old one. The group ID is the number in `` at `https://steamcommunity.com/groups//memberslistxml/?xml=1`. `css_gv_group` refuses anything @@ -33,18 +47,26 @@ that isn't a Steam group ID. ## How it works -- **Steam group.** The group's public member list - (`https://steamcommunity.com/gid//memberslistxml/?xml=1`, every `nextPageLink` page) - is fetched off the game thread every `refresh_minutes`. A failed or empty fetch keeps the old - list. Each good fetch is saved to `gv_verified` as `source = 'group'` rows, and the rows of - anyone who left are deleted; at startup those rows stand in for the list until Steam answers. +- **Steam group, checked per player as they join.** Once Steam has authorized a player, they're + verified straight away from what the database knows, then GroupVerify asks the Steam Web API + which groups they're in + (`https://api.steampowered.com/ISteamUser/GetUserGroupList/v1/?key=&steamid=`, + off the game thread). In the group: their `source = 'group'` row is added or refreshed. Not in + it: the row is deleted. Steam wouldn't say (a private profile, a bad key, an outage, a rate + limit): nothing changes, what the database has stands, and the log says why. A player is asked + about at most once per `min_forced_refresh_seconds`, so reconnecting doesn't spam Steam. +- **Only on join.** A player who leaves the group while on the server stays verified until they + next join (or someone runs `css_verify` on them). - **Overrides.** `gv_verified` is reread every `db_reload_seconds`, so web panel changes show up without a restart. Expired rows are ignored, and deleted on each reload. -- **Verified** = in the group list, **or** an unexpired row with `override` = 1. -- Online players are checked when they connect (once Steam has authorized them), after each group - fetch, after each database reload and after each settings command. Other plugins get - `OnVerifiedChanged` only when a player's status changes. Their state is dropped when they - disconnect. +- **Verified** = a `group` row (or this session's Steam answer, which wins), **or** an unexpired + row with `override` = 1. +- Online players are re-evaluated after each Steam answer, database reload and settings command. + Other plugins get `OnVerifiedChanged` only when a player's status changes. Their state is + dropped when they disconnect. +- **Why not the clan tag?** The tag shown on the scoreboard is CS2-Tags' rank tag on our server, + not the player's, and the group a client says it represents (`cl_clanid`) is a client setting + anyone can set to any group. Only Steam's own answer is trusted. ## `css_verify` (`!verify`) @@ -53,10 +75,11 @@ that isn't a Steam group ID. (SteamID64, `STEAM_X:Y:Z` or `[U:1:N]`), which works for offline players too. No target means `@me`. Works from the server console. -If the group list or the database was last loaded more than `min_forced_refresh_seconds` ago, -both are brought up to date first. Then the targets are re-evaluated, and the answer goes to chat: -one line per player for up to 5 targets (verified by Steam group, override with its expiry, or -not verified), a summary for more, and how old the group list and overrides are. Every use is +Each target (offline SteamIDs too) is checked with Steam again, and the database reread, where +that was last done more than `min_forced_refresh_seconds` ago. Then the targets are re-evaluated, +and the answer goes to chat: one line per player for up to 5 targets (verified by Steam group, +override with its expiry, or not verified, and why Steam couldn't be asked if it couldn't), a +summary for more, and how old the overrides are. Every use is logged through SimpleAdmin's `LogCommand` (server log and its Discord log) and the plugin's log. **Who can use it:** Admin and above, plus Guardian. A player qualifies when one of their @@ -86,7 +109,7 @@ match SimpleAdmin's `Timezone` (as the web panel already assumes for SimpleAdmin | `expires_at` | `datetime NULL` | NULL = never. Expired rows are ignored, then deleted. | | `added_by` | `varchar(128)` | free text: who added it (`steam-group` for group rows) | | `created_at` | `datetime` | first added | -| `updated_at` | `datetime` | group rows: last seen in the list | +| `updated_at` | `datetime` | group rows: when Steam last confirmed them | One row per player and source, so a manual override and a group row never overwrite each other. `override` is backticked in the plugin's SQL; do the same, it's close to a reserved word. @@ -102,7 +125,8 @@ ON DUPLICATE KEY UPDATE `override` = 1, expires_at = VALUES(expires_at), `NOW()` is only right if MySQL's time zone matches `timezone`; otherwise pass the time from the panel. To remove one: `DELETE FROM gv_verified WHERE steamid64 = ? AND source = 'manual'`. Leave -`group` rows alone: the next fetch rewrites them, and they never verify anyone on their own. +`group` rows alone: they verify the player, and the next Steam check as they join adds or deletes +them. ### `gv_settings` @@ -114,12 +138,12 @@ applies at the next reload (`db_reload_seconds`), timers included. |------------------------------|------------------|-----------------------------------------------------------------| | `enabled` | `1` | `0` turns verification off (`css_gv_enable`/`css_gv_disable`) | | `group_id64` | `0` | the Steam group's 64-bit ID; `0` turns it off (`css_gv_group`) | -| `refresh_minutes` | `10` | how often the member list is fetched | -| `min_forced_refresh_seconds` | `60` | `css_verify` refreshes only data older than this | +| `min_forced_refresh_seconds` | `60` | a player is rechecked with Steam at most this often | | `db_reload_seconds` | `60` | how often `gv_settings` and `gv_verified` are reread | | `timezone` | `UTC` | time zone of the `datetime` columns; match SimpleAdmin's | | `verify_min_rank` | `#rank/admin` | `css_verify` needs this group's immunity or higher | | `verify_extra_ranks` | `#rank/guardian` | comma-separated groups that may also use `css_verify` | +| `clan_tag` | (blank) | scoreboard tag for verified players without a rank, max 12 | ## For other plugins @@ -135,6 +159,11 @@ Use it on the game thread: - `OnVerifiedChanged(player, status)`: fired when an online player becomes verified (including just after connecting) or stops being verified (left the group, override ran out, or verification was turned off). Not fired on disconnect. +- `ClanTag`: the scoreboard tag for verified players who have no rank tag (`css_gv_clantag`, the + `clan_tag` setting), "" when none is set or while `IsActive` is false. GroupVerify doesn't set + tags itself; CS2-Tags (1.0.4c-zio6+) does. +- `OnClanTagChanged()`: fired when `ClanTag` changes, including to or from "" as GroupVerify is + turned on or off, loaded or unloaded. In the other plugin, copy `GroupVerifyApi.dll` from the server's `shared/GroupVerifyApi/` (or `GroupVerifyApi/bin/Release/net10.0/` here) to its `lib/`, and reference it compile-only. It must diff --git a/Settings.cs b/Settings.cs index 59eb3f7..e1b5404 100644 --- a/Settings.cs +++ b/Settings.cs @@ -4,12 +4,12 @@ namespace GroupVerify; internal sealed record Settings( bool Enabled, ulong GroupId64, - int RefreshMinutes, int MinForcedRefreshSeconds, int DbReloadSeconds, TimeZoneInfo TimeZone, string VerifyMinRank, - string[] VerifyExtraRanks) + string[] VerifyExtraRanks, + string ClanTag) { // Written to gv_settings when missing. The rank defaults are our real SimpleAdmin groups (see // the fork's Helper.StaffRanks and the web panel's site config). Only the names are set here: @@ -18,12 +18,12 @@ internal sealed record Settings( [ ("enabled", "1"), // 0 turns verification off (css_gv_enable/css_gv_disable) ("group_id64", "0"), // the Steam group's 64-bit ID (103582791...); 0 turns verification off (css_gv_group) - ("refresh_minutes", "10"), // how often the group's member list is fetched - ("min_forced_refresh_seconds", "60"), // css_verify refetches/reloads only when older than this + ("min_forced_refresh_seconds", "60"), // a player is rechecked with Steam (css_verify, rejoining) at most this often ("db_reload_seconds", "60"), // how often gv_verified and gv_settings are reread ("timezone", "UTC"), // must match CS2-SimpleAdmin's Timezone ("verify_min_rank", "#rank/admin"), // css_verify: this group's immunity or higher - ("verify_extra_ranks", "#rank/guardian") // css_verify: these groups too, comma-separated + ("verify_extra_ranks", "#rank/guardian"), // css_verify: these groups too, comma-separated + ("clan_tag", "") // scoreboard tag for verified players without a rank (css_gv_clantag) ]; public static readonly Settings Default = Parse(new Dictionary(), _ => { }); @@ -61,14 +61,32 @@ internal sealed record Settings( return new Settings( Get("enabled") != "0", groupId, - GetInt("refresh_minutes", 1), GetInt("min_forced_refresh_seconds", 0), GetInt("db_reload_seconds", 10), timeZone, Get("verify_min_rank"), - Get("verify_extra_ranks").Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries)); + Get("verify_extra_ranks").Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries), + ParseClanTag(Get("clan_tag"), warn)); } + // Steam's own group tags are at most 12 characters. Quotes and control characters are refused, + // since the tag travels in console commands. + public const int ClanTagMaxLength = 12; + + public static bool ValidClanTag(string tag) => + tag.Length <= ClanTagMaxLength && !tag.Any(c => char.IsControl(c) || c == '"' || c == ';'); + + private static string ParseClanTag(string tag, Action warn) + { + if (ValidClanTag(tag)) + return tag; + warn($"gv_settings clan_tag \"{tag}\" must be at most {ClanTagMaxLength} characters, without quotes, ; or control characters; ignoring it"); + return ""; + } + + // The tag other plugins see: none while verification is off. + public string EffectiveClanTag => Active ? ClanTag : ""; + // Verification only runs when enabled with a group set; otherwise nobody is verified. public bool Active => Enabled && GroupId64 != 0; diff --git a/SteamApi.cs b/SteamApi.cs new file mode 100644 index 0000000..4ec2d4e --- /dev/null +++ b/SteamApi.cs @@ -0,0 +1,97 @@ +using System.Net; +using System.Text.Json; + +namespace GroupVerify; + +// What Steam said about one player and our group. +internal enum Membership +{ + Member, + NotMember, + Unknown // Steam wouldn't say: private profile, bad key, rate limit, an outage +} + +// Asks the Steam Web API (ISteamUser/GetUserGroupList) which groups a player is in. Runs off the +// game thread. Needs a Web API key, from GroupVerify.json. +internal static class SteamApi +{ + private static readonly HttpClient Http = CreateClient(); + + private static HttpClient CreateClient() + { + var client = new HttpClient { Timeout = TimeSpan.FromSeconds(15) }; + client.DefaultRequestHeaders.UserAgent.ParseAdd("GroupVerify/1.0 (CounterStrikeSharp)"); + return client; + } + + // Whether the player is in the group. Problem says why when the answer is Unknown. Never + // includes the key: exception messages and the URL stay out of what's returned. + public static async Task<(Membership Result, string? Problem)> CheckAsync(string apiKey, ulong steamId64, + ulong groupId64, CancellationToken cancel) + { + var url = "https://api.steampowered.com/ISteamUser/GetUserGroupList/v1/" + + $"?key={Uri.EscapeDataString(apiKey)}&steamid={steamId64}"; + HttpResponseMessage response; + try + { + response = await Http.GetAsync(url, cancel); + } + catch (HttpRequestException ex) + { + return (Membership.Unknown, $"couldn't reach Steam ({ex.HttpRequestError})"); + } + catch (TaskCanceledException) when (!cancel.IsCancellationRequested) + { + return (Membership.Unknown, "Steam didn't answer in time"); + } + + using (response) + { + switch (response.StatusCode) + { + case HttpStatusCode.OK: + break; + case HttpStatusCode.Unauthorized: + case HttpStatusCode.Forbidden: + return (Membership.Unknown, $"Steam refused the request ({(int)response.StatusCode}): a bad API key, or a private profile"); + case HttpStatusCode.TooManyRequests: + return (Membership.Unknown, "Steam is rate-limiting the API key (429)"); + default: + return (Membership.Unknown, $"Steam answered {(int)response.StatusCode}"); + } + + JsonElement root; + try + { + using var doc = JsonDocument.Parse(await response.Content.ReadAsStringAsync(cancel)); + root = doc.RootElement.GetProperty("response").Clone(); + } + catch (Exception ex) when (ex is JsonException or KeyNotFoundException or InvalidOperationException) + { + return (Membership.Unknown, "Steam's answer wasn't the expected JSON"); + } + + // A private profile (or group list) comes back as success: false. + if (!root.TryGetProperty("success", out var success) || success.ValueKind != JsonValueKind.True) + { + var error = root.TryGetProperty("error", out var e) ? e.ToString() : "no reason given"; + return (Membership.Unknown, $"Steam wouldn't list their groups ({error}); their profile may be private"); + } + + // gid is the group's 32-bit account ID, the low half of its 64-bit ID. Accept the full + // 64-bit ID too, in case Steam ever sends that. + var account = groupId64 & 0xFFFFFFFF; + if (root.TryGetProperty("groups", out var groups) && groups.ValueKind == JsonValueKind.Array) + { + foreach (var group in groups.EnumerateArray()) + { + if (!group.TryGetProperty("gid", out var gid)) + continue; + if (ulong.TryParse(gid.ToString(), out var id) && (id == account || id == groupId64)) + return (Membership.Member, null); + } + } + return (Membership.NotMember, null); + } + } +} diff --git a/SteamGroup.cs b/SteamGroup.cs deleted file mode 100644 index 5be3801..0000000 --- a/SteamGroup.cs +++ /dev/null @@ -1,60 +0,0 @@ -using System.Xml.Linq; - -namespace GroupVerify; - -// Reads a Steam group's public member list (memberslistxml), runs off the game thread. -internal static class SteamGroup -{ - // 1000 members a page, so this allows groups of up to 100,000. - private const int MaxPages = 100; - // A pause between pages, to stay clear of Steam's rate limit. - private static readonly TimeSpan PageDelay = TimeSpan.FromMilliseconds(500); - - private static readonly HttpClient Http = CreateClient(); - - private static HttpClient CreateClient() - { - var client = new HttpClient { Timeout = TimeSpan.FromSeconds(30) }; - client.DefaultRequestHeaders.UserAgent.ParseAdd("GroupVerify/1.0 (CounterStrikeSharp)"); - return client; - } - - // Every member's SteamID64, following nextPageLink. Throws if any page fails, so a partial list - // never replaces a whole one. - public static async Task> FetchMembersAsync(ulong groupId64, CancellationToken cancel) - { - var members = new HashSet(); - Uri? url = new($"https://steamcommunity.com/gid/{groupId64}/memberslistxml/?xml=1"); - - for (var page = 1; url != null; page++) - { - if (page > MaxPages) - throw new InvalidDataException($"the member list has more than {MaxPages} pages"); - if (page > 1) - await Task.Delay(PageDelay, cancel); - - var root = XDocument.Parse(await Http.GetStringAsync(url, cancel)).Root; - if (root?.Name.LocalName != "memberList") - throw new InvalidDataException($"page {page} isn't a member list (<{root?.Name.LocalName}>)"); - - foreach (var id in root.Element("members")?.Elements("steamID64") ?? []) - { - if (ulong.TryParse(id.Value.Trim(), out var steamId)) - members.Add(steamId); - } - - url = NextPage(root.Element("nextPageLink")?.Value); - } - return members; - } - - // Only ever follows links back to steamcommunity.com, over https. - private static Uri? NextPage(string? link) - { - if (string.IsNullOrWhiteSpace(link) || !Uri.TryCreate(link.Trim(), UriKind.Absolute, out var uri)) - return null; - if (uri.Host != "steamcommunity.com" || (uri.Scheme != Uri.UriSchemeHttps && uri.Scheme != Uri.UriSchemeHttp)) - throw new InvalidDataException($"unexpected nextPageLink {uri}"); - return new UriBuilder(uri) { Scheme = Uri.UriSchemeHttps, Port = -1 }.Uri; - } -} diff --git a/release.sh b/release.sh index 171fdec..304ff5b 100755 --- a/release.sh +++ b/release.sh @@ -15,15 +15,21 @@ # addons/counterstrikesharp/shared/GroupVerifyApi/ GroupVerifyApi.dll, .pdb, .xml: the API other # plugins compile against (never in a plugin folder) # -# No configs/: GroupVerify has no config file. Its settings and data are in CS2-SimpleAdmin's -# database (gv_settings, gv_verified), which the plugin creates itself, so an upgrade never touches them. +# No configs/: the plugin writes configs/plugins/GroupVerify/GroupVerify.json (the Steam Web API key) +# itself when it's missing, and its settings and data are in CS2-SimpleAdmin's database (gv_settings, +# gv_verified), so an upgrade never touches them. set -euo pipefail FORGEJO_URL="https://git.zio.sh" OWNER="cs2" REPO="GroupVerify" -# 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. +# Needs write:repository. Only uploading needs it; downloading from the public repo doesn't. From the +# environment, or else from .forgejo-token in the cs2/ folder above every plugin (outside their +# repos, mode 600). Don't commit a real token. +TOKEN_FILE="$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")/../../.forgejo-token" +if [[ -z "${FORGEJO_TOKEN:-}" && -r "$TOKEN_FILE" ]]; then + FORGEJO_TOKEN="$(tr -d '[:space:]' < "$TOKEN_FILE")" +fi FORGEJO_TOKEN="${FORGEJO_TOKEN:-CHANGE_ME_FORGEJO_TOKEN}" # Only these ship from the publish output. CounterStrikeSharp.API.dll is excluded by the csproj @@ -43,7 +49,7 @@ if [[ -z "$TAG" ]]; then exit 1 fi if [[ "$FORGEJO_TOKEN" == "CHANGE_ME_FORGEJO_TOKEN" ]]; then - echo "Set FORGEJO_TOKEN (edit release.sh or export it) first." >&2 + echo "Set FORGEJO_TOKEN (export it, or put it in $TOKEN_FILE) first." >&2 exit 1 fi for tool in podman jq curl git; do