-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathSteamApiClient.cs
More file actions
289 lines (241 loc) · 11.6 KB
/
Copy pathSteamApiClient.cs
File metadata and controls
289 lines (241 loc) · 11.6 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
using System.Collections.Concurrent;
using System.Text.Json;
using Microsoft.Extensions.Logging;
namespace PrimeGuard;
/// <summary>
/// Steam profile data relevant to deciding whether to let a player in.
/// Note: NONE of these fields is "has Prime" — Prime cannot be read reliably
/// from the server. They are proxy signals to filter F2P accounts / smurfs.
///
/// Key convention: an "unknown" value (null for bool?/int?, or -1 for level)
/// means "could not be queried" and must NEVER cause a kick. Players are only
/// kicked on verified data, so a Steam API failure/rate-limit won't boot legit users.
/// </summary>
public sealed class SteamUserInfo
{
/// <summary>true = private, false = public, null = could not be determined.</summary>
public bool? IsProfilePrivate { get; set; }
/// <summary>Steam level, or -1 if it could not be read.</summary>
public int SteamLevel { get; set; } = -1;
/// <summary>Account age in days, or null if it could not be determined.</summary>
public int? AccountAgeDays { get; set; }
/// <summary>CS2 playtime in minutes, or null when Steam won't reveal it.
/// Steam reports playtime_forever as 0 for every game when the player enables
/// "Always keep my total playtime private", which is indistinguishable from a real 0 —
/// so 0 is treated as unknown rather than as "never played".</summary>
public int? CS2PlaytimeMinutes { get; set; }
public int? CS2PlaytimeHours => CS2PlaytimeMinutes / 60;
/// <summary>true/false per Steam, null = ban status could not be queried.</summary>
public bool? IsVacBanned { get; set; }
public bool? IsGameBanned { get; set; }
/// <summary>Days since the player's most recent ban, or null if unknown.
/// NOTE: Steam only reports this for the latest ban of ANY type (VAC or game) —
/// there is no per-ban date, so this can't distinguish an old VAC from a recent game ban.</summary>
public int? DaysSinceLastBan { get; set; }
// Per-endpoint success flags. Used to decide whether the lookup is worth caching:
// caching a half-failed result would disable those checks for the whole TTL.
internal bool SummaryOk { get; set; }
internal bool BansOk { get; set; }
internal bool LevelOk { get; set; }
internal bool PlaytimeOk { get; set; }
/// <summary>True when every endpoint this profile needed actually answered.
/// Level and playtime are only required for public profiles (they can't be read otherwise).</summary>
internal bool IsComplete =>
SummaryOk && BansOk && (IsProfilePrivate != false || (LevelOk && PlaytimeOk));
private static string Show(bool? b) => b?.ToString() ?? "?";
public override string ToString() =>
$"private={Show(IsProfilePrivate)} level={(SteamLevel < 0 ? "?" : SteamLevel.ToString())} " +
$"age={(AccountAgeDays?.ToString() ?? "?")}d cs2Hours={(CS2PlaytimeHours?.ToString() ?? "hidden")} " +
$"vac={Show(IsVacBanned)} gameBan={Show(IsGameBanned)} " +
$"daysSinceLastBan={(DaysSinceLastBan?.ToString() ?? "?")}";
}
/// <summary>
/// Thin Steam Web API client. Makes only the calls it needs and never throws
/// outward: on any failure it leaves the field "unknown" (null / -1) so the
/// caller fails open instead of kicking because of an error.
/// </summary>
public sealed class SteamApiClient
{
private const int Cs2AppId = 730;
private readonly HttpClient _http;
private readonly string _key;
private readonly ILogger _logger;
// Per-SteamID cache so the same player reconnecting doesn't re-query the API.
private readonly TimeSpan _cacheTtl;
private readonly ConcurrentDictionary<ulong, CacheEntry> _cache = new();
public SteamApiClient(HttpClient http, string apiKey, ILogger logger, TimeSpan cacheTtl)
{
_http = http;
_key = apiKey;
_logger = logger;
_cacheTtl = cacheTtl;
}
private bool CacheEnabled => _cacheTtl > TimeSpan.Zero;
/// <summary>Drops every cached lookup. Lets admins re-check a player who just fixed
/// their profile instead of waiting out the TTL. Returns how many entries were dropped.</summary>
public int ClearCache()
{
int count = _cache.Count;
_cache.Clear();
return count;
}
public async Task<SteamUserInfo> FetchAsync(ulong steamId)
{
if (CacheEnabled &&
_cache.TryGetValue(steamId, out var cached) &&
DateTime.UtcNow - cached.Timestamp < _cacheTtl)
{
return cached.Info;
}
var info = await FetchFreshAsync(steamId);
// Only cache lookups where EVERY endpoint answered. Caching a half-failed result
// (e.g. the ban endpoint timed out) would disable that check for the whole TTL,
// so partial results are left uncached and retried on the next connect.
if (CacheEnabled && info.IsComplete)
{
Prune();
_cache[steamId] = new CacheEntry(DateTime.UtcNow, info);
}
return info;
}
private async Task<SteamUserInfo> FetchFreshAsync(ulong steamId)
{
var info = new SteamUserInfo();
// Privacy/account age and bans are independent: run them in parallel.
await Task.WhenAll(
FetchSummaryAsync(steamId, info),
FetchBansAsync(steamId, info));
// Level and hours only make sense (and can only be read) when the profile
// is confirmed public. If it's private or unknown, we don't query them.
if (info.IsProfilePrivate == false)
{
await Task.WhenAll(
FetchSteamLevelAsync(steamId, info),
FetchPlaytimeAsync(steamId, info));
}
return info;
}
/// <summary>Drops expired entries once the cache grows, to bound memory from
/// one-off visitors who never reconnect.</summary>
private void Prune()
{
if (_cache.Count < 256) return;
var now = DateTime.UtcNow;
foreach (var kv in _cache)
{
if (now - kv.Value.Timestamp >= _cacheTtl)
_cache.TryRemove(kv.Key, out _);
}
}
private readonly record struct CacheEntry(DateTime Timestamp, SteamUserInfo Info);
private async Task<JsonDocument?> GetAsync(string url)
{
try
{
using var resp = await _http.GetAsync(url);
if (!resp.IsSuccessStatusCode)
{
_logger.LogWarning("PrimeGuard: Steam Web API returned {code} for {url}", (int)resp.StatusCode, Redact(url));
return null;
}
var body = await resp.Content.ReadAsStringAsync();
return JsonDocument.Parse(body);
}
catch (Exception ex)
{
_logger.LogError("PrimeGuard: failed querying {url}: {msg}", Redact(url), ex.Message);
return null;
}
}
private string Redact(string url) => string.IsNullOrEmpty(_key) ? url : url.Replace(_key, "***");
private async Task FetchSummaryAsync(ulong steamId, SteamUserInfo info)
{
var url = $"https://api.steampowered.com/ISteamUser/GetPlayerSummaries/v2/?key={_key}&steamids={steamId}";
using var doc = await GetAsync(url);
// No valid response => IsProfilePrivate stays null (unknown) => fail open.
// NOTE: we never mark "private" on failure; only when Steam says so explicitly.
if (doc is null ||
!doc.RootElement.TryGetProperty("response", out var response) ||
!response.TryGetProperty("players", out var players) ||
players.ValueKind != JsonValueKind.Array ||
players.GetArrayLength() == 0)
{
return;
}
info.SummaryOk = true;
var player = players[0];
// Only decide privacy when Steam actually reports it (3 = public, anything else = private).
// If the field is absent/unparseable, leave IsProfilePrivate null (unknown => fail open),
// so a limited/edge-case account is never kicked on missing data.
if (player.TryGetProperty("communityvisibilitystate", out var vis) && vis.TryGetInt32(out var v))
info.IsProfilePrivate = v != 3;
if (player.TryGetProperty("timecreated", out var tc) && tc.TryGetInt64(out var created))
{
var createdUtc = DateTimeOffset.FromUnixTimeSeconds(created).UtcDateTime;
info.AccountAgeDays = Math.Max(0, (int)(DateTime.UtcNow - createdUtc).TotalDays);
}
}
private async Task FetchSteamLevelAsync(ulong steamId, SteamUserInfo info)
{
var url = $"https://api.steampowered.com/IPlayerService/GetSteamLevel/v1/?key={_key}&steamid={steamId}";
using var doc = await GetAsync(url);
// On failure, SteamLevel stays -1 (unknown) and the check is skipped.
if (doc is not null &&
doc.RootElement.TryGetProperty("response", out var r) &&
r.TryGetProperty("player_level", out var lvl) &&
lvl.TryGetInt32(out var level))
{
info.SteamLevel = level;
info.LevelOk = true;
}
}
private async Task FetchPlaytimeAsync(ulong steamId, SteamUserInfo info)
{
// include_played_free_games=1 is MANDATORY: CS2 is free-to-play, and without it
// accounts that didn't buy it won't show up and would report 0 hours.
var url = $"https://api.steampowered.com/IPlayerService/GetOwnedGames/v1/?key={_key}&steamid={steamId}&include_played_free_games=1&format=json";
using var doc = await GetAsync(url);
if (doc is null)
return; // request failed => PlaytimeOk stays false so this lookup isn't cached
// A valid response with no "games" array means the player hides their game details:
// that's a definitive answer, so the lookup still counts as complete.
info.PlaytimeOk = true;
// No games array => the player hides their game details, so playtime stays unknown.
if (!doc.RootElement.TryGetProperty("response", out var r) ||
!r.TryGetProperty("games", out var games) ||
games.ValueKind != JsonValueKind.Array)
{
return;
}
foreach (var g in games.EnumerateArray())
{
if (g.TryGetProperty("appid", out var appid) && appid.TryGetInt32(out var id) && id == Cs2AppId)
{
// A reported 0 means Steam is hiding the number (see CS2PlaytimeMinutes),
// so it's left null instead of being read as "never played".
if (g.TryGetProperty("playtime_forever", out var pt) && pt.TryGetInt32(out var minutes) && minutes > 0)
info.CS2PlaytimeMinutes = minutes;
break;
}
}
}
private async Task FetchBansAsync(ulong steamId, SteamUserInfo info)
{
var url = $"https://api.steampowered.com/ISteamUser/GetPlayerBans/v1/?key={_key}&steamids={steamId}";
using var doc = await GetAsync(url);
// No response => bans stay null (unknown) => no kick for bans.
if (doc is null ||
!doc.RootElement.TryGetProperty("players", out var players) ||
players.ValueKind != JsonValueKind.Array ||
players.GetArrayLength() == 0)
{
return;
}
info.BansOk = true;
var p = players[0];
info.IsVacBanned = p.TryGetProperty("VACBanned", out var vac) && vac.ValueKind == JsonValueKind.True;
info.IsGameBanned = p.TryGetProperty("NumberOfGameBans", out var gb) && gb.TryGetInt32(out var n) && n > 0;
if (p.TryGetProperty("DaysSinceLastBan", out var days) && days.TryGetInt32(out var d))
info.DaysSinceLastBan = d;
}
}