-
Notifications
You must be signed in to change notification settings - Fork 1
Expand file tree
/
Copy pathProgram.cs
More file actions
186 lines (174 loc) · 9.58 KB
/
Copy pathProgram.cs
File metadata and controls
186 lines (174 loc) · 9.58 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
[assembly: InternalsVisibleTo("csgoSkinStatistics.Tests")]
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddControllers();
builder.Services.AddResponseCompression(options =>
{
options.EnableForHttps = true;
options.Providers.Add<Microsoft.AspNetCore.ResponseCompression.GzipCompressionProvider>();
options.Providers.Add<Microsoft.AspNetCore.ResponseCompression.BrotliCompressionProvider>();
options.MimeTypes = Microsoft.AspNetCore.ResponseCompression.ResponseCompressionDefaults.MimeTypes.Concat(
["application/javascript", "text/css", "text/html", "text/json", "text/plain"]);
});
builder.Services.AddHttpClient();
// Dedicated client for steamcommunity.com calls (inventory, profile, vanity resolve). Traffic is
// bursty/low, so we keep pooled connections alive far longer than the defaults to avoid paying a
// fresh TLS handshake (~100ms) on each cold request. PooledConnectionLifetime still rotates
// connections periodically for DNS hygiene, and an infinite handler lifetime stops IHttpClientFactory
// from recycling the handler (which would otherwise drop the warm connection pool every 2 minutes).
builder.Services.AddHttpClient("steam")
.ConfigurePrimaryHttpMessageHandler(() => new SocketsHttpHandler
{
PooledConnectionIdleTimeout = TimeSpan.FromMinutes(10),
PooledConnectionLifetime = TimeSpan.FromMinutes(30),
})
.SetHandlerLifetime(Timeout.InfiniteTimeSpan);
// Skinport's /v1/items feed is Brotli-only (a request without Accept-Encoding: br 406s), so this
// client auto-negotiates and decompresses it. AutomaticDecompression.All includes Brotli and adds
// the Accept-Encoding header itself.
builder.Services.AddHttpClient("skinport")
.ConfigurePrimaryHttpMessageHandler(() => new SocketsHttpHandler
{
AutomaticDecompression = System.Net.DecompressionMethods.All,
});
// Inventory response cache: /api/inventory results are cached by resolved SteamId64 for a few
// minutes so reload storms (and repeat viewers of the same inventory) don't each re-hit
// steamcommunity.com's inventory endpoint, which rate-limits per server IP. Bounded by *bytes* -
// each entry's Size is its serialized length - so total memory can never exceed SizeLimit no
// matter how many inventories are viewed, which matters on a small-memory host. A maxed 2000-item
// inventory serializes to ~3 MB, so 8 MB holds a couple of large ones plus several smaller ones;
// lower SizeLimit to tighten the footprint, raise it to cache more.
builder.Services.AddMemoryCache(options => options.SizeLimit = 8 * 1024 * 1024);
// Per-client-IP rate limiting on the API. Every uncached /api, /api/inventory and /api/profile
// call can trigger an outbound steamcommunity.com request, so an unthrottled client could relay
// traffic through our egress IP until Steam 429-bans it. A token bucket per IP bounds that while
// staying comfortably above the ~10 req/s a single inventory analysis makes (the client paces its
// per-item lookups 100ms apart). Limits live in the RateLimiting config section so they can be
// tuned without a redeploy. Only the API carries the "api" policy; static files are never limited.
var rateLimitConfig = builder.Configuration.GetSection("RateLimiting");
var tokenLimit = rateLimitConfig.GetValue("TokenLimit", 40);
var tokensPerPeriod = rateLimitConfig.GetValue("TokensPerPeriod", 20);
var replenishmentSeconds = rateLimitConfig.GetValue("ReplenishmentPeriodSeconds", 1.0);
var queueLimit = rateLimitConfig.GetValue("QueueLimit", 10);
builder.Services.AddRateLimiter(options =>
{
options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
options.AddPolicy("api", httpContext =>
{
// Partition by client IP. A single key ("unknown") for IP-less requests is deliberate:
// it caps that whole bucket rather than letting them each get their own allowance.
var clientIp = httpContext.Connection.RemoteIpAddress?.ToString() ?? "unknown";
return RateLimitPartition.GetTokenBucketLimiter(clientIp, _ => new TokenBucketRateLimiterOptions
{
TokenLimit = tokenLimit,
TokensPerPeriod = tokensPerPeriod,
ReplenishmentPeriod = TimeSpan.FromSeconds(replenishmentSeconds),
QueueLimit = queueLimit,
QueueProcessingOrder = QueueProcessingOrder.OldestFirst,
AutoReplenishment = true,
});
});
options.OnRejected = async (context, cancellationToken) =>
{
context.HttpContext.Response.StatusCode = StatusCodes.Status429TooManyRequests;
Console.WriteLine($"Rate limited {context.HttpContext.Connection.RemoteIpAddress} on {context.HttpContext.Request.Path}");
await context.HttpContext.Response.WriteAsJsonAsync(
new { error = "Too many requests. Please slow down and try again shortly." }, cancellationToken);
};
});
builder.Services.AddSingleton<SteamService>();
builder.Services.AddSingleton<DatabaseService>();
builder.Services.AddSingleton<ConstDataService>();
// Registered once and exposed both as itself (controllers enqueue into it) and as the
// hosted service that drains the queue.
builder.Services.AddSingleton<InventoryWarmService>();
builder.Services.AddHostedService(sp => sp.GetRequiredService<InventoryWarmService>());
// Skinport base prices: exposed as itself (controllers look prices up) and as the hosted service
// that refreshes them a few times a day.
builder.Services.AddSingleton<PriceService>();
builder.Services.AddHostedService(sp => sp.GetRequiredService<PriceService>());
var app = builder.Build();
// Defense-in-depth security headers on every response (via OnStarting so they apply even to the
// error responses written by UseExceptionHandler below). The CSP is conservative but tuned to what
// the app actually loads: same-origin scripts/styles - plus 'unsafe-inline', which the page's
// bootstrap script and the stylesheet media-swap onload handlers still rely on - Steam CDN images,
// and Google Fonts. frame-ancestors / X-Frame-Options block clickjacking. (See L9: if this ever
// runs behind a proxy that already sets these, drop the middleware.)
app.Use(async (context, next) =>
{
context.Response.OnStarting(() =>
{
var headers = context.Response.Headers;
headers["X-Content-Type-Options"] = "nosniff";
headers["X-Frame-Options"] = "DENY";
headers["Referrer-Policy"] = "strict-origin-when-cross-origin";
headers["Content-Security-Policy"] =
"default-src 'self'; " +
"script-src 'self' 'unsafe-inline'; " +
"style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; " +
"font-src 'self' https://fonts.gstatic.com; " +
"img-src 'self' data: https://*.steamstatic.com; " +
"connect-src 'self'; " +
"frame-ancestors 'none'; " +
"base-uri 'self'; " +
"object-src 'none'";
return Task.CompletedTask;
});
await next();
});
// Any unhandled exception from an endpoint becomes a generic 500 here, logged server-side. This
// keeps internal detail (paths, SQL, library internals) out of the response and means individual
// actions don't each need a copy-pasted catch-all.
app.UseExceptionHandler(errorApp => errorApp.Run(async context =>
{
var error = context.Features.Get<IExceptionHandlerFeature>()?.Error;
if (error != null)
{
Console.WriteLine($"Unhandled exception on {context.Request.Path}: {error.Message}");
Console.WriteLine(error.StackTrace);
}
context.Response.StatusCode = StatusCodes.Status500InternalServerError;
await context.Response.WriteAsJsonAsync(new { error = "Internal server error" });
}));
app.UseResponseCompression();
// /inventory now serves the unified single page (index.html); a #profile hash makes it open
// straight into the inventory view. Kept as a rewrite so old /inventory links still work.
app.UseRewriter(new RewriteOptions()
.AddRewrite("^inventory$", "index.html", skipRemainingRules: true));
app.UseDefaultFiles(); // Must be before UseStaticFiles
app.UseStaticFiles();
app.UseRouting();
app.UseRateLimiter();
app.MapControllers();
// Initialize database on startup
var dbService = app.Services.GetRequiredService<DatabaseService>();
await dbService.InitializeDatabaseAsync();
// Initialize Steam connection. Supervised: a boot-time failure (bad credentials, Steam outage)
// is logged rather than left as an unobserved exception, and ConnectAsync resets its running flag
// on failure so the on-demand reconnect in GetItemInfoAsync retries on the next lookup.
var steamService = app.Services.GetRequiredService<SteamService>();
_ = Task.Run(async () =>
{
try
{
await steamService.ConnectAsync();
}
catch (Exception ex)
{
Console.WriteLine($"Initial Steam connection failed: {ex.Message}");
}
});
// Initialize ConstDataService (loads const.json)
var constDataService = app.Services.GetRequiredService<ConstDataService>();
// Disconnect from Steam as part of the host's graceful shutdown (which Ctrl-C / SIGTERM already
// trigger) rather than from a Console.CancelKeyPress handler. The old handler tore Steam down and
// let the process die *around* the host, skipping request draining and hosted-service stop - and
// could dispose an account's RateLimitSemaphore out from under an in-flight GC request. Running on
// ApplicationStopping means the server has stopped accepting requests and in-flight ones have
// drained first.
var lifetime = app.Services.GetRequiredService<IHostApplicationLifetime>();
lifetime.ApplicationStopping.Register(() =>
{
Console.WriteLine("Application stopping, disconnecting from Steam...");
steamService.Disconnect();
});
app.Run();