using GmRelay.Web; using GmRelay.Web.Components; using GmRelay.Web.Health; using GmRelay.Web.Services; using Microsoft.AspNetCore.Authentication.Cookies; using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.DataProtection; using Microsoft.AspNetCore.Diagnostics.HealthChecks; using Microsoft.Extensions.Diagnostics.HealthChecks; using System.Security.Claims; using System.Security.Cryptography; using System.Text.Json; using Telegram.Bot; using Npgsql; var builder = WebApplication.CreateBuilder(args); // Add Aspire service defaults builder.AddServiceDefaults(); // Add HttpClient builder.Services.AddHttpClient(); // Add HttpContextAccessor for platform-agnostic identity resolution builder.Services.AddHttpContextAccessor(); // Add health checks builder.Services.AddHealthChecks() .AddCheck("npgsql"); // Add Data Protection builder.Services.AddDataProtection() .PersistKeysToFileSystem(new DirectoryInfo("/app/dataprotection-keys")); // Add Npgsql builder.AddNpgsqlDataSource("gmrelaydb"); // Add Services builder.Services.AddSingleton(); builder.Services.Configure(builder.Configuration.GetSection("Discord")); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddScoped(); builder.Services.AddScoped(); // Add Bot Client builder.Services.AddSingleton(sp => { var config = sp.GetRequiredService(); var token = config["Telegram__BotToken"] ?? config["Telegram:BotToken"] ?? throw new InvalidOperationException("Telegram__BotToken is required."); return new TelegramBotClient(token); }); // Add Authentication with hardened cookie settings builder.Services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme) .AddCookie(options => { options.LoginPath = "/login"; options.AccessDeniedPath = "/access-denied"; options.Cookie.HttpOnly = true; options.Cookie.SecurePolicy = CookieSecurePolicy.Always; options.Cookie.SameSite = SameSiteMode.Lax; options.ExpireTimeSpan = TimeSpan.FromDays(7); options.SlidingExpiration = true; }); builder.Services.AddAuthorization(); builder.Services.AddCascadingAuthenticationState(); // Add services to the container. builder.Services.AddRazorComponents() .AddInteractiveServerComponents(); var app = builder.Build(); // Configure the HTTP request pipeline. if (!app.Environment.IsDevelopment()) { app.UseExceptionHandler("/Error", createScopeForErrors: true); app.UseHsts(); } app.UseHttpsRedirection(); // Security headers middleware app.Use(async (context, next) => { context.Response.Headers["X-Content-Type-Options"] = "nosniff"; context.Response.Headers["X-Frame-Options"] = "DENY"; context.Response.Headers["Referrer-Policy"] = "strict-origin-when-cross-origin"; context.Response.Headers["Permissions-Policy"] = "camera=(), microphone=(), geolocation=()"; await next(); }); app.UseAuthentication(); app.UseAuthorization(); app.UseAntiforgery(); app.MapStaticAssets(); app.MapRazorComponents() .AddInteractiveServerRenderMode(); // Health check endpoints app.MapHealthChecks("/health", new HealthCheckOptions { ResponseWriter = async (context, report) => { context.Response.ContentType = "application/json"; var response = new { status = report.Status == HealthStatus.Healthy ? "healthy" : "unhealthy", timestamp = DateTimeOffset.UtcNow.ToString("O") }; await context.Response.WriteAsJsonAsync(response); } }); app.MapHealthChecks("/alive", new HealthCheckOptions { Predicate = r => r.Tags.Contains("live") }); // Endpoint to handle Telegram Login callback app.MapGet("/auth/telegram", async (HttpContext context, TelegramAuthService authService, ISessionStore sessionStore) => { if (!authService.Verify(context.Request.Query, out var telegramId, out var name)) return Results.Redirect("/login?error=auth_failed"); await sessionStore.UpsertPlayerAsync("Telegram", telegramId.ToString(System.Globalization.CultureInfo.InvariantCulture), name, null); // If already authenticated via another platform, link instead of replacing session if (context.User.Identity?.IsAuthenticated == true && context.User.TryGetPlatformIdentity(out var currentPlatform, out var currentExternalUserId) && currentPlatform != "Telegram") { try { // Always make Telegram the primary (it has the historical data/groups) await sessionStore.LinkIdentityAsync( "Telegram", telegramId.ToString(System.Globalization.CultureInfo.InvariantCulture), currentPlatform, currentExternalUserId, name); return Results.Redirect("/profile?linked=telegram"); } catch (InvalidOperationException ex) { return Results.Redirect($"/profile?link_error={Uri.EscapeDataString(ex.Message)}"); } } var authProperties = new AuthenticationProperties { IsPersistent = true }; await context.SignInAsync( CookieAuthenticationDefaults.AuthenticationScheme, CreateTelegramPrincipal(telegramId, name), authProperties); return Results.Redirect("/"); }); app.MapPost("/auth/telegram-webapp", async ( HttpContext context, TelegramAuthService authService, ISessionStore sessionStore, TelegramWebAppAuthRequest request) => { if (!authService.VerifyWebAppInitData(request.InitData, out var telegramId, out var name)) { return Results.Unauthorized(); } await sessionStore.UpsertPlayerAsync("Telegram", telegramId.ToString(System.Globalization.CultureInfo.InvariantCulture), name, null); var authProperties = new AuthenticationProperties { IsPersistent = true }; await context.SignInAsync( CookieAuthenticationDefaults.AuthenticationScheme, CreateTelegramPrincipal(telegramId, name), authProperties); return Results.Ok(new { redirectUrl = "/" }); }).DisableAntiforgery(); app.MapPost("/auth/telegram-login", async ( HttpContext context, TelegramAuthService authService, ISessionStore sessionStore, TelegramLoginPayload request) => { if (!authService.VerifyLoginPayload(request, out var telegramId, out var name)) { return Results.Unauthorized(); } await sessionStore.UpsertPlayerAsync("Telegram", telegramId.ToString(System.Globalization.CultureInfo.InvariantCulture), name, null); var authProperties = new AuthenticationProperties { IsPersistent = true }; await context.SignInAsync( CookieAuthenticationDefaults.AuthenticationScheme, CreateTelegramPrincipal(telegramId, name), authProperties); return Results.Ok(new { redirectUrl = "/" }); }).DisableAntiforgery(); app.MapGet("/auth/status", (HttpContext context) => Results.Ok(new { authenticated = context.User.Identity?.IsAuthenticated == true })); app.MapPost("/auth/logout", async (HttpContext context) => { await context.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme); return Results.Redirect("/"); }); // Discord OAuth endpoints app.MapGet("/auth/discord", (DiscordAuthService discordAuth, DiscordOAuthStateStore stateStore) => { var state = stateStore.CreateState(); var url = discordAuth.BuildAuthorizeUrl(state); return Results.Redirect(url); }); app.MapGet("/auth/discord/callback", async ( HttpContext context, DiscordAuthService discordAuth, DiscordOAuthStateStore stateStore, ISessionStore sessionStore) => { var code = context.Request.Query["code"].ToString(); var state = context.Request.Query["state"].ToString(); if (string.IsNullOrWhiteSpace(code) || string.IsNullOrWhiteSpace(state) || !stateStore.ValidateAndRemove(state)) { return Results.Redirect("/login?error=auth_failed"); } var user = await discordAuth.ExchangeCodeAsync(code); if (user is null) return Results.Redirect("/login?error=auth_failed"); await sessionStore.UpsertDiscordUserAsync(user.Id, user.DisplayName, user.AvatarUrl); // If already authenticated via another platform, link instead of replacing session if (context.User.Identity?.IsAuthenticated == true && context.User.TryGetPlatformIdentity(out var currentPlatform, out var currentExternalUserId) && currentPlatform != "Discord") { try { await sessionStore.LinkIdentityAsync( currentPlatform, currentExternalUserId, "Discord", user.Id, user.DisplayName); return Results.Redirect("/profile?linked=discord"); } catch (InvalidOperationException ex) { return Results.Redirect($"/profile?link_error={Uri.EscapeDataString(ex.Message)}"); } } var authProperties = new AuthenticationProperties { IsPersistent = true }; await context.SignInAsync( CookieAuthenticationDefaults.AuthenticationScheme, CreateDiscordPrincipal(user.Id, user.DisplayName, user.AvatarUrl), authProperties); return Results.Redirect("/"); }); // Identity linking API endpoints app.MapGet("/api/me/identities", async ( HttpContext context, ISessionStore sessionStore) => { if (!context.User.TryGetPlatformIdentity(out var platform, out var externalUserId)) return Results.Unauthorized(); var identities = await sessionStore.GetLinkedIdentitiesAsync(platform, externalUserId); return Results.Ok(identities); }).RequireAuthorization(); app.MapDelete("/api/me/identities/{targetPlatform}/{targetExternalUserId}", async ( HttpContext context, ISessionStore sessionStore, string targetPlatform, string targetExternalUserId) => { if (!context.User.TryGetPlatformIdentity(out var platform, out var externalUserId)) return Results.Unauthorized(); try { await sessionStore.UnlinkIdentityAsync(platform, externalUserId, targetPlatform, targetExternalUserId); return Results.NoContent(); } catch (InvalidOperationException ex) { return Results.BadRequest(new { error = ex.Message }); } }).RequireAuthorization(); // Public calendar subscription endpoint (no auth required) app.MapGet("/calendar/{token}.ics", async ( string token, CalendarSubscriptionService service, CancellationToken ct) => { try { var ics = await service.GetIcsAsync(token, ct); var bytes = System.Text.Encoding.UTF8.GetBytes(ics); return Results.File(bytes, "text/calendar", "schedule.ics"); } catch (SubscriptionNotFoundException) { return Results.NotFound(); } }); app.Run(); static ClaimsPrincipal CreateTelegramPrincipal(long telegramId, string name) { var claims = new List { new(ClaimTypes.NameIdentifier, telegramId.ToString(System.Globalization.CultureInfo.InvariantCulture)), new(ClaimTypes.Name, name), new("TelegramId", telegramId.ToString(System.Globalization.CultureInfo.InvariantCulture)), new("Platform", "Telegram") }; var claimsIdentity = new ClaimsIdentity(claims, CookieAuthenticationDefaults.AuthenticationScheme); return new ClaimsPrincipal(claimsIdentity); } static ClaimsPrincipal CreateDiscordPrincipal(string discordId, string name, string? avatarUrl) { var claims = new List { new(ClaimTypes.NameIdentifier, discordId), new(ClaimTypes.Name, name), new("DiscordId", discordId), new("Platform", "Discord") }; if (!string.IsNullOrWhiteSpace(avatarUrl)) claims.Add(new Claim("AvatarUrl", avatarUrl)); var claimsIdentity = new ClaimsIdentity(claims, CookieAuthenticationDefaults.AuthenticationScheme); return new ClaimsPrincipal(claimsIdentity); } public sealed record TelegramWebAppAuthRequest(string InitData);