-
Notifications
You must be signed in to change notification settings - Fork 1
Expand file tree
/
Copy pathProgram.cs
More file actions
359 lines (325 loc) · 15.5 KB
/
Program.cs
File metadata and controls
359 lines (325 loc) · 15.5 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
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
using Amazon.DynamoDBv2;
using Amazon.S3;
using Amazon.SecretsManager;
using Amazon.SimpleEmailV2;
using Fido2NetLib;
using GAToolAPI.Attributes;
using GAToolAPI.AuthExtensions;
using GAToolAPI.Helpers;
using GAToolAPI.Jobs;
using GAToolAPI.Middleware;
using GAToolAPI.Services;
using GAToolAPI.Services.Auth;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.Authorization;
using Microsoft.Extensions.Caching.Distributed;
using Microsoft.Extensions.Caching.StackExchangeRedis;
using NewRelic.LogEnrichers.Serilog;
using NSwag;
using NSwag.Generation.Processors.Security;
using Serilog;
using Serilog.Events;
using StackExchange.Redis;
using ZiggyCreatures.Caching.Fusion;
using ZiggyCreatures.Caching.Fusion.Backplane.StackExchangeRedis;
using ZiggyCreatures.Caching.Fusion.Serialization.SystemTextJson;
Log.Logger = new LoggerConfiguration()
.MinimumLevel.Override("Microsoft", LogEventLevel.Information)
.Enrich.FromLogContext()
.Enrich.WithNewRelicLogsInContext()
.WriteTo.Console()
.CreateBootstrapLogger();
try
{
var builder = WebApplication.CreateBuilder(args);
// Fetch ECS task metadata (no-ops gracefully outside ECS)
var ecsMetadata = await EcsMetadataService.FetchAsync();
builder.Services.AddSingleton(ecsMetadata);
if (ecsMetadata.IsRunningOnEcs)
Log.Information("Running on ECS: Task {TaskId} in cluster {Cluster} ({AZ})",
ecsMetadata.TaskId, ecsMetadata.ClusterName, ecsMetadata.AvailabilityZone);
// Preload secrets from AWS Secrets Manager
var smClient = new AmazonSecretsManagerClient();
var secretNames = new[]
{
"FRCApiKey", "TBAApiKey", "FTCApiKey", "CasterstoolApiKey", "TOAApiKey",
"FRCCurrentSeason", "FTCCurrentSeason",
"MailChimpAPIKey", "MailchimpAPIURL", "MailchimpListID",
"NewRelicLicenseKey",
"MailchimpWebhookSecret"
};
var preloadedSecrets = await AwsSecretProvider.PreloadSecretsAsync(smClient, secretNames);
var secretProvider = new AwsSecretProvider(smClient, preloadedSecrets);
builder.Services.AddSerilog((services, lc) => lc
.ReadFrom.Configuration(builder.Configuration)
.ReadFrom.Services(services)
.Enrich.With(new EcsSerilogEnricher(ecsMetadata))
.MinimumLevel.Override("Microsoft.AspNetCore.Hosting", LogEventLevel.Warning)
.MinimumLevel.Override("Microsoft.AspNetCore.Mvc", LogEventLevel.Warning)
.MinimumLevel.Override("Microsoft.AspNetCore.Routing", LogEventLevel.Warning)
// CORS logs "CORS policy execution successful." at Information for every
// preflight + cross-origin request. Drop to Warning so failures still surface.
.MinimumLevel.Override("Microsoft.AspNetCore.Cors", LogEventLevel.Warning)
// FusionCache emits a lot of Information/Debug per cache op (factory,
// backplane, distributed cache). Warning is enough in production.
.MinimumLevel.Override("ZiggyCreatures.Caching.Fusion", LogEventLevel.Warning));
builder.Services.AddAuthentication(options =>
{
// Self-issued ES256 JWT is the only accepted scheme.
options.DefaultAuthenticateScheme = "GatoolJwt";
options.DefaultChallengeScheme = "GatoolJwt";
})
.AddJwtBearer("GatoolJwt", options =>
{
// Self-issued JWT: ECDSA P-256 signing key from Secrets Manager.
// Configured asynchronously below once the DI container is built.
options.RequireHttpsMetadata = false;
options.SaveToken = true;
options.Events = new JwtBearerEvents
{
OnMessageReceived = async ctx =>
{
if (ctx.Options.TokenValidationParameters.IssuerSigningKey == null)
{
var tokenSvc = ctx.HttpContext.RequestServices
.GetRequiredService<TokenService>();
var key = await tokenSvc.GetValidationKeyAsync(ctx.HttpContext.RequestAborted);
ctx.Options.TokenValidationParameters = tokenSvc.BuildValidationParameters(key);
}
}
};
});
builder.Services.AddAuthorizationBuilder()
.AddPolicy("user", policy =>
{
policy.AuthenticationSchemes = ["GatoolJwt"];
policy.Requirements.Add(new HasRoleRequirement("user"));
})
.AddPolicy("admin", policy =>
{
policy.AuthenticationSchemes = ["GatoolJwt"];
policy.Requirements.Add(new HasRoleRequirement("admin"));
});
builder.Services.AddSingleton<IAuthorizationHandler, HasRoleHandler>();
builder.Services.AddSingleton<ISecretProvider>(secretProvider);
builder.Services.AddSingleton<IAmazonSecretsManager>(smClient);
builder.Services.AddAWSService<IAmazonS3>();
builder.Services.AddAWSService<IAmazonDynamoDB>();
builder.Services.AddAWSService<IAmazonSimpleEmailServiceV2>();
// Custom auth services (email OTP + WebAuthn passkeys)
builder.Services.AddSingleton<AuthSigningKeyProvider>();
builder.Services.AddSingleton<OtpPepperProvider>();
builder.Services.AddSingleton<AuthRepository>();
builder.Services.AddSingleton<AuthEmailService>();
builder.Services.AddSingleton<RedisRateLimiter>();
builder.Services.AddSingleton<TokenService>();
builder.Services.AddSingleton<OtpService>();
builder.Services.AddScoped<PasskeyService>();
builder.Services.AddMemoryCache();
builder.Services.AddHttpClient();
builder.Services.AddSingleton<CommunityAaguidService>();
builder.Services.AddHostedService(sp => sp.GetRequiredService<CommunityAaguidService>());
// Fido2 / WebAuthn server. ServerDomain is the WebAuthn rpId — must be an apex
// domain or registrable suffix shared by all origins. gatool.org covers
// gatool.org + beta.gatool.org. Localhost dev gets its own override via env.
//
// We also wire up the FIDO Metadata Service so we can resolve AAGUIDs to
// human-readable authenticator names (e.g. "iCloud Keychain", "1Password",
// "YubiKey 5") on passkey registration. Metadata is fetched from
// mds3.fidoalliance.org and cached in Redis (via IDistributedCache below).
builder.Services.AddFido2(o =>
{
o.ServerDomain = builder.Configuration["WebAuthn:ServerDomain"] ?? "gatool.org";
o.ServerName = "gatool";
o.Origins = (builder.Configuration["WebAuthn:Origins"]
?? "https://gatool.org,https://beta.gatool.org,http://localhost:3000")
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.ToHashSet();
o.TimestampDriftTolerance = 300_000;
})
.AddCachedMetadataService(b => b.AddFidoMetadataRepository());
// CORS allowlist: only the first-party UI origins (prod, beta, local dev)
// are permitted to call the API from a browser. Swagger UI is hosted on
// the same origin as the API so it doesn't need an entry (browsers omit
// the Origin header on same-origin requests). Non-browser clients (curl,
// server-to-server) are unaffected — CORS is a browser-enforced policy.
//
// Configurable via the Cors:AllowedOrigins env var (comma-separated) so
// the list can be adjusted without a redeploy.
var corsOrigins = (builder.Configuration["Cors:AllowedOrigins"]
?? "https://gatool.org,https://beta.gatool.org,http://localhost:3000")
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
builder.Services.AddCors(options =>
{
options.AddDefaultPolicy(b => b
.WithOrigins(corsOrigins)
.AllowAnyMethod()
.AllowAnyHeader()
.AllowCredentials());
});
builder.Services.AddControllers(options =>
{
options.Filters.Add<BulkRequestEnrichmentFilter>();
});
// Add HttpContextAccessor for RedisCache.IgnoreCurrentRequest() functionality
builder.Services.AddHttpContextAccessor();
builder.Services.AddOpenApiDocument(config =>
{
config.Title = "GATool API";
config.Version = "v3";
config.AddSecurity("JWT", [], new OpenApiSecurityScheme
{
Type = OpenApiSecuritySchemeType.ApiKey,
Name = "Authorization",
In = OpenApiSecurityApiKeyLocation.Header,
Description = "Type into the textbox: Bearer {your JWT token}."
});
config.OperationProcessors.Add(new AspNetCoreOperationSecurityScopeProcessor("JWT"));
});
var redisConfig = new ConfigurationOptions
{
EndPoints =
{
{
// ReSharper disable once NotResolvedInText
builder.Configuration["Redis:Host"] ?? throw new ArgumentNullException("Redis:Host"),
// ReSharper disable once NotResolvedInText
int.Parse(builder.Configuration["Redis:Port"] ?? throw new ArgumentNullException("Redis:Port"))
}
},
Password = builder.Configuration["Redis:Password"] ?? null,
Ssl = builder.Configuration.GetValue<bool?>("Redis:UseTls") ?? false,
AllowAdmin = true,
// Don't crash on first connect failure: return a multiplexer that keeps
// retrying in the background. FusionCache will operate L1-only until
// Redis is reachable, then transparently re-engage L2 + backplane.
AbortOnConnectFail = false,
ConnectRetry = 5,
ConnectTimeout = 5000,
ReconnectRetryPolicy = new ExponentialRetry(500)
};
builder.Services.AddSingleton<IConnectionMultiplexer>(_ => ConnectionMultiplexer.Connect(redisConfig));
// FusionCache: shared L1 (memory) + L2 (Redis via shared multiplexer) + Redis backplane.
// L1 + single-flight factory provides intra-task stampede protection.
// L2 + backplane coordinate cache state across all ECS tasks so a popular key
// hits downstream APIs (FRC/TBA/Statbotics/etc.) at most once per fleet.
builder.Services.AddSingleton<IDistributedCache>(sp =>
new Microsoft.Extensions.Caching.StackExchangeRedis.RedisCache(new RedisCacheOptions
{
ConnectionMultiplexerFactory = () =>
Task.FromResult(sp.GetRequiredService<IConnectionMultiplexer>())
}));
builder.Services.AddFusionCache()
.WithDefaultEntryOptions(new FusionCacheEntryOptions
{
Duration = TimeSpan.FromMinutes(1),
// Fail-safe: when downstream errors and we have a stale value, return it.
// Critical for an announcer tool — better stale data than a broken broadcast.
IsFailSafeEnabled = true,
FailSafeMaxDuration = TimeSpan.FromHours(24),
FailSafeThrottleDuration = TimeSpan.FromSeconds(30),
// Soft timeout: if a factory takes longer than this AND we have stale data,
// return the stale data and let the factory finish in the background.
FactorySoftTimeout = TimeSpan.FromMilliseconds(500),
// Hard timeout: absolute ceiling for a factory call (no stale data path).
FactoryHardTimeout = TimeSpan.FromSeconds(10),
// Eager refresh: pro-actively refresh entries at 80% of duration to keep
// hot keys fresh without ever exposing a "real" miss to a request.
EagerRefreshThreshold = 0.8f,
// Allow background updates triggered by timeouts/eager-refresh to populate L2.
AllowBackgroundDistributedCacheOperations = true,
AllowBackgroundBackplaneOperations = true
})
.WithSerializer(new FusionCacheSystemTextJsonSerializer(new System.Text.Json.JsonSerializerOptions
{
PropertyNameCaseInsensitive = true,
PropertyNamingPolicy = System.Text.Json.JsonNamingPolicy.CamelCase
}))
.WithRegisteredDistributedCache()
.WithBackplane(sp => new RedisBackplane(new RedisBackplaneOptions
{
ConnectionMultiplexerFactory = () =>
Task.FromResult(sp.GetRequiredService<IConnectionMultiplexer>())
}));
builder.Services.AddHttpClient();
// Per-request holder for the desired downstream cache TTL. Populated by
// RedisCacheAttribute when an action runs; left null on background-job code paths
// (no controller -> no caching).
builder.Services.AddScoped<CacheTtlContext>();
builder.Services.AddScoped<FRCApiService>();
builder.Services.AddScoped<FirstGlobalApiService>();
builder.Services.AddScoped<TBAApiService>();
builder.Services.AddScoped<StatboticsApiService>();
builder.Services.AddScoped<CasterstoolApiService>();
builder.Services.AddScoped<FTCApiService>();
builder.Services.AddScoped<FTCScoutApiService>();
builder.Services.AddScoped<TOAApiService>();
builder.Services.AddSingleton<UserStorageService>();
builder.Services.AddSingleton<HighScoreRepository>();
builder.Services.AddScoped<TeamDataService>();
builder.Services.AddScoped<ScheduleService>();
builder.Services.AddScoped<FTCScheduleService>();
builder.Services.AddSingleton<MailchimpWebhookService>();
// Register job services
builder.Services.AddScoped<JobRunnerService>();
builder.Services.AddScoped<UpdateGlobalHighScoresJob>();
var app = builder.Build();
// Initialize the ServiceLocator for RedisCache functionality
ServiceLocator.ServiceProvider = app.Services;
// Check if we're running a job instead of the web API
if (args.Length > 0 && args[0] == "--job")
{
var scope = app.Services.CreateScope();
var jobRunner = scope.ServiceProvider.GetRequiredService<JobRunnerService>();
if (args.Length < 2)
{
Console.WriteLine("Usage: --job <job-name>");
Console.WriteLine("Available jobs:");
foreach (var j in jobRunner.GetAvailableJobs()) Console.WriteLine($" - {j}");
return;
}
var jobName = args[1];
await jobRunner.RunJobAsync(jobName);
return;
}
app.UseMiddleware<ExceptionHandlingMiddleware>();
app.UseMiddleware<UndefinedRouteParameterMiddleware>();
app.UseSerilogRequestLogging(options =>
{
// Demote noisy, expected requests to Verbose so they're filtered out of
// production sinks but still available locally if MinimumLevel is lowered.
// - /livecheck: ALB target group health check (every 30s per task)
// - OPTIONS preflight: every cross-origin request fires one
options.GetLevel = (httpContext, _, ex) =>
{
if (ex != null) return LogEventLevel.Error;
if (httpContext.Response.StatusCode >= 500) return LogEventLevel.Error;
if (HttpMethods.IsOptions(httpContext.Request.Method)) return LogEventLevel.Verbose;
var path = httpContext.Request.Path.Value;
if (path is "/livecheck" or "/version") return LogEventLevel.Verbose;
return LogEventLevel.Information;
};
});
app.UseMiddleware<NewRelicRequestFilter>();
app.UseMiddleware<NewRelicEcsEnricher>();
app.UseOpenApi();
app.UseSwaggerUi(config =>
{
config.DocumentTitle = "GATool API";
config.Path = "/swagger";
config.DocumentPath = "/swagger/{documentName}/swagger.json";
});
app.UseCors();
app.UseAuthentication();
app.UseAuthorization();
app.MapControllers();
app.Run();
}
catch (Exception ex)
{
Log.Fatal(ex, "Application terminated unexpectedly");
}
finally
{
Log.CloseAndFlush();
}