diff --git a/Config.cs b/Config.cs deleted file mode 100644 index 48f9830..0000000 --- a/Config.cs +++ /dev/null @@ -1,15 +0,0 @@ -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 c729101..575c19f 100644 --- a/Database.cs +++ b/Database.cs @@ -7,6 +7,7 @@ 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( @@ -137,28 +138,35 @@ internal sealed class Database(string connectionString) 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) + // 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) { 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(); - } + await using var transaction = await connection.BeginTransactionAsync(); - // 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(); + 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 diff --git a/GroupVerify.cs b/GroupVerify.cs index 92b1c18..0797478 100644 --- a/GroupVerify.cs +++ b/GroupVerify.cs @@ -17,15 +17,14 @@ 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. -// 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. +// Everything is kept in CS2-SimpleAdmin's database, over its connection string. // // 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, IPluginConfig +public class GroupVerify : BasePlugin, IGroupVerifyBackend { public override string ModuleName => "GroupVerify"; - public override string ModuleVersion => "0.3.0"; + public override string ModuleVersion => "0.2.0"; public override string ModuleAuthor => "astra"; public override string ModuleDescription => "Verifies players by Steam group membership or a manual override"; @@ -47,21 +46,12 @@ public class GroupVerify : BasePlugin, IGroupVerifyBackend, IPluginConfig _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; + // 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 // SteamID64 -> override end (null: never), from gv_verified. private Dictionary _overrides = []; @@ -73,6 +63,7 @@ public class GroupVerify : BasePlugin, IGroupVerifyBackend, IPluginConfig _evaluated = []; + private Timer? _groupTimer; private Timer? _dbTimer; // A css_verify target: an online player (Slot set) or a SteamID looked up offline. @@ -80,12 +71,6 @@ public class GroupVerify : BasePlugin, IGroupVerifyBackend, IPluginConfig(OnPlayerConnectFull); @@ -157,6 +142,7 @@ public class GroupVerify : BasePlugin, IGroupVerifyBackend, IPluginConfig _ready && _settings.Active; - public string ClanTag => _settings.ClanTag; - public VerifyStatus GetStatus(ulong steamId64) { if (!IsActive) return default; - var inGroup = _membership.TryGetValue(steamId64, out var member) ? member : _groupRows.Contains(steamId64); + var inGroup = _groupMembers.Contains(steamId64); if (!_overrides.TryGetValue(steamId64, out var ends)) return new VerifyStatus(inGroup, false, null); // Overrides can run out between reloads. @@ -212,46 +196,31 @@ public class GroupVerify : BasePlugin, IGroupVerifyBackend, IPluginConfig 0) Logger.LogInformation("Removed {Count} expired gv_verified rows", snapshot.Expired); - ApplySettings(version == _settingsVersion ? snapshot.Settings : _settings); + ApplySettings(version == _settingsVersion ? snapshot.Settings : _settings, snapshot.GroupRows); }); }); } - // Switches to new settings (or just re-evaluates, if they're the same). - private void ApplySettings(Settings next) + // 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) { var old = _settings; var first = !_ready; _settings = next; _ready = true; - var groupChanged = !first && old.GroupId64 != next.GroupId64; - if (groupChanged) + var groupChanged = old.GroupId64 != next.GroupId64; + if (first || groupChanged) { - // 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); - } - }); - } + // 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; } if (first || old.Active != next.Active) @@ -263,92 +232,69 @@ public class GroupVerify : BasePlugin, IGroupVerifyBackend, IPluginConfig ReloadDatabase(), TimerFlags.REPEAT); - } + if (first || old.RefreshMinutes != next.RefreshMinutes || old.DbReloadSeconds != next.DbReloadSeconds) + RestartTimers(); 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)) - CheckAll(); + RefreshGroup(); } - private void CheckAll() + private void RestartTimers() { - foreach (var player in Utilities.GetPlayers()) - { - if (player is { IsValid: true, IsBot: false, IsHLTV: false } && player.AuthorizedSteamID is { } id) - CheckMembership(id.SteamId64); - } + _groupTimer?.Kill(); + _dbTimer?.Kill(); + _groupTimer = AddTimer(_settings.RefreshMinutes * 60f, () => RefreshGroup(), TimerFlags.REPEAT); + _dbTimer = AddTimer(_settings.DbReloadSeconds, () => ReloadDatabase(), TimerFlags.REPEAT); } - // 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) + // 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() { if (_db == null || !IsActive) return Task.CompletedTask; - 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; + if (_groupRefresh != null) + return _groupRefresh; var db = _db; var settings = _settings; - var key = Config.SteamWebApiKey; + var groupId = settings.GroupId64; var cancel = _unloading.Token; - _checkedAt[steamId] = DateTime.UtcNow; - var task = Task.Run(async () => + _groupAttemptAt = DateTime.UtcNow; + return _groupRefresh = Task.Run(async () => { - var (result, problem) = (Membership.Unknown, (string?)null); + HashSet? members = null; try { - (result, problem) = await SteamApi.CheckAsync(key, steamId, settings.GroupId64, cancel); + 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; + } } - catch (OperationCanceledException) when (cancel.IsCancellationRequested) + 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) { return; } - catch (Exception ex) - { - // Only the type: messages could quote the request, key and all. - (result, problem) = (Membership.Unknown, $"unexpected {ex.GetType().Name}"); - } - if (result == Membership.Unknown) - { - Logger.LogWarning("Couldn't check {SteamId} with Steam, keeping what the database has: {Problem}", steamId, problem); - } - else + if (members != null) { try { - await db.SetGroupMemberAsync(steamId, result == Membership.Member, settings.Now()); + await db.SaveGroupAsync(members, settings.Now()); } catch (Exception ex) { - // 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); + // 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); } } @@ -356,26 +302,15 @@ public class GroupVerify : BasePlugin, IGroupVerifyBackend, IPluginConfig { - _checks.Remove(steamId); - // Dropped if the group changed or verification was turned off while asking. - if (settings.GroupId64 != _settings.GroupId64 || !IsActive) + _groupRefresh = null; + // Dropped if the group changed or verification was turned off while fetching. + if (members == null || groupId != _settings.GroupId64 || !IsActive) return; - 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); - } + _groupMembers = members; + _groupFetchedAt = DateTime.UtcNow; + EvaluateAll(); }); }); - _checks[steamId] = task; - return task; } // --- Verification (game thread) ---------------------------------------------------------- @@ -420,13 +355,9 @@ public class GroupVerify : BasePlugin, IGroupVerifyBackend, IPluginConfig targets) { LogCommand(caller, commandString); var maxAge = TimeSpan.FromSeconds(_settings.MinForcedRefreshSeconds); - var pending = targets.Select(t => CheckMembership(t.SteamId)).ToList(); - if (_dbAttemptAt == null || DateTime.UtcNow - _dbAttemptAt > maxAge) + var now = DateTime.UtcNow; + var pending = new List(); + if (_groupAttemptAt == null || now - _groupAttemptAt > maxAge) + pending.Add(RefreshGroup()); + if (_dbAttemptAt == null || now - _dbAttemptAt > maxAge) pending.Add(ReloadDatabase()); if (pending.All(t => t.IsCompleted)) @@ -559,10 +493,7 @@ public class GroupVerify : BasePlugin, IGroupVerifyBackend, IPluginConfig 0 ? $"clan tag \"{_settings.ClanTag}\"" : "no clan tag"; - return $"{ChatColors.Green}On{ChatColors.Default}, Steam group {_settings.GroupId64}, {tag}."; - } + return $"{ChatColors.Green}On{ChatColors.Default}, Steam group {_settings.GroupId64}."; if (!_settings.Enabled) { var group = _settings.GroupId64 == 0 ? "no Steam group set" : $"Steam group {_settings.GroupId64}"; @@ -719,29 +649,6 @@ public class GroupVerify : BasePlugin, IGroupVerifyBackend, IPluginConfig 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 db34da1..7458536 100644 --- a/GroupVerifyApi/GroupVerifyBridge.cs +++ b/GroupVerifyApi/GroupVerifyBridge.cs @@ -12,9 +12,6 @@ public interface IGroupVerifyBackend /// See . VerifyStatus GetStatus(ulong steamId64); - - /// The configured clan tag; the bridge blanks it while inactive. - string ClanTag { get; } } /// @@ -37,15 +34,9 @@ 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) { @@ -65,36 +56,11 @@ 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. Tells - /// subscribers the clan tag is gone, if there was one. - /// + /// Called by GroupVerify on unload; ignored if a newer backend has attached since. public void Detach(IGroupVerifyBackend backend) { - 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; + if (ReferenceEquals(_backend, backend)) + _backend = null; } /// diff --git a/GroupVerifyApi/IGroupVerifyApi.cs b/GroupVerifyApi/IGroupVerifyApi.cs index d5bb316..142af35 100644 --- a/GroupVerifyApi/IGroupVerifyApi.cs +++ b/GroupVerifyApi/IGroupVerifyApi.cs @@ -43,19 +43,6 @@ 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 5742a5f..5a0a68a 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 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. +-- 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. -- source 'manual': written by the web panel, `override` 1, expires_at NULL or a date. --- updated_at is when Steam last confirmed a group row. +-- updated_at is when the plugin last saw a group row in the list. 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 c0ddd8f..b32e772 100644 --- a/README.md +++ b/README.md @@ -4,21 +4,9 @@ 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. -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. +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. ## Turning it on @@ -27,19 +15,17 @@ 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 check everyone online +css_gv_group 103582791429521408 set the group (its 64-bit ID) and fetch its members now css_gv_group 0 clear the group: verification is off -css_gv_group show whether it's on and the group +css_gv_group show whether it's on, the group, and how old the list is 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) checks everyone on the server with Steam straight away. Changing the group -deletes the saved members of the old one. +changing the group) fetches the member list straight away. The group ID is the number in `` at `https://steamcommunity.com/groups//memberslistxml/?xml=1`. `css_gv_group` refuses anything @@ -47,26 +33,18 @@ that isn't a Steam group ID. ## How it works -- **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). +- **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. - **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** = 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. +- **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. ## `css_verify` (`!verify`) @@ -75,11 +53,10 @@ 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. -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 +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 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 @@ -109,7 +86,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: when Steam last confirmed them | +| `updated_at` | `datetime` | group rows: last seen in the list | 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. @@ -125,8 +102,7 @@ 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: they verify the player, and the next Steam check as they join adds or deletes -them. +`group` rows alone: the next fetch rewrites them, and they never verify anyone on their own. ### `gv_settings` @@ -138,12 +114,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`) | -| `min_forced_refresh_seconds` | `60` | a player is rechecked with Steam at most this often | +| `refresh_minutes` | `10` | how often the member list is fetched | +| `min_forced_refresh_seconds` | `60` | `css_verify` refreshes only data older than this | | `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 @@ -159,11 +135,6 @@ 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 e1b5404..59eb3f7 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 ClanTag) + string[] VerifyExtraRanks) { // 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) - ("min_forced_refresh_seconds", "60"), // a player is rechecked with Steam (css_verify, rejoining) at most this often + ("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 ("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 - ("clan_tag", "") // scoreboard tag for verified players without a rank (css_gv_clantag) + ("verify_extra_ranks", "#rank/guardian") // css_verify: these groups too, comma-separated ]; public static readonly Settings Default = Parse(new Dictionary(), _ => { }); @@ -61,32 +61,14 @@ 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), - ParseClanTag(Get("clan_tag"), warn)); + Get("verify_extra_ranks").Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries)); } - // 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 deleted file mode 100644 index 4ec2d4e..0000000 --- a/SteamApi.cs +++ /dev/null @@ -1,97 +0,0 @@ -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 new file mode 100644 index 0000000..5be3801 --- /dev/null +++ b/SteamGroup.cs @@ -0,0 +1,60 @@ +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 304ff5b..171fdec 100755 --- a/release.sh +++ b/release.sh @@ -15,21 +15,15 @@ # addons/counterstrikesharp/shared/GroupVerifyApi/ GroupVerifyApi.dll, .pdb, .xml: the API other # plugins compile against (never in a plugin folder) # -# 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. +# 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. set -euo pipefail FORGEJO_URL="https://git.zio.sh" OWNER="cs2" REPO="GroupVerify" -# 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 +# 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 @@ -49,7 +43,7 @@ if [[ -z "$TAG" ]]; then exit 1 fi if [[ "$FORGEJO_TOKEN" == "CHANGE_ME_FORGEJO_TOKEN" ]]; then - echo "Set FORGEJO_TOKEN (export it, or put it in $TOKEN_FILE) first." >&2 + echo "Set FORGEJO_TOKEN (edit release.sh or export it) first." >&2 exit 1 fi for tool in podman jq curl git; do