Files
GmRelayBot/src/GmRelay.Web/Program.cs
T
Toutsu 6cb2fbe610
PR Checks / test-and-build (pull_request) Successful in 7m28s
feat(web): add private club showcases with membership flow (v3.7.0)
Implements Issue #110: game masters can now publish sessions
exclusively to a club's private showcase, gated behind a
member application and approval flow. Adds a 4-state
publication_mode (None/Catalog/ClubOnly/Both) replacing the
binary is_public, plus a club_memberships table with
Pending/Active/Rejected/Left lifecycle and partial unique
index ensuring a single Active row per (group, player).

Highlights
- V030 migration: club_memberships, publication_mode, drop
  is_public, recreate partial indexes, portfolio_games gains
  publication_mode.
- PublicationMode enum + extensions in GmRelay.Shared.
- ISessionStore gains 12 membership/showcase methods;
  AuthorizedMembershipService owns the membership flow with
  GM-only approve/reject authorization.
- PublicClub / PublicMasterProfile / PublicSession: member-
  aware queries (ClubOnly visible only to Active members).
- New pages: MyClubMemberships (/profile/memberships) and
  ClubApplications (/group/{id}/applications).
- GroupDetails and EditSession switch from a bool toggle to
  a 4-state publication_mode selector.
- NavMenu adds Moji kluby, PublicLayout adds Kluby.

Tests: 4 new test files (PublicationMode, ClubMemberships,
AuthorizedMembershipService, ClubShowcaseSource) + updates
to PublicClubPages, AuthorizedSessionService/Portfolio
service FakeSessionStore, CampaignTemplatesNavigation.
493 tests pass.

Bump version 3.6.0 -> 3.7.0 across Directory.Build.props,
compose.yaml, deploy.yml, NavMenu.razor.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 11:09:22 +03:00

364 lines
12 KiB
C#

using GmRelay.Web;
using GmRelay.Web.Components;
using GmRelay.Web.Health;
using GmRelay.Web.Services;
using GmRelay.Web.Services.Portfolio;
using GmRelay.Web.Services.Portfolio.Covers;
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<NpgsqlHealthCheck>("npgsql");
// Add Data Protection
builder.Services.AddDataProtection()
.PersistKeysToFileSystem(new DirectoryInfo("/app/dataprotection-keys"));
// Add Npgsql
builder.AddNpgsqlDataSource("gmrelaydb");
// Add Services
builder.Services.AddSingleton<TelegramAuthService>();
builder.Services.AddPortfolioCoverStorage(builder.Configuration);
builder.Services.Configure<DiscordOAuthOptions>(builder.Configuration.GetSection("Discord"));
builder.Services.AddSingleton<DiscordAuthService>();
builder.Services.AddSingleton<DiscordOAuthStateStore>();
builder.Services.AddSingleton<ISessionStore, SessionService>();
builder.Services.AddScoped<AuthorizedSessionService>();
builder.Services.AddScoped<AuthorizedMembershipService>();
builder.Services.AddScoped<CalendarSubscriptionService>();
builder.Services.AddSingleton<IPortfolioStore, PortfolioService>();
builder.Services.AddScoped<AuthorizedPortfolioService>();
// Add Bot Client
builder.Services.AddSingleton<ITelegramBotClient>(sp =>
{
var config = sp.GetRequiredService<IConfiguration>();
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.UsePortfolioCoverFiles();
app.UseAuthentication();
app.UseAuthorization();
app.UseAntiforgery();
app.MapStaticAssets();
app.MapRazorComponents<App>()
.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<Claim>
{
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<Claim>
{
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);