Compare commits
2 commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f0975fa2b4 | |||
| 10e5fe9f3c |
11 changed files with 444 additions and 207 deletions
15
Config.cs
Normal file
15
Config.cs
Normal 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; } = "";
|
||||
}
|
||||
34
Database.cs
34
Database.cs
|
|
@ -7,7 +7,6 @@ namespace GroupVerify;
|
|||
internal sealed class Database(string connectionString)
|
||||
{
|
||||
private const string MigrationPrefix = "GroupVerify.Migrations.";
|
||||
private const int UpsertBatch = 500;
|
||||
|
||||
// What a reload hands back to the game thread.
|
||||
public sealed record Snapshot(
|
||||
|
|
@ -138,37 +137,30 @@ internal sealed class Database(string connectionString)
|
|||
await command.ExecuteNonQueryAsync();
|
||||
}
|
||||
|
||||
// Saves a fetched group list: upserts a 'group' row for every member (keeping created_at), then
|
||||
// deletes the 'group' rows of anyone no longer in it. One transaction, so a failure keeps the
|
||||
// previous list whole.
|
||||
public async Task SaveGroupAsync(IReadOnlyCollection<ulong> members, DateTime now)
|
||||
// Records one player's group membership from a Steam check: a member gets or refreshes their
|
||||
// 'group' row (keeping created_at, the first time they were seen in it); anyone else loses it.
|
||||
public async Task SetGroupMemberAsync(ulong steamId, bool member, DateTime now)
|
||||
{
|
||||
await using var connection = await OpenAsync();
|
||||
await using var transaction = await connection.BeginTransactionAsync();
|
||||
|
||||
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, $"""
|
||||
var sql = member
|
||||
? """
|
||||
INSERT INTO gv_verified (steamid64, source, `override`, expires_at, added_by, created_at, updated_at)
|
||||
VALUES {rows}
|
||||
VALUES (@steamid, 'group', 0, NULL, 'steam-group', @now, @now)
|
||||
ON DUPLICATE KEY UPDATE updated_at = @now
|
||||
""", parameters);
|
||||
command.Transaction = transaction;
|
||||
"""
|
||||
: "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 command = Command(connection,
|
||||
"DELETE FROM gv_verified WHERE source = 'group' AND updated_at < @now", ("@now", now)))
|
||||
// Forgets every 'group' row, when the group changes: they belong to the old one.
|
||||
public async Task ClearGroupRowsAsync()
|
||||
{
|
||||
command.Transaction = transaction;
|
||||
await using var connection = await OpenAsync();
|
||||
await using var command = Command(connection, "DELETE FROM gv_verified WHERE source = 'group'");
|
||||
await command.ExecuteNonQueryAsync();
|
||||
}
|
||||
|
||||
await transaction.CommitAsync();
|
||||
}
|
||||
|
||||
// Whether a player may use css_verify: one of their SimpleAdmin groups has at least minRank's
|
||||
// immunity, or is one of extraRanks. Groups are read the way our SimpleAdmin fork's
|
||||
// PermissionManager.GetRank reads ranks: an unexpired sa_admins row for this server whose
|
||||
|
|
|
|||
253
GroupVerify.cs
253
GroupVerify.cs
|
|
@ -17,14 +17,15 @@ namespace GroupVerify;
|
|||
|
||||
// Marks players as verified when they're in a Steam group, or have a manual override in the
|
||||
// gv_verified table (written by the web panel), and tells other plugins through GroupVerifyApi.
|
||||
// Everything is kept in CS2-SimpleAdmin's database, over its connection string.
|
||||
// Group membership is checked per player with the Steam Web API when they join, and kept in
|
||||
// CS2-SimpleAdmin's database, over its connection string, like everything except the API key.
|
||||
//
|
||||
// Threads: every field below is only touched on the game thread. HTTP and database work runs in
|
||||
// Task.Run and hands its result back with Server.NextFrame(Async).
|
||||
public class GroupVerify : BasePlugin, IGroupVerifyBackend
|
||||
public class GroupVerify : BasePlugin, IGroupVerifyBackend, IPluginConfig<GroupVerifyConfig>
|
||||
{
|
||||
public override string ModuleName => "GroupVerify";
|
||||
public override string ModuleVersion => "0.2.0";
|
||||
public override string ModuleVersion => "0.3.0";
|
||||
public override string ModuleAuthor => "astra";
|
||||
public override string ModuleDescription => "Verifies players by Steam group membership or a manual override";
|
||||
|
||||
|
|
@ -46,12 +47,21 @@ public class GroupVerify : BasePlugin, IGroupVerifyBackend
|
|||
// doesn't put the old value back.
|
||||
private int _settingsVersion;
|
||||
|
||||
// The group's members. Seeded from gv_verified's 'group' rows at load, so a Steam outage at
|
||||
// startup doesn't unverify everyone, then replaced by each successful fetch.
|
||||
private HashSet<ulong> _groupMembers = [];
|
||||
private DateTime? _groupFetchedAt; // UTC, the last successful fetch this session
|
||||
private DateTime? _groupAttemptAt; // UTC, the last fetch started
|
||||
private Task? _groupRefresh; // in flight, shared by everyone who asks
|
||||
public GroupVerifyConfig Config { get; set; } = new();
|
||||
|
||||
// Group members as of the database: gv_verified's 'group' rows, reread with every reload.
|
||||
private HashSet<ulong> _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<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.
|
||||
private Dictionary<ulong, DateTime?> _overrides = [];
|
||||
|
|
@ -63,7 +73,6 @@ public class GroupVerify : BasePlugin, IGroupVerifyBackend
|
|||
// only about changes.
|
||||
private readonly Dictionary<int, (ulong SteamId, bool Verified)> _evaluated = [];
|
||||
|
||||
private Timer? _groupTimer;
|
||||
private Timer? _dbTimer;
|
||||
|
||||
// A css_verify target: an online player (Slot set) or a SteamID looked up offline.
|
||||
|
|
@ -71,6 +80,12 @@ public class GroupVerify : BasePlugin, IGroupVerifyBackend
|
|||
|
||||
private sealed record Caller(int Slot, ulong SteamId, string Name);
|
||||
|
||||
public void OnConfigParsed(GroupVerifyConfig config)
|
||||
{
|
||||
config.SteamWebApiKey = config.SteamWebApiKey.Trim();
|
||||
Config = config;
|
||||
}
|
||||
|
||||
public override void Load(bool hotReload)
|
||||
{
|
||||
RegisterEventHandler<EventPlayerConnectFull>(OnPlayerConnectFull);
|
||||
|
|
@ -142,7 +157,6 @@ public class GroupVerify : BasePlugin, IGroupVerifyBackend
|
|||
{
|
||||
GroupVerifyBridge.Instance.Detach(this);
|
||||
_unloading.Cancel();
|
||||
_groupTimer?.Kill();
|
||||
_dbTimer?.Kill();
|
||||
_evaluated.Clear();
|
||||
}
|
||||
|
|
@ -151,11 +165,13 @@ public class GroupVerify : BasePlugin, IGroupVerifyBackend
|
|||
|
||||
public bool IsActive => _ready && _settings.Active;
|
||||
|
||||
public string ClanTag => _settings.ClanTag;
|
||||
|
||||
public VerifyStatus GetStatus(ulong steamId64)
|
||||
{
|
||||
if (!IsActive)
|
||||
return default;
|
||||
var inGroup = _groupMembers.Contains(steamId64);
|
||||
var inGroup = _membership.TryGetValue(steamId64, out var member) ? member : _groupRows.Contains(steamId64);
|
||||
if (!_overrides.TryGetValue(steamId64, out var ends))
|
||||
return new VerifyStatus(inGroup, false, null);
|
||||
// Overrides can run out between reloads.
|
||||
|
|
@ -196,31 +212,46 @@ public class GroupVerify : BasePlugin, IGroupVerifyBackend
|
|||
if (snapshot == null)
|
||||
return;
|
||||
_overrides = snapshot.Overrides;
|
||||
_groupRows = snapshot.GroupRows;
|
||||
_dbLoadedAt = DateTime.UtcNow;
|
||||
if (snapshot.Expired > 0)
|
||||
Logger.LogInformation("Removed {Count} expired gv_verified rows", snapshot.Expired);
|
||||
ApplySettings(version == _settingsVersion ? snapshot.Settings : _settings, snapshot.GroupRows);
|
||||
ApplySettings(version == _settingsVersion ? snapshot.Settings : _settings);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Switches to new settings (or just re-evaluates, if they're the same). savedGroupRows is the
|
||||
// group list saved in the database, used only at the first load.
|
||||
private void ApplySettings(Settings next, HashSet<ulong>? savedGroupRows = null)
|
||||
// Switches to new settings (or just re-evaluates, if they're the same).
|
||||
private void ApplySettings(Settings next)
|
||||
{
|
||||
var old = _settings;
|
||||
var first = !_ready;
|
||||
_settings = next;
|
||||
_ready = true;
|
||||
|
||||
var groupChanged = old.GroupId64 != next.GroupId64;
|
||||
if (first || groupChanged)
|
||||
var groupChanged = !first && old.GroupId64 != next.GroupId64;
|
||||
if (groupChanged)
|
||||
{
|
||||
// At load, the saved rows stand in for the list until the first fetch below. They're
|
||||
// whichever group was last fetched, so after a change of group they're not used.
|
||||
_groupMembers = first && savedGroupRows != null ? savedGroupRows : [];
|
||||
_groupFetchedAt = null;
|
||||
_groupAttemptAt = null;
|
||||
// Everything known so far is about the old group.
|
||||
_groupRows = [];
|
||||
_membership.Clear();
|
||||
_checkedAt.Clear();
|
||||
_checkProblem.Clear();
|
||||
var db = _db;
|
||||
if (db != null)
|
||||
{
|
||||
Task.Run(async () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
await db.ClearGroupRowsAsync();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogError("Couldn't clear the old group's rows from gv_verified: {Error}", ex.Message);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (first || old.Active != next.Active)
|
||||
|
|
@ -232,69 +263,92 @@ public class GroupVerify : BasePlugin, IGroupVerifyBackend
|
|||
next.Enabled ? "no Steam group set, css_gv_group" : "disabled, css_gv_enable");
|
||||
}
|
||||
|
||||
if (first || old.RefreshMinutes != next.RefreshMinutes || old.DbReloadSeconds != next.DbReloadSeconds)
|
||||
RestartTimers();
|
||||
|
||||
EvaluateAll();
|
||||
|
||||
if (next.Active && (first || groupChanged || !old.Active))
|
||||
RefreshGroup();
|
||||
}
|
||||
|
||||
private void RestartTimers()
|
||||
if (first || old.DbReloadSeconds != next.DbReloadSeconds)
|
||||
{
|
||||
_groupTimer?.Kill();
|
||||
_dbTimer?.Kill();
|
||||
_groupTimer = AddTimer(_settings.RefreshMinutes * 60f, () => RefreshGroup(), TimerFlags.REPEAT);
|
||||
_dbTimer = AddTimer(_settings.DbReloadSeconds, () => ReloadDatabase(), TimerFlags.REPEAT);
|
||||
}
|
||||
|
||||
// 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()
|
||||
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();
|
||||
}
|
||||
|
||||
private void CheckAll()
|
||||
{
|
||||
foreach (var player in Utilities.GetPlayers())
|
||||
{
|
||||
if (player is { IsValid: true, IsBot: false, IsHLTV: false } && player.AuthorizedSteamID is { } id)
|
||||
CheckMembership(id.SteamId64);
|
||||
}
|
||||
}
|
||||
|
||||
// Asks Steam whether the player is in the group, then records the answer in gv_verified (a
|
||||
// member's row is added or refreshed, a non-member's removed) and re-evaluates them. When Steam
|
||||
// won't say, nothing changes and what the database has stands. Skipped while verification is
|
||||
// off, without an API key, or if they were asked about within min_forced_refresh_seconds.
|
||||
private Task CheckMembership(ulong steamId)
|
||||
{
|
||||
if (_db == null || !IsActive)
|
||||
return Task.CompletedTask;
|
||||
if (_groupRefresh != null)
|
||||
return _groupRefresh;
|
||||
if (Config.SteamWebApiKey.Length == 0)
|
||||
{
|
||||
if (!_noKeyWarned)
|
||||
Logger.LogWarning("No SteamWebApiKey in configs/plugins/GroupVerify/GroupVerify.json, so nobody's group membership is checked");
|
||||
_noKeyWarned = true;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
if (_checks.TryGetValue(steamId, out var running))
|
||||
return running;
|
||||
if (_checkedAt.TryGetValue(steamId, out var at) &&
|
||||
DateTime.UtcNow - at < TimeSpan.FromSeconds(_settings.MinForcedRefreshSeconds))
|
||||
return Task.CompletedTask;
|
||||
|
||||
var db = _db;
|
||||
var settings = _settings;
|
||||
var groupId = settings.GroupId64;
|
||||
var key = Config.SteamWebApiKey;
|
||||
var cancel = _unloading.Token;
|
||||
_groupAttemptAt = DateTime.UtcNow;
|
||||
return _groupRefresh = Task.Run(async () =>
|
||||
_checkedAt[steamId] = DateTime.UtcNow;
|
||||
var task = Task.Run(async () =>
|
||||
{
|
||||
HashSet<ulong>? members = null;
|
||||
var (result, problem) = (Membership.Unknown, (string?)null);
|
||||
try
|
||||
{
|
||||
members = await SteamGroup.FetchMembersAsync(groupId, cancel);
|
||||
if (members.Count == 0)
|
||||
{
|
||||
Logger.LogWarning("Steam group {Group}'s member list came back empty, keeping the old one", groupId);
|
||||
members = null;
|
||||
(result, problem) = await SteamApi.CheckAsync(key, steamId, settings.GroupId64, cancel);
|
||||
}
|
||||
}
|
||||
catch (Exception ex) when (!cancel.IsCancellationRequested)
|
||||
{
|
||||
Logger.LogWarning("Couldn't fetch Steam group {Group}'s member list, keeping the old one: {Error}",
|
||||
groupId, ex.Message);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
catch (OperationCanceledException) when (cancel.IsCancellationRequested)
|
||||
{
|
||||
return;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Only the type: messages could quote the request, key and all.
|
||||
(result, problem) = (Membership.Unknown, $"unexpected {ex.GetType().Name}");
|
||||
}
|
||||
|
||||
if (members != null)
|
||||
if (result == Membership.Unknown)
|
||||
{
|
||||
Logger.LogWarning("Couldn't check {SteamId} with Steam, keeping what the database has: {Problem}", steamId, problem);
|
||||
}
|
||||
else
|
||||
{
|
||||
try
|
||||
{
|
||||
await db.SaveGroupAsync(members, settings.Now());
|
||||
await db.SetGroupMemberAsync(steamId, result == Membership.Member, settings.Now());
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// The fetched list is still used; it just won't survive a restart.
|
||||
Logger.LogError("Couldn't save the Steam group list to gv_verified: {Error}", ex.Message);
|
||||
// The answer still counts until a restart; it just isn't saved.
|
||||
Logger.LogError("Couldn't save {SteamId}'s group membership to gv_verified: {Error}", steamId, ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -302,15 +356,26 @@ public class GroupVerify : BasePlugin, IGroupVerifyBackend
|
|||
return;
|
||||
await Server.NextFrameAsync(() =>
|
||||
{
|
||||
_groupRefresh = null;
|
||||
// Dropped if the group changed or verification was turned off while fetching.
|
||||
if (members == null || groupId != _settings.GroupId64 || !IsActive)
|
||||
_checks.Remove(steamId);
|
||||
// Dropped if the group changed or verification was turned off while asking.
|
||||
if (settings.GroupId64 != _settings.GroupId64 || !IsActive)
|
||||
return;
|
||||
_groupMembers = members;
|
||||
_groupFetchedAt = DateTime.UtcNow;
|
||||
EvaluateAll();
|
||||
if (result == Membership.Unknown)
|
||||
{
|
||||
_checkProblem[steamId] = problem ?? "no reason given";
|
||||
return;
|
||||
}
|
||||
_checkProblem.Remove(steamId);
|
||||
_membership[steamId] = result == Membership.Member;
|
||||
foreach (var player in Utilities.GetPlayers())
|
||||
{
|
||||
if (player.AuthorizedSteamID?.SteamId64 == steamId)
|
||||
Evaluate(player);
|
||||
}
|
||||
});
|
||||
});
|
||||
_checks[steamId] = task;
|
||||
return task;
|
||||
}
|
||||
|
||||
// --- Verification (game thread) ----------------------------------------------------------
|
||||
|
|
@ -355,9 +420,13 @@ public class GroupVerify : BasePlugin, IGroupVerifyBackend
|
|||
return HookResult.Continue;
|
||||
}
|
||||
|
||||
// Evaluated at once from what the database knows, then checked with Steam, which may change it.
|
||||
private void OnClientAuthorized(int playerSlot, SteamID steamId)
|
||||
{
|
||||
Evaluate(Utilities.GetPlayerFromSlot(playerSlot));
|
||||
var player = Utilities.GetPlayerFromSlot(playerSlot);
|
||||
Evaluate(player);
|
||||
if (player is { IsValid: true, IsBot: false, IsHLTV: false })
|
||||
CheckMembership(steamId.SteamId64);
|
||||
}
|
||||
|
||||
private void OnClientDisconnect(int playerSlot)
|
||||
|
|
@ -442,18 +511,15 @@ public class GroupVerify : BasePlugin, IGroupVerifyBackend
|
|||
});
|
||||
}
|
||||
|
||||
// Brings the group list and database up to date if they're older than
|
||||
// Rechecks each target with Steam and rereads the database, where those are older than
|
||||
// min_forced_refresh_seconds, then answers.
|
||||
private void Run(Caller? caller, string commandString, List<Target> targets)
|
||||
{
|
||||
LogCommand(caller, commandString);
|
||||
|
||||
var maxAge = TimeSpan.FromSeconds(_settings.MinForcedRefreshSeconds);
|
||||
var now = DateTime.UtcNow;
|
||||
var pending = new List<Task>();
|
||||
if (_groupAttemptAt == null || now - _groupAttemptAt > maxAge)
|
||||
pending.Add(RefreshGroup());
|
||||
if (_dbAttemptAt == null || now - _dbAttemptAt > maxAge)
|
||||
var pending = targets.Select(t => CheckMembership(t.SteamId)).ToList();
|
||||
if (_dbAttemptAt == null || DateTime.UtcNow - _dbAttemptAt > maxAge)
|
||||
pending.Add(ReloadDatabase());
|
||||
|
||||
if (pending.All(t => t.IsCompleted))
|
||||
|
|
@ -493,7 +559,10 @@ public class GroupVerify : BasePlugin, IGroupVerifyBackend
|
|||
{
|
||||
var name = online?.PlayerName ?? target.Name;
|
||||
var where = online == null ? " (offline)" : "";
|
||||
lines.Add($"{ChatColors.Gold}{name}{ChatColors.Default} [{target.SteamId}]{where}: {Describe(status)}");
|
||||
var unchecked_ = _checkProblem.TryGetValue(target.SteamId, out var problem)
|
||||
? $" {ChatColors.Grey}(couldn't check with Steam: {problem}; using the database){ChatColors.Default}"
|
||||
: "";
|
||||
lines.Add($"{ChatColors.Gold}{name}{ChatColors.Default} [{target.SteamId}]{where}: {Describe(status)}{unchecked_}");
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -531,11 +600,9 @@ public class GroupVerify : BasePlugin, IGroupVerifyBackend
|
|||
if (!_settings.Active)
|
||||
return DescribeState();
|
||||
|
||||
string group;
|
||||
if (_groupFetchedAt is { } fetched)
|
||||
group = $"group list {_groupMembers.Count} members, {FormatSpan(DateTime.UtcNow - fetched)} old";
|
||||
else
|
||||
group = $"group list {_groupMembers.Count} members from the database (Steam not reached yet)";
|
||||
var group = Config.SteamWebApiKey.Length == 0
|
||||
? "no Steam Web API key (GroupVerify.json), so group membership isn't being checked"
|
||||
: $"group membership checked with Steam on join; {_groupRows.Count} known members";
|
||||
|
||||
var db = _dbLoadedAt is { } loaded ? $"overrides {FormatSpan(DateTime.UtcNow - loaded)} old" : "overrides not loaded";
|
||||
return $"{group}; {db}.";
|
||||
|
|
@ -558,7 +625,10 @@ public class GroupVerify : BasePlugin, IGroupVerifyBackend
|
|||
private string DescribeState()
|
||||
{
|
||||
if (_settings.Active)
|
||||
return $"{ChatColors.Green}On{ChatColors.Default}, Steam group {_settings.GroupId64}.";
|
||||
{
|
||||
var tag = _settings.ClanTag.Length > 0 ? $"clan tag \"{_settings.ClanTag}\"" : "no clan tag";
|
||||
return $"{ChatColors.Green}On{ChatColors.Default}, Steam group {_settings.GroupId64}, {tag}.";
|
||||
}
|
||||
if (!_settings.Enabled)
|
||||
{
|
||||
var group = _settings.GroupId64 == 0 ? "no Steam group set" : $"Steam group {_settings.GroupId64}";
|
||||
|
|
@ -649,6 +719,29 @@ public class GroupVerify : BasePlugin, IGroupVerifyBackend
|
|||
ChangeSetting(player, command, "group_id64", groupId.ToString(), s => s with { GroupId64 = groupId });
|
||||
}
|
||||
|
||||
[ConsoleCommand("css_gv_clantag", "Shows or sets the scoreboard tag verified players without a rank get; - clears it")]
|
||||
[CommandHelper(usage: "[tag|-]")]
|
||||
[RequiresPermissions(SettingsPermission)]
|
||||
public void OnClanTag(CCSPlayerController? player, CommandInfo command)
|
||||
{
|
||||
if (command.ArgCount < 2)
|
||||
{
|
||||
var current = _settings.ClanTag.Length > 0 ? $"\"{_settings.ClanTag}\"" : "not set";
|
||||
command.ReplyToCommand($"[Verify] Clan tag for verified players: {current}. {StripColors(DescribeState())}");
|
||||
return;
|
||||
}
|
||||
|
||||
var tag = command.ArgString.Trim().Trim('"');
|
||||
if (tag == "-")
|
||||
tag = "";
|
||||
if (!Settings.ValidClanTag(tag))
|
||||
{
|
||||
command.ReplyToCommand($"[Verify] A clan tag is at most {Settings.ClanTagMaxLength} characters, without quotes, ; or control characters. - clears it.");
|
||||
return;
|
||||
}
|
||||
ChangeSetting(player, command, "clan_tag", tag, s => s with { ClanTag = tag });
|
||||
}
|
||||
|
||||
private void ChangeSetting(CCSPlayerController? player, CommandInfo command, string name, string value,
|
||||
Func<Settings, Settings> change)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -12,6 +12,9 @@ public interface IGroupVerifyBackend
|
|||
|
||||
/// <summary>See <see cref="IGroupVerifyApi.GetStatus"/>.</summary>
|
||||
VerifyStatus GetStatus(ulong steamId64);
|
||||
|
||||
/// <summary>The configured clan tag; the bridge blanks it while inactive.</summary>
|
||||
string ClanTag { get; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -34,9 +37,15 @@ public sealed class GroupVerifyBridge : IGroupVerifyApi
|
|||
/// <inheritdoc />
|
||||
public event Action<CCSPlayerController, VerifyStatus>? OnVerifiedChanged;
|
||||
|
||||
/// <inheritdoc />
|
||||
public event Action? OnClanTagChanged;
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool IsActive => _backend?.IsActive ?? false;
|
||||
|
||||
/// <inheritdoc />
|
||||
public string ClanTag => _backend is { IsActive: true } backend ? backend.ClanTag : "";
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool IsVerified(CCSPlayerController player)
|
||||
{
|
||||
|
|
@ -56,11 +65,36 @@ public sealed class GroupVerifyBridge : IGroupVerifyApi
|
|||
/// <summary>Called by GroupVerify on load.</summary>
|
||||
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)
|
||||
{
|
||||
if (ReferenceEquals(_backend, backend))
|
||||
if (!ReferenceEquals(_backend, backend))
|
||||
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>
|
||||
|
|
|
|||
|
|
@ -43,6 +43,19 @@ public interface IGroupVerifyApi
|
|||
/// so handlers should be idempotent.
|
||||
/// </summary>
|
||||
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>
|
||||
|
|
|
|||
|
|
@ -2,12 +2,12 @@
|
|||
-- gv_migrations (like SimpleAdmin's sa_migrations, kept apart so neither confuses the other).
|
||||
-- Times are wall-clock values in gv_settings' timezone, which should match SimpleAdmin's Timezone.
|
||||
|
||||
-- One row per player and source. A player counts as verified if they're in the Steam group's
|
||||
-- member list, or have a row with `override` = 1 whose expires_at is NULL (never) or still ahead.
|
||||
-- source 'group': written by the plugin for everyone in the group list, `override` 0. Removed
|
||||
-- when they leave the group. The live list decides, not these rows.
|
||||
-- One row per player and source. A player counts as verified if they have a 'group' row, or a
|
||||
-- row with `override` = 1 whose expires_at is NULL (never) or still ahead.
|
||||
-- source 'group': written by the plugin when Steam's Web API says they're in the group (checked
|
||||
-- as they join), `override` 0. Deleted when Steam says they aren't.
|
||||
-- source 'manual': written by the web panel, `override` 1, expires_at NULL or a date.
|
||||
-- updated_at is when the plugin last saw a group row in the list.
|
||||
-- updated_at is when Steam last confirmed a group row.
|
||||
CREATE TABLE IF NOT EXISTS `gv_verified` (
|
||||
`steamid64` bigint(20) NOT NULL,
|
||||
`source` enum('group','manual') NOT NULL,
|
||||
|
|
|
|||
77
README.md
77
README.md
|
|
@ -4,9 +4,21 @@ Marks players as **verified** when they're in our Steam group, or when staff hav
|
|||
manual override on the web panel. It gives out nothing itself: other plugins ask it through its
|
||||
API (`GroupVerifyApi`, see [For other plugins](#for-other-plugins)) and hand out their own perks.
|
||||
|
||||
Everything lives in CS2-SimpleAdmin's MySQL database, reached through SimpleAdmin's API
|
||||
(`GetConnectionString()`), so there's no config file. Needs CS2-SimpleAdmin on MySQL; on SQLite,
|
||||
or without SimpleAdmin, it logs an error and verifies nobody.
|
||||
Its data and settings live in CS2-SimpleAdmin's MySQL database, reached through SimpleAdmin's API
|
||||
(`GetConnectionString()`). Needs CS2-SimpleAdmin on MySQL; on SQLite, or without SimpleAdmin, it
|
||||
logs an error and verifies nobody. The one exception is the Steam Web API key, a secret, which goes
|
||||
in `configs/plugins/GroupVerify/GroupVerify.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"SteamWebApiKey": "<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
|
||||
|
||||
|
|
@ -15,17 +27,19 @@ verified, including players with an override, and other plugins see `IsActive ==
|
|||
install is enabled with no group, so it's off until you set one:
|
||||
|
||||
```
|
||||
css_gv_group 103582791429521408 set the group (its 64-bit ID) and fetch its members now
|
||||
css_gv_group 103582791429521408 set the group (its 64-bit ID) and check everyone online
|
||||
css_gv_group 0 clear the group: verification is off
|
||||
css_gv_group show whether it's on, the group, and how old the list is
|
||||
css_gv_group show whether it's on and the group
|
||||
css_gv_disable turn it off, keeping the group
|
||||
css_gv_enable turn it back on
|
||||
css_gv_clantag zio the scoreboard tag for verified players without a rank (- clears it)
|
||||
```
|
||||
|
||||
These need `@css/root` (an `admin_overrides.json` entry for a command changes that) and work from
|
||||
the server console and RCON. Changes are saved to `gv_settings` and take effect at once: turning
|
||||
it off tells other plugins every verified player stopped being verified, and turning it on (or
|
||||
changing the group) fetches the member list straight away.
|
||||
changing the group) checks everyone on the server with Steam straight away. Changing the group
|
||||
deletes the saved members of the old one.
|
||||
|
||||
The group ID is the number in `<groupID64>` at
|
||||
`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
|
||||
|
||||
- **Steam group.** The group's public member list
|
||||
(`https://steamcommunity.com/gid/<group_id64>/memberslistxml/?xml=1`, every `nextPageLink` page)
|
||||
is fetched off the game thread every `refresh_minutes`. A failed or empty fetch keeps the old
|
||||
list. Each good fetch is saved to `gv_verified` as `source = 'group'` rows, and the rows of
|
||||
anyone who left are deleted; at startup those rows stand in for the list until Steam answers.
|
||||
- **Steam group, checked per player as they join.** Once Steam has authorized a player, they're
|
||||
verified straight away from what the database knows, then GroupVerify asks the Steam Web API
|
||||
which groups they're in
|
||||
(`https://api.steampowered.com/ISteamUser/GetUserGroupList/v1/?key=<key>&steamid=<steamid64>`,
|
||||
off the game thread). In the group: their `source = 'group'` row is added or refreshed. Not in
|
||||
it: the row is deleted. Steam wouldn't say (a private profile, a bad key, an outage, a rate
|
||||
limit): nothing changes, what the database has stands, and the log says why. A player is asked
|
||||
about at most once per `min_forced_refresh_seconds`, so reconnecting doesn't spam Steam.
|
||||
- **Only on join.** A player who leaves the group while on the server stays verified until they
|
||||
next join (or someone runs `css_verify` on them).
|
||||
- **Overrides.** `gv_verified` is reread every `db_reload_seconds`, so web panel changes show up
|
||||
without a restart. Expired rows are ignored, and deleted on each reload.
|
||||
- **Verified** = in the group list, **or** an unexpired row with `override` = 1.
|
||||
- Online players are checked when they connect (once Steam has authorized them), after each group
|
||||
fetch, after each database reload and after each settings command. Other plugins get
|
||||
`OnVerifiedChanged` only when a player's status changes. Their state is dropped when they
|
||||
disconnect.
|
||||
- **Verified** = a `group` row (or this session's Steam answer, which wins), **or** an unexpired
|
||||
row with `override` = 1.
|
||||
- Online players are re-evaluated after each Steam answer, database reload and settings command.
|
||||
Other plugins get `OnVerifiedChanged` only when a player's status changes. Their state is
|
||||
dropped when they disconnect.
|
||||
- **Why not the clan tag?** The tag shown on the scoreboard is CS2-Tags' rank tag on our server,
|
||||
not the player's, and the group a client says it represents (`cl_clanid`) is a client setting
|
||||
anyone can set to any group. Only Steam's own answer is trusted.
|
||||
|
||||
## `css_verify` (`!verify`)
|
||||
|
||||
|
|
@ -53,10 +75,11 @@ that isn't a Steam group ID.
|
|||
(SteamID64, `STEAM_X:Y:Z` or `[U:1:N]`), which works for offline players too. No target means
|
||||
`@me`. Works from the server console.
|
||||
|
||||
If the group list or the database was last loaded more than `min_forced_refresh_seconds` ago,
|
||||
both are brought up to date first. Then the targets are re-evaluated, and the answer goes to chat:
|
||||
one line per player for up to 5 targets (verified by Steam group, override with its expiry, or
|
||||
not verified), a summary for more, and how old the group list and overrides are. Every use is
|
||||
Each target (offline SteamIDs too) is checked with Steam again, and the database reread, where
|
||||
that was last done more than `min_forced_refresh_seconds` ago. Then the targets are re-evaluated,
|
||||
and the answer goes to chat: one line per player for up to 5 targets (verified by Steam group,
|
||||
override with its expiry, or not verified, and why Steam couldn't be asked if it couldn't), a
|
||||
summary for more, and how old the overrides are. Every use is
|
||||
logged through SimpleAdmin's `LogCommand` (server log and its Discord log) and the plugin's log.
|
||||
|
||||
**Who can use it:** Admin and above, plus Guardian. A player qualifies when one of their
|
||||
|
|
@ -86,7 +109,7 @@ match SimpleAdmin's `Timezone` (as the web panel already assumes for SimpleAdmin
|
|||
| `expires_at` | `datetime NULL` | NULL = never. Expired rows are ignored, then deleted. |
|
||||
| `added_by` | `varchar(128)` | free text: who added it (`steam-group` for group rows) |
|
||||
| `created_at` | `datetime` | first added |
|
||||
| `updated_at` | `datetime` | group rows: last seen in the list |
|
||||
| `updated_at` | `datetime` | group rows: when Steam last confirmed them |
|
||||
|
||||
One row per player and source, so a manual override and a group row never overwrite each other.
|
||||
`override` is backticked in the plugin's SQL; do the same, it's close to a reserved word.
|
||||
|
|
@ -102,7 +125,8 @@ ON DUPLICATE KEY UPDATE `override` = 1, expires_at = VALUES(expires_at),
|
|||
|
||||
`NOW()` is only right if MySQL's time zone matches `timezone`; otherwise pass the time from the
|
||||
panel. To remove one: `DELETE FROM gv_verified WHERE steamid64 = ? AND source = 'manual'`. Leave
|
||||
`group` rows alone: the next fetch rewrites them, and they never verify anyone on their own.
|
||||
`group` rows alone: they verify the player, and the next Steam check as they join adds or deletes
|
||||
them.
|
||||
|
||||
### `gv_settings`
|
||||
|
||||
|
|
@ -114,12 +138,12 @@ applies at the next reload (`db_reload_seconds`), timers included.
|
|||
|------------------------------|------------------|-----------------------------------------------------------------|
|
||||
| `enabled` | `1` | `0` turns verification off (`css_gv_enable`/`css_gv_disable`) |
|
||||
| `group_id64` | `0` | the Steam group's 64-bit ID; `0` turns it off (`css_gv_group`) |
|
||||
| `refresh_minutes` | `10` | how often the member list is fetched |
|
||||
| `min_forced_refresh_seconds` | `60` | `css_verify` refreshes only data older than this |
|
||||
| `min_forced_refresh_seconds` | `60` | a player is rechecked with Steam at most this often |
|
||||
| `db_reload_seconds` | `60` | how often `gv_settings` and `gv_verified` are reread |
|
||||
| `timezone` | `UTC` | time zone of the `datetime` columns; match SimpleAdmin's |
|
||||
| `verify_min_rank` | `#rank/admin` | `css_verify` needs this group's immunity or higher |
|
||||
| `verify_extra_ranks` | `#rank/guardian` | comma-separated groups that may also use `css_verify` |
|
||||
| `clan_tag` | (blank) | scoreboard tag for verified players without a rank, max 12 |
|
||||
|
||||
## For other plugins
|
||||
|
||||
|
|
@ -135,6 +159,11 @@ Use it on the game thread:
|
|||
- `OnVerifiedChanged(player, status)`: fired when an online player becomes verified (including
|
||||
just after connecting) or stops being verified (left the group, override ran out, or
|
||||
verification was turned off). Not fired on disconnect.
|
||||
- `ClanTag`: the scoreboard tag for verified players who have no rank tag (`css_gv_clantag`, the
|
||||
`clan_tag` setting), "" when none is set or while `IsActive` is false. GroupVerify doesn't set
|
||||
tags itself; CS2-Tags (1.0.4c-zio6+) does.
|
||||
- `OnClanTagChanged()`: fired when `ClanTag` changes, including to or from "" as GroupVerify is
|
||||
turned on or off, loaded or unloaded.
|
||||
|
||||
In the other plugin, copy `GroupVerifyApi.dll` from the server's `shared/GroupVerifyApi/` (or
|
||||
`GroupVerifyApi/bin/Release/net10.0/` here) to its `lib/`, and reference it compile-only. It must
|
||||
|
|
|
|||
32
Settings.cs
32
Settings.cs
|
|
@ -4,12 +4,12 @@ namespace GroupVerify;
|
|||
internal sealed record Settings(
|
||||
bool Enabled,
|
||||
ulong GroupId64,
|
||||
int RefreshMinutes,
|
||||
int MinForcedRefreshSeconds,
|
||||
int DbReloadSeconds,
|
||||
TimeZoneInfo TimeZone,
|
||||
string VerifyMinRank,
|
||||
string[] VerifyExtraRanks)
|
||||
string[] VerifyExtraRanks,
|
||||
string ClanTag)
|
||||
{
|
||||
// Written to gv_settings when missing. The rank defaults are our real SimpleAdmin groups (see
|
||||
// the fork's Helper.StaffRanks and the web panel's site config). Only the names are set here:
|
||||
|
|
@ -18,12 +18,12 @@ internal sealed record Settings(
|
|||
[
|
||||
("enabled", "1"), // 0 turns verification off (css_gv_enable/css_gv_disable)
|
||||
("group_id64", "0"), // the Steam group's 64-bit ID (103582791...); 0 turns verification off (css_gv_group)
|
||||
("refresh_minutes", "10"), // how often the group's member list is fetched
|
||||
("min_forced_refresh_seconds", "60"), // css_verify refetches/reloads only when older than this
|
||||
("min_forced_refresh_seconds", "60"), // a player is rechecked with Steam (css_verify, rejoining) at most this often
|
||||
("db_reload_seconds", "60"), // how often gv_verified and gv_settings are reread
|
||||
("timezone", "UTC"), // must match CS2-SimpleAdmin's Timezone
|
||||
("verify_min_rank", "#rank/admin"), // css_verify: this group's immunity or higher
|
||||
("verify_extra_ranks", "#rank/guardian") // css_verify: these groups too, comma-separated
|
||||
("verify_extra_ranks", "#rank/guardian"), // css_verify: these groups too, comma-separated
|
||||
("clan_tag", "") // scoreboard tag for verified players without a rank (css_gv_clantag)
|
||||
];
|
||||
|
||||
public static readonly Settings Default = Parse(new Dictionary<string, string>(), _ => { });
|
||||
|
|
@ -61,14 +61,32 @@ internal sealed record Settings(
|
|||
return new Settings(
|
||||
Get("enabled") != "0",
|
||||
groupId,
|
||||
GetInt("refresh_minutes", 1),
|
||||
GetInt("min_forced_refresh_seconds", 0),
|
||||
GetInt("db_reload_seconds", 10),
|
||||
timeZone,
|
||||
Get("verify_min_rank"),
|
||||
Get("verify_extra_ranks").Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries));
|
||||
Get("verify_extra_ranks").Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries),
|
||||
ParseClanTag(Get("clan_tag"), warn));
|
||||
}
|
||||
|
||||
// Steam's own group tags are at most 12 characters. Quotes and control characters are refused,
|
||||
// since the tag travels in console commands.
|
||||
public const int ClanTagMaxLength = 12;
|
||||
|
||||
public static bool ValidClanTag(string tag) =>
|
||||
tag.Length <= ClanTagMaxLength && !tag.Any(c => char.IsControl(c) || c == '"' || c == ';');
|
||||
|
||||
private static string ParseClanTag(string tag, Action<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.
|
||||
public bool Active => Enabled && GroupId64 != 0;
|
||||
|
||||
|
|
|
|||
97
SteamApi.cs
Normal file
97
SteamApi.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
16
release.sh
16
release.sh
|
|
@ -15,15 +15,21 @@
|
|||
# addons/counterstrikesharp/shared/GroupVerifyApi/ GroupVerifyApi.dll, .pdb, .xml: the API other
|
||||
# plugins compile against (never in a plugin folder)
|
||||
#
|
||||
# No configs/: GroupVerify has no config file. Its settings and data are in CS2-SimpleAdmin's
|
||||
# database (gv_settings, gv_verified), which the plugin creates itself, so an upgrade never touches them.
|
||||
# No configs/: the plugin writes configs/plugins/GroupVerify/GroupVerify.json (the Steam Web API key)
|
||||
# itself when it's missing, and its settings and data are in CS2-SimpleAdmin's database (gv_settings,
|
||||
# gv_verified), so an upgrade never touches them.
|
||||
set -euo pipefail
|
||||
|
||||
FORGEJO_URL="https://git.zio.sh"
|
||||
OWNER="cs2"
|
||||
REPO="GroupVerify"
|
||||
# Placeholder - replace, or set FORGEJO_TOKEN in the environment instead. Needs write:repository.
|
||||
# Only uploading needs it; downloading from the public repo doesn't. Don't commit a real token.
|
||||
# Needs write:repository. Only uploading needs it; downloading from the public repo doesn't. From the
|
||||
# environment, or else from .forgejo-token in the cs2/ folder above every plugin (outside their
|
||||
# repos, mode 600). Don't commit a real token.
|
||||
TOKEN_FILE="$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")/../../.forgejo-token"
|
||||
if [[ -z "${FORGEJO_TOKEN:-}" && -r "$TOKEN_FILE" ]]; then
|
||||
FORGEJO_TOKEN="$(tr -d '[:space:]' < "$TOKEN_FILE")"
|
||||
fi
|
||||
FORGEJO_TOKEN="${FORGEJO_TOKEN:-CHANGE_ME_FORGEJO_TOKEN}"
|
||||
|
||||
# Only these ship from the publish output. CounterStrikeSharp.API.dll is excluded by the csproj
|
||||
|
|
@ -43,7 +49,7 @@ if [[ -z "$TAG" ]]; then
|
|||
exit 1
|
||||
fi
|
||||
if [[ "$FORGEJO_TOKEN" == "CHANGE_ME_FORGEJO_TOKEN" ]]; then
|
||||
echo "Set FORGEJO_TOKEN (edit release.sh or export it) first." >&2
|
||||
echo "Set FORGEJO_TOKEN (export it, or put it in $TOKEN_FILE) first." >&2
|
||||
exit 1
|
||||
fi
|
||||
for tool in podman jq curl git; do
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue