GroupVerify 0.3.0: check players with the Steam Web API as they join, clan tag for other plugins

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

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

New clan_tag setting and css_gv_clantag <tag|-> (@css/root): the scoreboard tag for verified
players without a rank, applied by CS2-Tags. The API gains ClanTag and OnClanTagChanged, so
shared/GroupVerifyApi changes: needs a server restart.
This commit is contained in:
Astra 2026-10-02 18:42:41 +01:00
parent 1f61958099
commit 1a9540c3e4
11 changed files with 436 additions and 204 deletions

15
Config.cs Normal file
View file

@ -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; } = "";
}

View file

@ -7,7 +7,6 @@ namespace GroupVerify;
internal sealed class Database(string connectionString) internal sealed class Database(string connectionString)
{ {
private const string MigrationPrefix = "GroupVerify.Migrations."; private const string MigrationPrefix = "GroupVerify.Migrations.";
private const int UpsertBatch = 500;
// What a reload hands back to the game thread. // What a reload hands back to the game thread.
public sealed record Snapshot( public sealed record Snapshot(
@ -138,35 +137,28 @@ internal sealed class Database(string connectionString)
await command.ExecuteNonQueryAsync(); await command.ExecuteNonQueryAsync();
} }
// Saves a fetched group list: upserts a 'group' row for every member (keeping created_at), then // Records one player's group membership from a Steam check: a member gets or refreshes their
// deletes the 'group' rows of anyone no longer in it. One transaction, so a failure keeps the // 'group' row (keeping created_at, the first time they were seen in it); anyone else loses it.
// previous list whole. public async Task SetGroupMemberAsync(ulong steamId, bool member, DateTime now)
public async Task SaveGroupAsync(IReadOnlyCollection<ulong> members, DateTime now)
{ {
await using var connection = await OpenAsync(); 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)) // Forgets every 'group' row, when the group changes: they belong to the old one.
{ public async Task ClearGroupRowsAsync()
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 connection = await OpenAsync();
await using var command = Command(connection, $""" await using var command = Command(connection, "DELETE FROM gv_verified WHERE source = 'group'");
INSERT INTO gv_verified (steamid64, source, `override`, expires_at, added_by, created_at, updated_at) await command.ExecuteNonQueryAsync();
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 // Whether a player may use css_verify: one of their SimpleAdmin groups has at least minRank's

View file

@ -17,14 +17,15 @@ namespace GroupVerify;
// Marks players as verified when they're in a Steam group, or have a manual override in the // 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. // 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 // 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). // Task.Run and hands its result back with Server.NextFrame(Async).
public class GroupVerify : BasePlugin, IGroupVerifyBackend public class GroupVerify : BasePlugin, IGroupVerifyBackend, IPluginConfig<GroupVerifyConfig>
{ {
public override string ModuleName => "GroupVerify"; 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 ModuleAuthor => "astra";
public override string ModuleDescription => "Verifies players by Steam group membership or a manual override"; 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. // doesn't put the old value back.
private int _settingsVersion; private int _settingsVersion;
// The group's members. Seeded from gv_verified's 'group' rows at load, so a Steam outage at public GroupVerifyConfig Config { get; set; } = new();
// startup doesn't unverify everyone, then replaced by each successful fetch.
private HashSet<ulong> _groupMembers = []; // Group members as of the database: gv_verified's 'group' rows, reread with every reload.
private DateTime? _groupFetchedAt; // UTC, the last successful fetch this session private HashSet<ulong> _groupRows = [];
private DateTime? _groupAttemptAt; // UTC, the last fetch started // This session's Steam checks, which win over the database until the next restart: SteamID64 ->
private Task? _groupRefresh; // in flight, shared by everyone who asks // whether they're a member. Players Steam wouldn't answer for aren't in here.
private readonly Dictionary<ulong, bool> _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<ulong, DateTime> _checkedAt = [];
// SteamID64 -> why Steam couldn't say, for css_verify.
private readonly Dictionary<ulong, string> _checkProblem = [];
// Checks in flight, shared by everyone who asks about the same player.
private readonly Dictionary<ulong, Task> _checks = [];
private bool _noKeyWarned;
// SteamID64 -> override end (null: never), from gv_verified. // SteamID64 -> override end (null: never), from gv_verified.
private Dictionary<ulong, DateTime?> _overrides = []; private Dictionary<ulong, DateTime?> _overrides = [];
@ -63,7 +73,6 @@ public class GroupVerify : BasePlugin, IGroupVerifyBackend
// only about changes. // only about changes.
private readonly Dictionary<int, (ulong SteamId, bool Verified)> _evaluated = []; private readonly Dictionary<int, (ulong SteamId, bool Verified)> _evaluated = [];
private Timer? _groupTimer;
private Timer? _dbTimer; private Timer? _dbTimer;
// A css_verify target: an online player (Slot set) or a SteamID looked up offline. // 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); 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) public override void Load(bool hotReload)
{ {
RegisterEventHandler<EventPlayerConnectFull>(OnPlayerConnectFull); RegisterEventHandler<EventPlayerConnectFull>(OnPlayerConnectFull);
@ -142,7 +157,6 @@ public class GroupVerify : BasePlugin, IGroupVerifyBackend
{ {
GroupVerifyBridge.Instance.Detach(this); GroupVerifyBridge.Instance.Detach(this);
_unloading.Cancel(); _unloading.Cancel();
_groupTimer?.Kill();
_dbTimer?.Kill(); _dbTimer?.Kill();
_evaluated.Clear(); _evaluated.Clear();
} }
@ -151,11 +165,13 @@ public class GroupVerify : BasePlugin, IGroupVerifyBackend
public bool IsActive => _ready && _settings.Active; public bool IsActive => _ready && _settings.Active;
public string ClanTag => _settings.ClanTag;
public VerifyStatus GetStatus(ulong steamId64) public VerifyStatus GetStatus(ulong steamId64)
{ {
if (!IsActive) if (!IsActive)
return default; 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)) if (!_overrides.TryGetValue(steamId64, out var ends))
return new VerifyStatus(inGroup, false, null); return new VerifyStatus(inGroup, false, null);
// Overrides can run out between reloads. // Overrides can run out between reloads.
@ -196,31 +212,46 @@ public class GroupVerify : BasePlugin, IGroupVerifyBackend
if (snapshot == null) if (snapshot == null)
return; return;
_overrides = snapshot.Overrides; _overrides = snapshot.Overrides;
_groupRows = snapshot.GroupRows;
_dbLoadedAt = DateTime.UtcNow; _dbLoadedAt = DateTime.UtcNow;
if (snapshot.Expired > 0) if (snapshot.Expired > 0)
Logger.LogInformation("Removed {Count} expired gv_verified rows", snapshot.Expired); 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 // Switches to new settings (or just re-evaluates, if they're the same).
// group list saved in the database, used only at the first load. private void ApplySettings(Settings next)
private void ApplySettings(Settings next, HashSet<ulong>? savedGroupRows = null)
{ {
var old = _settings; var old = _settings;
var first = !_ready; var first = !_ready;
_settings = next; _settings = next;
_ready = true; _ready = true;
var groupChanged = old.GroupId64 != next.GroupId64; var groupChanged = !first && old.GroupId64 != next.GroupId64;
if (first || groupChanged) if (groupChanged)
{ {
// At load, the saved rows stand in for the list until the first fetch below. They're // Everything known so far is about the old group.
// whichever group was last fetched, so after a change of group they're not used. _groupRows = [];
_groupMembers = first && savedGroupRows != null ? savedGroupRows : []; _membership.Clear();
_groupFetchedAt = null; _checkedAt.Clear();
_groupAttemptAt = null; _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) 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"); next.Enabled ? "no Steam group set, css_gv_group" : "disabled, css_gv_enable");
} }
if (first || old.RefreshMinutes != next.RefreshMinutes || old.DbReloadSeconds != next.DbReloadSeconds) if (first || old.DbReloadSeconds != next.DbReloadSeconds)
RestartTimers(); {
_dbTimer?.Kill();
_dbTimer = AddTimer(_settings.DbReloadSeconds, () => ReloadDatabase(), TimerFlags.REPEAT);
}
EvaluateAll(); 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)) if (next.Active && (first || groupChanged || !old.Active))
RefreshGroup(); CheckAll();
} }
private void RestartTimers() private void CheckAll()
{ {
_groupTimer?.Kill(); foreach (var player in Utilities.GetPlayers())
_dbTimer?.Kill(); {
_groupTimer = AddTimer(_settings.RefreshMinutes * 60f, () => RefreshGroup(), TimerFlags.REPEAT); if (player is { IsValid: true, IsBot: false, IsHLTV: false } && player.AuthorizedSteamID is { } id)
_dbTimer = AddTimer(_settings.DbReloadSeconds, () => ReloadDatabase(), TimerFlags.REPEAT); CheckMembership(id.SteamId64);
}
} }
// Fetches the group's member list and saves it to gv_verified. A failed or empty fetch keeps the // Asks Steam whether the player is in the group, then records the answer in gv_verified (a
// old list. Does nothing while verification is off. Concurrent calls share one. // member's row is added or refreshed, a non-member's removed) and re-evaluates them. When Steam
private Task RefreshGroup() // 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) if (_db == null || !IsActive)
return Task.CompletedTask; return Task.CompletedTask;
if (_groupRefresh != null) if (Config.SteamWebApiKey.Length == 0)
return _groupRefresh; {
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 db = _db;
var settings = _settings; var settings = _settings;
var groupId = settings.GroupId64; var key = Config.SteamWebApiKey;
var cancel = _unloading.Token; var cancel = _unloading.Token;
_groupAttemptAt = DateTime.UtcNow; _checkedAt[steamId] = DateTime.UtcNow;
return _groupRefresh = Task.Run(async () => var task = Task.Run(async () =>
{ {
HashSet<ulong>? members = null; var (result, problem) = (Membership.Unknown, (string?)null);
try try
{ {
members = await SteamGroup.FetchMembersAsync(groupId, cancel); (result, problem) = await SteamApi.CheckAsync(key, steamId, settings.GroupId64, cancel);
if (members.Count == 0)
{
Logger.LogWarning("Steam group {Group}'s member list came back empty, keeping the old one", groupId);
members = null;
}
} }
catch (Exception ex) when (!cancel.IsCancellationRequested) catch (OperationCanceledException) 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; 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 try
{ {
await db.SaveGroupAsync(members, settings.Now()); await db.SetGroupMemberAsync(steamId, result == Membership.Member, settings.Now());
} }
catch (Exception ex) catch (Exception ex)
{ {
// The fetched list is still used; it just won't survive a restart. // The answer still counts until a restart; it just isn't saved.
Logger.LogError("Couldn't save the Steam group list to gv_verified: {Error}", ex.Message); 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; return;
await Server.NextFrameAsync(() => await Server.NextFrameAsync(() =>
{ {
_groupRefresh = null; _checks.Remove(steamId);
// Dropped if the group changed or verification was turned off while fetching. // Dropped if the group changed or verification was turned off while asking.
if (members == null || groupId != _settings.GroupId64 || !IsActive) if (settings.GroupId64 != _settings.GroupId64 || !IsActive)
return; return;
_groupMembers = members; if (result == Membership.Unknown)
_groupFetchedAt = DateTime.UtcNow; {
EvaluateAll(); _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) ---------------------------------------------------------- // --- Verification (game thread) ----------------------------------------------------------
@ -355,9 +420,13 @@ public class GroupVerify : BasePlugin, IGroupVerifyBackend
return HookResult.Continue; 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) 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) 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. // min_forced_refresh_seconds, then answers.
private void Run(Caller? caller, string commandString, List<Target> targets) private void Run(Caller? caller, string commandString, List<Target> targets)
{ {
LogCommand(caller, commandString); LogCommand(caller, commandString);
var maxAge = TimeSpan.FromSeconds(_settings.MinForcedRefreshSeconds); var maxAge = TimeSpan.FromSeconds(_settings.MinForcedRefreshSeconds);
var now = DateTime.UtcNow; var pending = targets.Select(t => CheckMembership(t.SteamId)).ToList();
var pending = new List<Task>(); if (_dbAttemptAt == null || DateTime.UtcNow - _dbAttemptAt > maxAge)
if (_groupAttemptAt == null || now - _groupAttemptAt > maxAge)
pending.Add(RefreshGroup());
if (_dbAttemptAt == null || now - _dbAttemptAt > maxAge)
pending.Add(ReloadDatabase()); pending.Add(ReloadDatabase());
if (pending.All(t => t.IsCompleted)) if (pending.All(t => t.IsCompleted))
@ -493,7 +559,10 @@ public class GroupVerify : BasePlugin, IGroupVerifyBackend
{ {
var name = online?.PlayerName ?? target.Name; var name = online?.PlayerName ?? target.Name;
var where = online == null ? " (offline)" : ""; 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) if (!_settings.Active)
return DescribeState(); return DescribeState();
string group; var group = Config.SteamWebApiKey.Length == 0
if (_groupFetchedAt is { } fetched) ? "no Steam Web API key (GroupVerify.json), so group membership isn't being checked"
group = $"group list {_groupMembers.Count} members, {FormatSpan(DateTime.UtcNow - fetched)} old"; : $"group membership checked with Steam on join; {_groupRows.Count} known members";
else
group = $"group list {_groupMembers.Count} members from the database (Steam not reached yet)";
var db = _dbLoadedAt is { } loaded ? $"overrides {FormatSpan(DateTime.UtcNow - loaded)} old" : "overrides not loaded"; var db = _dbLoadedAt is { } loaded ? $"overrides {FormatSpan(DateTime.UtcNow - loaded)} old" : "overrides not loaded";
return $"{group}; {db}."; return $"{group}; {db}.";
@ -558,7 +625,10 @@ public class GroupVerify : BasePlugin, IGroupVerifyBackend
private string DescribeState() private string DescribeState()
{ {
if (_settings.Active) 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) if (!_settings.Enabled)
{ {
var group = _settings.GroupId64 == 0 ? "no Steam group set" : $"Steam group {_settings.GroupId64}"; 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 }); 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, private void ChangeSetting(CCSPlayerController? player, CommandInfo command, string name, string value,
Func<Settings, Settings> change) Func<Settings, Settings> change)
{ {

View file

@ -12,6 +12,9 @@ public interface IGroupVerifyBackend
/// <summary>See <see cref="IGroupVerifyApi.GetStatus"/>.</summary> /// <summary>See <see cref="IGroupVerifyApi.GetStatus"/>.</summary>
VerifyStatus GetStatus(ulong steamId64); VerifyStatus GetStatus(ulong steamId64);
/// <summary>The configured clan tag; the bridge blanks it while inactive.</summary>
string ClanTag { get; }
} }
/// <summary> /// <summary>
@ -34,9 +37,15 @@ public sealed class GroupVerifyBridge : IGroupVerifyApi
/// <inheritdoc /> /// <inheritdoc />
public event Action<CCSPlayerController, VerifyStatus>? OnVerifiedChanged; public event Action<CCSPlayerController, VerifyStatus>? OnVerifiedChanged;
/// <inheritdoc />
public event Action? OnClanTagChanged;
/// <inheritdoc /> /// <inheritdoc />
public bool IsActive => _backend?.IsActive ?? false; public bool IsActive => _backend?.IsActive ?? false;
/// <inheritdoc />
public string ClanTag => _backend is { IsActive: true } backend ? backend.ClanTag : "";
/// <inheritdoc /> /// <inheritdoc />
public bool IsVerified(CCSPlayerController player) public bool IsVerified(CCSPlayerController player)
{ {
@ -56,11 +65,36 @@ public sealed class GroupVerifyBridge : IGroupVerifyApi
/// <summary>Called by GroupVerify on load.</summary> /// <summary>Called by GroupVerify on load.</summary>
public void Attach(IGroupVerifyBackend backend) => _backend = backend; public void Attach(IGroupVerifyBackend backend) => _backend = backend;
/// <summary>Called by GroupVerify on unload; ignored if a newer backend has attached since.</summary> /// <summary>
/// Called by GroupVerify on unload; ignored if a newer backend has attached since. Tells
/// subscribers the clan tag is gone, if there was one.
/// </summary>
public void Detach(IGroupVerifyBackend backend) public void Detach(IGroupVerifyBackend backend)
{ {
if (ReferenceEquals(_backend, backend)) if (!ReferenceEquals(_backend, backend))
_backend = null; return;
var had = ClanTag;
_backend = null;
if (had.Length > 0)
RaiseClanTagChanged();
}
/// <summary>Called by GroupVerify on the game thread; returns handlers' exceptions, like RaiseVerifiedChanged.</summary>
public List<Exception> RaiseClanTagChanged()
{
var errors = new List<Exception>();
foreach (var handler in OnClanTagChanged?.GetInvocationList() ?? [])
{
try
{
((Action)handler)();
}
catch (Exception ex)
{
errors.Add(ex);
}
}
return errors;
} }
/// <summary> /// <summary>

View file

@ -43,6 +43,19 @@ public interface IGroupVerifyApi
/// so handlers should be idempotent. /// so handlers should be idempotent.
/// </summary> /// </summary>
event Action<CCSPlayerController, VerifyStatus>? OnVerifiedChanged; event Action<CCSPlayerController, VerifyStatus>? OnVerifiedChanged;
/// <summary>
/// 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 <see cref="IsActive"/> is false.
/// </summary>
string ClanTag { get; }
/// <summary>
/// Fired on the game thread when <see cref="ClanTag"/> changes, including to or from empty as
/// GroupVerify is turned on or off, loaded or unloaded. Re-tag everyone when it fires.
/// </summary>
event Action? OnClanTagChanged;
} }
/// <summary>A player's verification, and why.</summary> /// <summary>A player's verification, and why.</summary>

View file

@ -2,12 +2,12 @@
-- gv_migrations (like SimpleAdmin's sa_migrations, kept apart so neither confuses the other). -- 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. -- 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 -- One row per player and source. A player counts as verified if they have a 'group' row, or a
-- member list, or have a row with `override` = 1 whose expires_at is NULL (never) or still ahead. -- 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 -- source 'group': written by the plugin when Steam's Web API says they're in the group (checked
-- when they leave the group. The live list decides, not these rows. -- 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. -- 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` ( CREATE TABLE IF NOT EXISTS `gv_verified` (
`steamid64` bigint(20) NOT NULL, `steamid64` bigint(20) NOT NULL,
`source` enum('group','manual') NOT NULL, `source` enum('group','manual') NOT NULL,

View file

@ -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 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. 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 Its data and settings live 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, (`GetConnectionString()`). Needs CS2-SimpleAdmin on MySQL; on SQLite, or without SimpleAdmin, it
or without SimpleAdmin, it logs an error and verifies nobody. 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": "<from https://steamcommunity.com/dev/apikey>",
"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 ## 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: 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 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_disable turn it off, keeping the group
css_gv_enable turn it back on 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 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 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 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 `<groupID64>` at The group ID is the number in `<groupID64>` at
`https://steamcommunity.com/groups/<name>/memberslistxml/?xml=1`. `css_gv_group` refuses anything `https://steamcommunity.com/groups/<name>/memberslistxml/?xml=1`. `css_gv_group` refuses anything
@ -33,18 +47,26 @@ that isn't a Steam group ID.
## How it works ## How it works
- **Steam group.** The group's public member list - **Steam group, checked per player as they join.** Once Steam has authorized a player, they're
(`https://steamcommunity.com/gid/<group_id64>/memberslistxml/?xml=1`, every `nextPageLink` page) verified straight away from what the database knows, then GroupVerify asks the Steam Web API
is fetched off the game thread every `refresh_minutes`. A failed or empty fetch keeps the old which groups they're in
list. Each good fetch is saved to `gv_verified` as `source = 'group'` rows, and the rows of (`https://api.steampowered.com/ISteamUser/GetUserGroupList/v1/?key=<key>&steamid=<steamid64>`,
anyone who left are deleted; at startup those rows stand in for the list until Steam answers. 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 - **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. without a restart. Expired rows are ignored, and deleted on each reload.
- **Verified** = in the group list, **or** an unexpired row with `override` = 1. - **Verified** = a `group` row (or this session's Steam answer, which wins), **or** an unexpired
- Online players are checked when they connect (once Steam has authorized them), after each group row with `override` = 1.
fetch, after each database reload and after each settings command. Other plugins get - Online players are re-evaluated after each Steam answer, database reload and settings command.
`OnVerifiedChanged` only when a player's status changes. Their state is dropped when they Other plugins get `OnVerifiedChanged` only when a player's status changes. Their state is
disconnect. 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`) ## `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 (SteamID64, `STEAM_X:Y:Z` or `[U:1:N]`), which works for offline players too. No target means
`@me`. Works from the server console. `@me`. Works from the server console.
If the group list or the database was last loaded more than `min_forced_refresh_seconds` ago, Each target (offline SteamIDs too) is checked with Steam again, and the database reread, where
both are brought up to date first. Then the targets are re-evaluated, and the answer goes to chat: that was last done more than `min_forced_refresh_seconds` ago. Then the targets are re-evaluated,
one line per player for up to 5 targets (verified by Steam group, override with its expiry, or and the answer goes to chat: one line per player for up to 5 targets (verified by Steam group,
not verified), a summary for more, and how old the group list and overrides are. Every use is 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. 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 **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. | | `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) | | `added_by` | `varchar(128)` | free text: who added it (`steam-group` for group rows) |
| `created_at` | `datetime` | first added | | `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. 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. `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 `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 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` ### `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`) | | `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`) | | `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` | a player is rechecked with Steam at most this often |
| `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 | | `db_reload_seconds` | `60` | how often `gv_settings` and `gv_verified` are reread |
| `timezone` | `UTC` | time zone of the `datetime` columns; match SimpleAdmin's | | `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_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` | | `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 ## For other plugins
@ -135,6 +159,11 @@ Use it on the game thread:
- `OnVerifiedChanged(player, status)`: fired when an online player becomes verified (including - `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 just after connecting) or stops being verified (left the group, override ran out, or
verification was turned off). Not fired on disconnect. 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 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 `GroupVerifyApi/bin/Release/net10.0/` here) to its `lib/`, and reference it compile-only. It must

View file

@ -4,12 +4,12 @@ namespace GroupVerify;
internal sealed record Settings( internal sealed record Settings(
bool Enabled, bool Enabled,
ulong GroupId64, ulong GroupId64,
int RefreshMinutes,
int MinForcedRefreshSeconds, int MinForcedRefreshSeconds,
int DbReloadSeconds, int DbReloadSeconds,
TimeZoneInfo TimeZone, TimeZoneInfo TimeZone,
string VerifyMinRank, string VerifyMinRank,
string[] VerifyExtraRanks) string[] VerifyExtraRanks,
string ClanTag)
{ {
// Written to gv_settings when missing. The rank defaults are our real SimpleAdmin groups (see // 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: // 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) ("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) ("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"), // a player is rechecked with Steam (css_verify, rejoining) at most this often
("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 ("db_reload_seconds", "60"), // how often gv_verified and gv_settings are reread
("timezone", "UTC"), // must match CS2-SimpleAdmin's Timezone ("timezone", "UTC"), // must match CS2-SimpleAdmin's Timezone
("verify_min_rank", "#rank/admin"), // css_verify: this group's immunity or higher ("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<string, string>(), _ => { }); public static readonly Settings Default = Parse(new Dictionary<string, string>(), _ => { });
@ -61,14 +61,32 @@ internal sealed record Settings(
return new Settings( return new Settings(
Get("enabled") != "0", Get("enabled") != "0",
groupId, groupId,
GetInt("refresh_minutes", 1),
GetInt("min_forced_refresh_seconds", 0), GetInt("min_forced_refresh_seconds", 0),
GetInt("db_reload_seconds", 10), GetInt("db_reload_seconds", 10),
timeZone, timeZone,
Get("verify_min_rank"), 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<string> 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. // Verification only runs when enabled with a group set; otherwise nobody is verified.
public bool Active => Enabled && GroupId64 != 0; public bool Active => Enabled && GroupId64 != 0;

97
SteamApi.cs Normal file
View file

@ -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);
}
}
}

View file

@ -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<HashSet<ulong>> FetchMembersAsync(ulong groupId64, CancellationToken cancel)
{
var members = new HashSet<ulong>();
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;
}
}

View file

@ -15,8 +15,9 @@
# addons/counterstrikesharp/shared/GroupVerifyApi/ GroupVerifyApi.dll, .pdb, .xml: the API other # addons/counterstrikesharp/shared/GroupVerifyApi/ GroupVerifyApi.dll, .pdb, .xml: the API other
# plugins compile against (never in a plugin folder) # plugins compile against (never in a plugin folder)
# #
# No configs/: GroupVerify has no config file. Its settings and data are in CS2-SimpleAdmin's # No configs/: the plugin writes configs/plugins/GroupVerify/GroupVerify.json (the Steam Web API key)
# database (gv_settings, gv_verified), which the plugin creates itself, so an upgrade never touches them. # 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 set -euo pipefail
FORGEJO_URL="https://git.zio.sh" FORGEJO_URL="https://git.zio.sh"