diff --git a/.env.example b/.env.example index 6f92bc9..47db44c 100644 --- a/.env.example +++ b/.env.example @@ -14,6 +14,13 @@ TELEGRAM_MINI_APP_URL= # Можно получить в Discord Developer Portal (https://discord.com/developers/applications) DISCORD_BOT_TOKEN=YOUR_DISCORD_BOT_TOKEN_HERE +# Discord OAuth (для Web Dashboard) +# Client ID и Secret из OAuth2 раздела Discord Developer Portal +# Redirect URI должен указывать на /auth/discord/callback вашего домена +DISCORD_CLIENT_ID=YOUR_DISCORD_CLIENT_ID_HERE +DISCORD_CLIENT_SECRET=YOUR_DISCORD_CLIENT_SECRET_HERE +DISCORD_REDIRECT_URI=https://your-domain.example/auth/discord/callback + # Пароль для базы данных PostgreSQL POSTGRES_PASSWORD=StrongPasswordForDatabase diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml index c3ecb38..5440737 100644 --- a/.gitea/workflows/deploy.yml +++ b/.gitea/workflows/deploy.yml @@ -6,7 +6,7 @@ on: - main env: - VERSION: 2.7.2 + VERSION: 2.8.0 jobs: # ЧАСТЬ 1: Собираем образы и кладем в Gitea (чтобы делиться с ребятами) @@ -113,6 +113,9 @@ jobs: echo "DISCORD_BOT_TOKEN=${{ secrets.DISCORD_BOT_TOKEN }}" >> .env echo "TELEGRAM_BOT_USERNAME=${{ secrets.TELEGRAM_BOT_USERNAME }}" >> .env echo "TELEGRAM_MINI_APP_URL=${{ secrets.TELEGRAM_MINI_APP_URL }}" >> .env + echo "DISCORD_CLIENT_ID=${{ secrets.DISCORD_CLIENT_ID }}" >> .env + echo "DISCORD_CLIENT_SECRET=${{ secrets.DISCORD_CLIENT_SECRET }}" >> .env + echo "DISCORD_REDIRECT_URI=${{ secrets.DISCORD_REDIRECT_URI }}" >> .env - name: Deploy Containers run: | diff --git a/Directory.Build.props b/Directory.Build.props index f7d29a2..3cc296b 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -1,6 +1,6 @@ - 2.7.2 + 2.8.0 net10.0 preview enable diff --git a/README.md b/README.md index 7d5511b..6c4fc47 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ Проект разработан с упором на производительность, архитектуру Vertical Slice, Native AOT (для бота) и удобство развертывания с использованием .NET Aspire. -**Текущая версия:** `v2.7.2`. +**Текущая версия:** `v2.8.0`. --- diff --git a/compose.yaml b/compose.yaml index 9f47220..2bdbd1a 100644 --- a/compose.yaml +++ b/compose.yaml @@ -49,7 +49,7 @@ services: crond -f bot: - image: git.codeanddice.ru/toutsu/gmrelay-bot:2.7.2 + image: git.codeanddice.ru/toutsu/gmrelay-bot:2.8.0 restart: always depends_on: db: @@ -67,7 +67,7 @@ services: retries: 3 discord: - image: git.codeanddice.ru/toutsu/gmrelay-discord-bot:2.7.2 + image: git.codeanddice.ru/toutsu/gmrelay-discord-bot:2.8.0 restart: always depends_on: db: @@ -84,7 +84,7 @@ services: retries: 3 web: - image: git.codeanddice.ru/toutsu/gmrelay-web:2.7.2 + image: git.codeanddice.ru/toutsu/gmrelay-web:2.8.0 restart: always depends_on: db: @@ -94,6 +94,9 @@ services: - "Telegram__BotToken=${TELEGRAM_BOT_TOKEN:?Set TELEGRAM_BOT_TOKEN in .env}" - "Telegram__BotUsername=${TELEGRAM_BOT_USERNAME:?Set TELEGRAM_BOT_USERNAME in .env}" - "Telegram__MiniAppUrl=${TELEGRAM_MINI_APP_URL:-}" + - "Discord__ClientId=${DISCORD_CLIENT_ID:-}" + - "Discord__ClientSecret=${DISCORD_CLIENT_SECRET:-}" + - "Discord__RedirectUri=${DISCORD_REDIRECT_URI:-}" ports: - "${GMRELAY_WEB_PORT:-8080}:8080" volumes: diff --git a/src/GmRelay.Bot/Migrations/V019__audit_log_platform_identity.sql b/src/GmRelay.Bot/Migrations/V019__audit_log_platform_identity.sql new file mode 100644 index 0000000..e191e07 --- /dev/null +++ b/src/GmRelay.Bot/Migrations/V019__audit_log_platform_identity.sql @@ -0,0 +1,18 @@ +-- ============================================================= +-- V019: Rename session_audit_log.actor_telegram_id to actor_external_user_id +-- ============================================================= +-- Scope: Support platform-agnostic audit log identity. +-- ============================================================= + +ALTER TABLE session_audit_log + ADD COLUMN actor_external_user_id VARCHAR(255); + +UPDATE session_audit_log + SET actor_external_user_id = actor_telegram_id::TEXT + WHERE actor_external_user_id IS NULL; + +ALTER TABLE session_audit_log + ALTER COLUMN actor_external_user_id SET NOT NULL; + +ALTER TABLE session_audit_log + DROP COLUMN actor_telegram_id; diff --git a/src/GmRelay.Web/Components/Layout/NavMenu.razor b/src/GmRelay.Web/Components/Layout/NavMenu.razor index e326eb9..745eff7 100644 --- a/src/GmRelay.Web/Components/Layout/NavMenu.razor +++ b/src/GmRelay.Web/Components/Layout/NavMenu.razor @@ -39,9 +39,19 @@ @@ -79,4 +89,7 @@ private void ToggleMenu() => isOpen = !isOpen; private void CloseMenu() => isOpen = false; + + private static string GetPlatformLabel(System.Security.Claims.ClaimsPrincipal user) => + user.TryGetDiscordId(out _) ? "Discord" : "Telegram"; } diff --git a/src/GmRelay.Web/Components/Pages/CampaignTemplates.razor b/src/GmRelay.Web/Components/Pages/CampaignTemplates.razor index ce6b05d..5b634f8 100644 --- a/src/GmRelay.Web/Components/Pages/CampaignTemplates.razor +++ b/src/GmRelay.Web/Components/Pages/CampaignTemplates.razor @@ -185,7 +185,7 @@ private Guid selectedGroupId; private Guid? deletingTemplateId; private bool isCreatingTemplate; - private long telegramId; + private string? externalUserId; private string? errorMessage; private string? successMessage; private CampaignTemplateEditModel templateModel = new(); @@ -195,13 +195,13 @@ protected override async Task OnInitializedAsync() { var authState = await AuthStateProvider.GetAuthenticationStateAsync(); - if (!authState.User.TryGetTelegramId(out telegramId)) + if (!authState.User.TryGetPlatformIdentity(out var platform, out externalUserId)) { Navigation.NavigateTo("/access-denied"); return; } - groups = await SessionService.GetGroupsForGmAsync(telegramId); + groups = await SessionService.GetGroupsForCurrentUserAsync(); selectedGroupId = groups.FirstOrDefault()?.Id ?? Guid.Empty; if (selectedGroupId != Guid.Empty) @@ -228,7 +228,7 @@ campaignTemplates = null; campaignTemplateModels = []; - var templates = await SessionService.GetCampaignTemplatesForGmAsync(selectedGroupId, telegramId); + var templates = await SessionService.GetCampaignTemplatesForCurrentUserAsync(selectedGroupId); if (templates is null) { Navigation.NavigateTo("/access-denied"); @@ -260,9 +260,8 @@ try { - await SessionService.CreateCampaignTemplateForGmAsync( + await SessionService.CreateCampaignTemplateForCurrentUserAsync( selectedGroupId, - telegramId, new CreateCampaignTemplateRequest( templateModel.Name, templateModel.Title, @@ -298,7 +297,7 @@ try { - await SessionService.DeleteCampaignTemplateForGmAsync(template.Id, telegramId); + await SessionService.DeleteCampaignTemplateForCurrentUserAsync(template.Id); successMessage = "Шаблон кампании удалён."; await LoadTemplates(); } diff --git a/src/GmRelay.Web/Components/Pages/EditSession.razor b/src/GmRelay.Web/Components/Pages/EditSession.razor index af68256..2ac809d 100644 --- a/src/GmRelay.Web/Components/Pages/EditSession.razor +++ b/src/GmRelay.Web/Components/Pages/EditSession.razor @@ -87,13 +87,13 @@ protected override async Task OnInitializedAsync() { var authState = await AuthStateProvider.GetAuthenticationStateAsync(); - if (!authState.User.TryGetTelegramId(out var telegramId)) + if (!authState.User.TryGetPlatformIdentity(out var platform, out var externalUserId)) { Navigation.NavigateTo("/access-denied"); return; } - session = await SessionService.GetSessionForGmAsync(SessionId, telegramId); + session = await SessionService.GetSessionForCurrentUserAsync(SessionId); if (session is null) { Navigation.NavigateTo("/access-denied"); @@ -114,7 +114,7 @@ try { var authState = await AuthStateProvider.GetAuthenticationStateAsync(); - if (!authState.User.TryGetTelegramId(out var telegramId)) + if (!authState.User.TryGetPlatformIdentity(out var platform, out var externalUserId)) { Navigation.NavigateTo("/access-denied"); return; @@ -122,7 +122,7 @@ var utcTime = new DateTimeOffset(model.ScheduledAtLocal, TimeSpan.FromHours(3)).ToUniversalTime().UtcDateTime; - await SessionService.UpdateSessionForGmAsync(SessionId, telegramId, model.Title, utcTime, model.JoinLink, model.MaxPlayers); + await SessionService.UpdateSessionForCurrentUserAsync(SessionId, model.Title, utcTime, model.JoinLink, model.MaxPlayers); Navigation.NavigateTo($"/group/{session!.GroupId}"); } catch (SessionAccessDeniedException) diff --git a/src/GmRelay.Web/Components/Pages/GroupDetails.razor b/src/GmRelay.Web/Components/Pages/GroupDetails.razor index 5f5a62c..6dbb686 100644 --- a/src/GmRelay.Web/Components/Pages/GroupDetails.razor +++ b/src/GmRelay.Web/Components/Pages/GroupDetails.razor @@ -40,8 +40,8 @@ @if (groupManagement.CurrentUserIsOwner && manager.Role == GroupManagerRoleExtensions.CoGmValue) { - } } @@ -403,9 +403,9 @@ private Guid? promotingSessionId; private Guid? processingBatchId; private Guid? processingTemplateId; - private long? removingCoGmId; + private string? removingCoGmId; private bool isAddingCoGm; - private long telegramId; + private string? externalUserId; private string? errorMessage; private string? successMessage; private CoGmEditModel coGmModel = new(); @@ -417,7 +417,7 @@ protected override async Task OnInitializedAsync() { var authState = await AuthStateProvider.GetAuthenticationStateAsync(); - if (!authState.User.TryGetTelegramId(out telegramId)) + if (!authState.User.TryGetPlatformIdentity(out var platform, out externalUserId)) { Navigation.NavigateTo("/access-denied"); return; @@ -428,21 +428,21 @@ private async Task LoadSessions() { - groupManagement = await SessionService.GetGroupManagementForGmAsync(GroupId, telegramId); + groupManagement = await SessionService.GetGroupManagementForCurrentUserAsync(GroupId); if (groupManagement is null) { Navigation.NavigateTo("/access-denied"); return; } - sessions = await SessionService.GetUpcomingSessionsForGmAsync(GroupId, telegramId); + sessions = await SessionService.GetUpcomingSessionsForCurrentUserAsync(GroupId); if (sessions is null) { Navigation.NavigateTo("/access-denied"); return; } - campaignTemplates = await SessionService.GetCampaignTemplatesForGmAsync(GroupId, telegramId); + campaignTemplates = await SessionService.GetCampaignTemplatesForCurrentUserAsync(GroupId); if (campaignTemplates is null) { Navigation.NavigateTo("/access-denied"); @@ -470,8 +470,8 @@ { await SessionService.AddCoGmForOwnerAsync( GroupId, - telegramId, - coGmModel.TelegramId.Value, + "Telegram", + coGmModel.TelegramId.Value.ToString(), coGmModel.DisplayName, coGmModel.TelegramUsername); @@ -493,15 +493,15 @@ } } - private async Task RemoveCoGm(long coGmTelegramId) + private async Task RemoveCoGm(string coGmExternalUserId) { errorMessage = null; successMessage = null; - removingCoGmId = coGmTelegramId; + removingCoGmId = coGmExternalUserId; try { - await SessionService.RemoveCoGmForOwnerAsync(GroupId, telegramId, coGmTelegramId); + await SessionService.RemoveCoGmForOwnerAsync(GroupId, "Telegram", coGmExternalUserId); successMessage = "Co-GM удалён."; await LoadSessions(); } @@ -527,7 +527,7 @@ try { - await SessionService.PromoteWaitlistedPlayerForGmAsync(sessionId, telegramId); + await SessionService.PromoteWaitlistedPlayerForCurrentUserAsync(sessionId); await LoadSessions(); } catch (SessionAccessDeniedException) @@ -559,7 +559,7 @@ loadingParticipantsSessionId = sessionId; try { - var participants = await SessionService.GetSessionParticipantsForGmAsync(sessionId, telegramId); + var participants = await SessionService.GetSessionParticipantsForCurrentUserAsync(sessionId); participantsCache[sessionId] = participants ?? []; } catch (Exception ex) @@ -582,7 +582,7 @@ try { - await SessionService.RemovePlayerFromSessionForGmAsync(sessionId, telegramId, participantId); + await SessionService.RemovePlayerFromSessionForCurrentUserAsync(sessionId, participantId); participantsCache.Remove(sessionId); successMessage = "Игрок исключён."; await LoadSessions(); @@ -658,10 +658,9 @@ try { - await SessionService.UpdateBatchDetailsForGmAsync(batch.BatchId, telegramId, batch.Title, batch.JoinLink); - await SessionService.UpdateBatchNotificationModeForGmAsync( + await SessionService.UpdateBatchDetailsForCurrentUserAsync(batch.BatchId, batch.Title, batch.JoinLink); + await SessionService.UpdateBatchNotificationModeForCurrentUserAsync( batch.BatchId, - telegramId, SessionNotificationModeExtensions.FromDatabaseValue(batch.NotificationMode)); successMessage = "Настройки batch обновлены."; await LoadSessions(); @@ -696,7 +695,7 @@ try { var utcTime = new DateTimeOffset(batch.FirstScheduledAtLocal, TimeSpan.FromHours(3)).ToUniversalTime().UtcDateTime; - await SessionService.RescheduleBatchForGmAsync(batch.BatchId, telegramId, utcTime, batch.IntervalDays); + await SessionService.RescheduleBatchForCurrentUserAsync(batch.BatchId, utcTime, batch.IntervalDays); successMessage = "Расписание пачки обновлено."; await LoadSessions(); } @@ -726,7 +725,7 @@ ? BatchCloneInterval.NextMonth : BatchCloneInterval.NextWeek; - var clonedBatch = await SessionService.CloneBatchForGmAsync(batch.BatchId, telegramId, interval); + var clonedBatch = await SessionService.CloneBatchForCurrentUserAsync(batch.BatchId, interval); successMessage = $"Пачка склонирована: {clonedBatch.SessionCount} игр."; await LoadSessions(); } @@ -759,7 +758,7 @@ return; } - var createdBatch = await SessionService.CreateBatchFromCampaignTemplateForGmAsync(template.Id, telegramId, utcTime); + var createdBatch = await SessionService.CreateBatchFromCampaignTemplateForCurrentUserAsync(template.Id, utcTime); successMessage = $"Batch создан из шаблона: {createdBatch.SessionCount} игр."; await LoadSessions(); } @@ -836,7 +835,7 @@ private bool IsTemplateBusy(CampaignTemplateUsageModel template) => processingTemplateId == template.Id; private string CurrentUserRole => - groupManagement?.Managers.FirstOrDefault(manager => manager.TelegramId == telegramId)?.Role + groupManagement?.Managers.FirstOrDefault(manager => manager.ExternalUserId == externalUserId)?.Role ?? GroupManagerRoleExtensions.CoGmValue; private static string FormatRole(string role) => diff --git a/src/GmRelay.Web/Components/Pages/GroupStats.razor b/src/GmRelay.Web/Components/Pages/GroupStats.razor index 43ea763..0ae1ac0 100644 --- a/src/GmRelay.Web/Components/Pages/GroupStats.razor +++ b/src/GmRelay.Web/Components/Pages/GroupStats.razor @@ -5,7 +5,7 @@ @using Microsoft.AspNetCore.Components.Authorization @using System.Security.Claims @attribute [Authorize] -@inject ISessionStore SessionStore +@inject AuthorizedSessionService SessionService @inject AuthenticationStateProvider AuthStateProvider @inject NavigationManager Navigation @@ -85,9 +85,9 @@
@s.DisplayName - @if (!string.IsNullOrEmpty(s.TelegramUsername)) + @if (!string.IsNullOrEmpty(s.ExternalUsername)) { - @@@s.TelegramUsername + @@@s.ExternalUsername }
@@ -171,21 +171,20 @@ Navigation.NavigateTo("/login"); return; } - var telegramIdClaim = user.FindFirst("telegram_id")?.Value - ?? user.FindFirst(ClaimTypes.NameIdentifier)?.Value; - if (!long.TryParse(telegramIdClaim, out var telegramId)) + if (!user.TryGetPlatformIdentity(out var platform, out var externalUserId)) { Navigation.NavigateTo("/login"); return; } try { - if (!await SessionStore.IsGroupManagerAsync(GroupId, telegramId)) + var groupManagement = await SessionService.GetGroupManagementForCurrentUserAsync(GroupId); + if (groupManagement is null) { Navigation.NavigateTo("/access-denied"); return; } - stats = await SessionStore.GetGroupAttendanceStatsAsync(GroupId) ?? new(); + stats = await SessionService.GetGroupAttendanceStatsForCurrentUserAsync(GroupId) ?? new(); UpdateSortedStats(); } catch (Exception ex) diff --git a/src/GmRelay.Web/Components/Pages/Home.razor b/src/GmRelay.Web/Components/Pages/Home.razor index 70ef8eb..be859dd 100644 --- a/src/GmRelay.Web/Components/Pages/Home.razor +++ b/src/GmRelay.Web/Components/Pages/Home.razor @@ -43,7 +43,7 @@
🎮

@group.Name

-

ID: @group.TelegramChatId

+

ID: @(group.Platform == "Discord" ? group.ExternalGroupId : group.TelegramChatId.ToString())

@FormatRole(group.ManagerRole) @@ -93,13 +93,13 @@ var user = authState.User; userName = user.Identity?.Name ?? "Мастер Игры"; - if (!user.TryGetTelegramId(out var telegramId)) + if (!user.TryGetPlatformIdentity(out _, out _)) { Navigation.NavigateTo("/access-denied"); return; } - groups = await SessionService.GetGroupsForGmAsync(telegramId); + groups = await SessionService.GetGroupsForCurrentUserAsync(); } private static string FormatRole(string role) => diff --git a/src/GmRelay.Web/Components/Pages/Login.razor b/src/GmRelay.Web/Components/Pages/Login.razor index 1547289..66cf48e 100644 --- a/src/GmRelay.Web/Components/Pages/Login.razor +++ b/src/GmRelay.Web/Components/Pages/Login.razor @@ -10,7 +10,7 @@
diff --git a/src/GmRelay.Web/Components/Pages/SessionHistory.razor b/src/GmRelay.Web/Components/Pages/SessionHistory.razor index ad4304c..623c133 100644 --- a/src/GmRelay.Web/Components/Pages/SessionHistory.razor +++ b/src/GmRelay.Web/Components/Pages/SessionHistory.razor @@ -56,7 +56,7 @@ { @entry.ChangedAt.ToString("dd.MM.yyyy HH:mm") UTC - @entry.ActorName (@entry.ActorTelegramId) + @entry.ActorName (@entry.ActorExternalUserId) @GetChangeTypeLabel(entry.ChangeType) @@ -82,13 +82,13 @@ protected override async Task OnInitializedAsync() { var authState = await AuthStateProvider.GetAuthenticationStateAsync(); - if (!authState.User.TryGetTelegramId(out var telegramId)) + if (!authState.User.TryGetPlatformIdentity(out var platform, out var externalUserId)) { Navigation.NavigateTo("/access-denied"); return; } - var session = await SessionService.GetSessionForGmAsync(SessionId, telegramId); + var session = await SessionService.GetSessionForCurrentUserAsync(SessionId); if (session is null) { Navigation.NavigateTo("/access-denied"); @@ -97,7 +97,7 @@ sessionTitle = session.Title; groupId = session.GroupId; - entries = await SessionService.GetSessionHistoryForGmAsync(SessionId, telegramId); + entries = await SessionService.GetSessionHistoryForCurrentUserAsync(SessionId); } private string GetChangeTypeLabel(string changeType) => changeType switch diff --git a/src/GmRelay.Web/DiscordOAuthOptions.cs b/src/GmRelay.Web/DiscordOAuthOptions.cs new file mode 100644 index 0000000..8ee91b3 --- /dev/null +++ b/src/GmRelay.Web/DiscordOAuthOptions.cs @@ -0,0 +1,19 @@ +namespace GmRelay.Web; + +public sealed class DiscordOAuthOptions +{ + public string ClientId { get; set; } = string.Empty; + public string ClientSecret { get; set; } = string.Empty; + public string RedirectUri { get; set; } = string.Empty; + public string[] Scopes { get; set; } = ["identify", "guilds"]; + + public void Validate() + { + if (string.IsNullOrWhiteSpace(ClientId)) + throw new InvalidOperationException("Discord:ClientId is required."); + if (string.IsNullOrWhiteSpace(ClientSecret)) + throw new InvalidOperationException("Discord:ClientSecret is required."); + if (string.IsNullOrWhiteSpace(RedirectUri)) + throw new InvalidOperationException("Discord:RedirectUri is required."); + } +} diff --git a/src/GmRelay.Web/Program.cs b/src/GmRelay.Web/Program.cs index f09b076..ad67c2d 100644 --- a/src/GmRelay.Web/Program.cs +++ b/src/GmRelay.Web/Program.cs @@ -1,3 +1,4 @@ +using GmRelay.Web; using GmRelay.Web.Components; using GmRelay.Web.Health; using GmRelay.Web.Services; @@ -7,6 +8,7 @@ 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; @@ -16,6 +18,12 @@ 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"); @@ -29,6 +37,8 @@ builder.AddNpgsqlDataSource("gmrelaydb"); // Add Services builder.Services.AddSingleton(); +builder.Services.Configure(builder.Configuration.GetSection("Discord")); +builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddScoped(); builder.Services.AddScoped(); @@ -174,6 +184,56 @@ app.MapPost("/auth/logout", async (HttpContext context) => return Results.Redirect("/"); }); +// Discord OAuth endpoints +app.MapGet("/auth/discord", (HttpContext context, DiscordAuthService discordAuth) => +{ + var state = Guid.NewGuid().ToString("N"); + context.Response.Cookies.Append("__DiscordOAuthState", state, new CookieOptions + { + HttpOnly = true, + Secure = true, + SameSite = SameSiteMode.Strict, + MaxAge = TimeSpan.FromMinutes(5) + }); + var url = discordAuth.BuildAuthorizeUrl(state); + return Results.Redirect(url); +}); + +app.MapGet("/auth/discord/callback", async ( + HttpContext context, + DiscordAuthService discordAuth, + ISessionStore sessionStore) => +{ + var code = context.Request.Query["code"].ToString(); + var state = context.Request.Query["state"].ToString(); + var storedState = context.Request.Cookies["__DiscordOAuthState"]; + + context.Response.Cookies.Delete("__DiscordOAuthState"); + + if (string.IsNullOrWhiteSpace(code) || + string.IsNullOrWhiteSpace(state) || + !CryptographicOperations.FixedTimeEquals( + System.Text.Encoding.UTF8.GetBytes(state), + System.Text.Encoding.UTF8.GetBytes(storedState ?? string.Empty))) + { + 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); + + var authProperties = new AuthenticationProperties { IsPersistent = true }; + await context.SignInAsync( + CookieAuthenticationDefaults.AuthenticationScheme, + CreateDiscordPrincipal(user.Id, user.DisplayName, user.AvatarUrl), + authProperties); + + return Results.Redirect("/"); +}); + // Public calendar subscription endpoint (no auth required) app.MapGet("/calendar/{token}.ics", async ( string token, @@ -200,11 +260,29 @@ static ClaimsPrincipal CreateTelegramPrincipal(long telegramId, string name) { new(ClaimTypes.NameIdentifier, telegramId.ToString(System.Globalization.CultureInfo.InvariantCulture)), new(ClaimTypes.Name, name), - new("TelegramId", telegramId.ToString(System.Globalization.CultureInfo.InvariantCulture)) + 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); diff --git a/src/GmRelay.Web/Services/AuthorizedSessionService.cs b/src/GmRelay.Web/Services/AuthorizedSessionService.cs index 76f32fb..f091fa9 100644 --- a/src/GmRelay.Web/Services/AuthorizedSessionService.cs +++ b/src/GmRelay.Web/Services/AuthorizedSessionService.cs @@ -1,182 +1,237 @@ +using System.Security.Claims; using GmRelay.Shared.Domain; namespace GmRelay.Web.Services; -public sealed class AuthorizedSessionService(ISessionStore sessionStore) +public sealed class AuthorizedSessionService(ISessionStore sessionStore, IHttpContextAccessor httpContextAccessor) { - public Task> GetGroupsForGmAsync(long gmId) => - sessionStore.GetGroupsForGmAsync(gmId); - - public async Task GetGroupManagementForGmAsync(Guid groupId, long gmId) + private (string Platform, string ExternalUserId, string Name)? GetCurrentIdentity() { - if (!await GroupBelongsToGmAsync(groupId, gmId)) - { + var user = httpContextAccessor.HttpContext?.User; + if (user is null || !user.TryGetPlatformIdentity(out var platform, out var externalUserId)) + return null; + + var name = user.FindFirst(ClaimTypes.Name)?.Value ?? externalUserId; + return (platform, externalUserId, name); + } + + public Task> GetGroupsForCurrentUserAsync() + { + var identity = GetCurrentIdentity(); + if (identity is null) + return Task.FromResult(new List()); + + return sessionStore.GetGroupsForUserAsync(identity.Value.Platform, identity.Value.ExternalUserId); + } + + public async Task GetGroupManagementForCurrentUserAsync(Guid groupId) + { + var identity = GetCurrentIdentity(); + if (identity is null) + return null; + + if (!await sessionStore.IsGroupManagerAsync(groupId, identity.Value.Platform, identity.Value.ExternalUserId)) return null; - } var group = await sessionStore.GetGroupAsync(groupId); if (group is null) - { return null; - } var managers = await sessionStore.GetGroupManagersAsync(groupId); - var isOwner = await sessionStore.IsGroupOwnerAsync(groupId, gmId); + var isOwner = await sessionStore.IsGroupOwnerAsync(groupId, identity.Value.Platform, identity.Value.ExternalUserId); return new WebGroupManagement(group, managers, isOwner); } - public async Task?> GetUpcomingSessionsForGmAsync(Guid groupId, long gmId) + public async Task?> GetUpcomingSessionsForCurrentUserAsync(Guid groupId) { - if (!await GroupBelongsToGmAsync(groupId, gmId)) - { + var identity = GetCurrentIdentity(); + if (identity is null) + return null; + + if (!await sessionStore.IsGroupManagerAsync(groupId, identity.Value.Platform, identity.Value.ExternalUserId)) return null; - } return await sessionStore.GetUpcomingSessionsAsync(groupId); } - public async Task GetSessionForGmAsync(Guid sessionId, long gmId) + public async Task GetSessionForCurrentUserAsync(Guid sessionId) { + var identity = GetCurrentIdentity(); + if (identity is null) + return null; + var session = await sessionStore.GetSessionAsync(sessionId); if (session is null) - { return null; - } - return await GroupBelongsToGmAsync(session.GroupId, gmId) ? session : null; + return await sessionStore.IsGroupManagerAsync(session.GroupId, identity.Value.Platform, identity.Value.ExternalUserId) ? session : null; } - public async Task GetBatchForGmAsync(Guid batchId, long gmId) + public async Task GetBatchForCurrentUserAsync(Guid batchId) { + var identity = GetCurrentIdentity(); + if (identity is null) + return null; + var batch = await sessionStore.GetBatchAsync(batchId); if (batch is null) - { return null; - } - return await GroupBelongsToGmAsync(batch.GroupId, gmId) ? batch : null; + return await sessionStore.IsGroupManagerAsync(batch.GroupId, identity.Value.Platform, identity.Value.ExternalUserId) ? batch : null; } - public async Task UpdateSessionForGmAsync(Guid sessionId, long gmId, string title, DateTime scheduledAt, string joinLink, int? maxPlayers) + public async Task UpdateSessionForCurrentUserAsync(Guid sessionId, string title, DateTime scheduledAt, string joinLink, int? maxPlayers) { - var session = await GetSessionForGmAsync(sessionId, gmId); + var identity = GetCurrentIdentity(); + if (identity is null) + throw new InvalidOperationException("User is not authenticated."); + + var session = await GetSessionForCurrentUserAsync(sessionId); if (session is null) { - throw new SessionAccessDeniedException(sessionId, gmId); + throw new SessionAccessDeniedException(sessionId, identity.Value.ExternalUserId); } await sessionStore.UpdateSessionAsync(sessionId, session.GroupId, title, scheduledAt, joinLink, maxPlayers); if (session.Title != title) - await sessionStore.LogSessionChangeAsync(sessionId, gmId, "ГМ", "Title", session.Title, title); + await sessionStore.LogSessionChangeAsync(sessionId, identity.Value.ExternalUserId, identity.Value.Name, "Title", session.Title, title); if (session.ScheduledAt != scheduledAt) - await sessionStore.LogSessionChangeAsync(sessionId, gmId, "ГМ", "Time", session.ScheduledAt.ToString("O"), scheduledAt.ToString("O")); + await sessionStore.LogSessionChangeAsync(sessionId, identity.Value.ExternalUserId, identity.Value.Name, "Time", session.ScheduledAt.ToString("O"), scheduledAt.ToString("O")); if (session.JoinLink != joinLink) - await sessionStore.LogSessionChangeAsync(sessionId, gmId, "ГМ", "Link", session.JoinLink, joinLink); + await sessionStore.LogSessionChangeAsync(sessionId, identity.Value.ExternalUserId, identity.Value.Name, "Link", session.JoinLink, joinLink); if (session.MaxPlayers != maxPlayers) - await sessionStore.LogSessionChangeAsync(sessionId, gmId, "ГМ", "MaxPlayers", session.MaxPlayers?.ToString(), maxPlayers?.ToString()); + await sessionStore.LogSessionChangeAsync(sessionId, identity.Value.ExternalUserId, identity.Value.Name, "MaxPlayers", session.MaxPlayers?.ToString(), maxPlayers?.ToString()); } - public async Task PromoteWaitlistedPlayerForGmAsync(Guid sessionId, long gmId) + public async Task PromoteWaitlistedPlayerForCurrentUserAsync(Guid sessionId) { - var session = await GetSessionForGmAsync(sessionId, gmId); + var identity = GetCurrentIdentity(); + if (identity is null) + throw new InvalidOperationException("User is not authenticated."); + + var session = await GetSessionForCurrentUserAsync(sessionId); if (session is null) { - throw new SessionAccessDeniedException(sessionId, gmId); + throw new SessionAccessDeniedException(sessionId, identity.Value.ExternalUserId); } await sessionStore.PromoteWaitlistedPlayerAsync(sessionId, session.GroupId); - await sessionStore.LogSessionChangeAsync(sessionId, gmId, "ГМ", "WaitlistPromote", null, null); + await sessionStore.LogSessionChangeAsync(sessionId, identity.Value.ExternalUserId, identity.Value.Name, "WaitlistPromote", null, null); } - public async Task UpdateBatchDetailsForGmAsync(Guid batchId, long gmId, string title, string joinLink) + public async Task UpdateBatchDetailsForCurrentUserAsync(Guid batchId, string title, string joinLink) { - var batch = await GetBatchForGmAsync(batchId, gmId); + var identity = GetCurrentIdentity(); + if (identity is null) + throw new InvalidOperationException("User is not authenticated."); + + var batch = await GetBatchForCurrentUserAsync(batchId); if (batch is null) { - throw new SessionAccessDeniedException(batchId, gmId); + throw new SessionAccessDeniedException(batchId, identity.Value.ExternalUserId); } await sessionStore.UpdateBatchDetailsAsync(batchId, batch.GroupId, title.Trim(), joinLink.Trim()); } - public async Task UpdateBatchNotificationModeForGmAsync(Guid batchId, long gmId, SessionNotificationMode notificationMode) + public async Task UpdateBatchNotificationModeForCurrentUserAsync(Guid batchId, SessionNotificationMode notificationMode) { - var batch = await GetBatchForGmAsync(batchId, gmId); + var identity = GetCurrentIdentity(); + if (identity is null) + throw new InvalidOperationException("User is not authenticated."); + + var batch = await GetBatchForCurrentUserAsync(batchId); if (batch is null) { - throw new SessionAccessDeniedException(batchId, gmId); + throw new SessionAccessDeniedException(batchId, identity.Value.ExternalUserId); } await sessionStore.UpdateBatchNotificationModeAsync(batchId, batch.GroupId, notificationMode); } - public async Task RescheduleBatchForGmAsync(Guid batchId, long gmId, DateTime firstScheduledAt, int intervalDays) + public async Task RescheduleBatchForCurrentUserAsync(Guid batchId, DateTime firstScheduledAt, int intervalDays) { if (intervalDays <= 0) { throw new ArgumentOutOfRangeException(nameof(intervalDays), intervalDays, "Interval must be greater than zero."); } - var batch = await GetBatchForGmAsync(batchId, gmId); + var identity = GetCurrentIdentity(); + if (identity is null) + throw new InvalidOperationException("User is not authenticated."); + + var batch = await GetBatchForCurrentUserAsync(batchId); if (batch is null) { - throw new SessionAccessDeniedException(batchId, gmId); + throw new SessionAccessDeniedException(batchId, identity.Value.ExternalUserId); } await sessionStore.RescheduleBatchAsync(batchId, batch.GroupId, firstScheduledAt, intervalDays); - await sessionStore.LogSessionChangeAsync(batchId, gmId, "ГМ", "BatchRescheduled", batch.FirstScheduledAt.ToString("O"), firstScheduledAt.ToString("O")); + await sessionStore.LogSessionChangeAsync(batchId, identity.Value.ExternalUserId, identity.Value.Name, "BatchRescheduled", batch.FirstScheduledAt.ToString("O"), firstScheduledAt.ToString("O")); } - public async Task CloneBatchForGmAsync(Guid batchId, long gmId, BatchCloneInterval interval) + public async Task CloneBatchForCurrentUserAsync(Guid batchId, BatchCloneInterval interval) { - var batch = await GetBatchForGmAsync(batchId, gmId); + var identity = GetCurrentIdentity(); + if (identity is null) + throw new InvalidOperationException("User is not authenticated."); + + var batch = await GetBatchForCurrentUserAsync(batchId); if (batch is null) { - throw new SessionAccessDeniedException(batchId, gmId); + throw new SessionAccessDeniedException(batchId, identity.Value.ExternalUserId); } return await sessionStore.CloneBatchAsync(batchId, batch.GroupId, interval); } - public async Task?> GetCampaignTemplatesForGmAsync(Guid groupId, long gmId) + public async Task?> GetCampaignTemplatesForCurrentUserAsync(Guid groupId) { - if (!await GroupBelongsToGmAsync(groupId, gmId)) - { + var identity = GetCurrentIdentity(); + if (identity is null) + return null; + + if (!await sessionStore.IsGroupManagerAsync(groupId, identity.Value.Platform, identity.Value.ExternalUserId)) return null; - } return await sessionStore.GetCampaignTemplatesAsync(groupId); } - public async Task CreateCampaignTemplateForGmAsync( + public async Task CreateCampaignTemplateForCurrentUserAsync( Guid groupId, - long gmId, CreateCampaignTemplateRequest request) { - if (!await GroupBelongsToGmAsync(groupId, gmId)) + var identity = GetCurrentIdentity(); + if (identity is null) + throw new InvalidOperationException("User is not authenticated."); + + if (!await sessionStore.IsGroupManagerAsync(groupId, identity.Value.Platform, identity.Value.ExternalUserId)) { - throw new SessionAccessDeniedException(groupId, gmId); + throw new SessionAccessDeniedException(groupId, identity.Value.ExternalUserId); } var normalizedRequest = NormalizeCampaignTemplateRequest(request); return await sessionStore.CreateCampaignTemplateAsync(groupId, normalizedRequest); } - public async Task DeleteCampaignTemplateForGmAsync(Guid templateId, long gmId) + public async Task DeleteCampaignTemplateForCurrentUserAsync(Guid templateId) { + var identity = GetCurrentIdentity(); + if (identity is null) + throw new InvalidOperationException("User is not authenticated."); + var template = await sessionStore.GetCampaignTemplateAsync(templateId); - if (template is null || !await GroupBelongsToGmAsync(template.GroupId, gmId)) + if (template is null || !await sessionStore.IsGroupManagerAsync(template.GroupId, identity.Value.Platform, identity.Value.ExternalUserId)) { - throw new SessionAccessDeniedException(templateId, gmId); + throw new SessionAccessDeniedException(templateId, identity.Value.ExternalUserId); } await sessionStore.DeleteCampaignTemplateAsync(templateId, template.GroupId); } - public async Task CreateBatchFromCampaignTemplateForGmAsync( + public async Task CreateBatchFromCampaignTemplateForCurrentUserAsync( Guid templateId, - long gmId, DateTime firstScheduledAt) { if (firstScheduledAt <= DateTime.UtcNow) @@ -184,89 +239,112 @@ public sealed class AuthorizedSessionService(ISessionStore sessionStore) throw new ArgumentOutOfRangeException(nameof(firstScheduledAt), firstScheduledAt, "First scheduled time must be in the future."); } + var identity = GetCurrentIdentity(); + if (identity is null) + throw new InvalidOperationException("User is not authenticated."); + var template = await sessionStore.GetCampaignTemplateAsync(templateId); - if (template is null || !await GroupBelongsToGmAsync(template.GroupId, gmId)) + if (template is null || !await sessionStore.IsGroupManagerAsync(template.GroupId, identity.Value.Platform, identity.Value.ExternalUserId)) { - throw new SessionAccessDeniedException(templateId, gmId); + throw new SessionAccessDeniedException(templateId, identity.Value.ExternalUserId); } return await sessionStore.CreateBatchFromTemplateAsync(templateId, template.GroupId, firstScheduledAt); } - public async Task AddCoGmForOwnerAsync(Guid groupId, long ownerTelegramId, long coGmTelegramId, string displayName, string? telegramUsername) + public async Task AddCoGmForOwnerAsync(Guid groupId, string coGmPlatform, string coGmExternalUserId, string displayName, string? externalUsername) { - if (coGmTelegramId <= 0) + if (string.IsNullOrWhiteSpace(coGmExternalUserId)) { - throw new ArgumentOutOfRangeException(nameof(coGmTelegramId), coGmTelegramId, "Telegram id must be greater than zero."); + throw new ArgumentException("External user id must not be empty.", nameof(coGmExternalUserId)); } - if (ownerTelegramId == coGmTelegramId) + var identity = GetCurrentIdentity(); + if (identity is null) + throw new InvalidOperationException("User is not authenticated."); + + if (identity.Value.ExternalUserId == coGmExternalUserId && identity.Value.Platform == coGmPlatform) { throw new InvalidOperationException("Owner is already a group manager."); } - if (!await sessionStore.IsGroupOwnerAsync(groupId, ownerTelegramId)) + if (!await sessionStore.IsGroupOwnerAsync(groupId, identity.Value.Platform, identity.Value.ExternalUserId)) { - throw new SessionAccessDeniedException(groupId, ownerTelegramId); + throw new SessionAccessDeniedException(groupId, identity.Value.ExternalUserId); } var normalizedName = string.IsNullOrWhiteSpace(displayName) - ? $"Telegram {coGmTelegramId.ToString(System.Globalization.CultureInfo.InvariantCulture)}" + ? $"{coGmPlatform} {coGmExternalUserId}" : displayName.Trim(); - var normalizedUsername = string.IsNullOrWhiteSpace(telegramUsername) + var normalizedUsername = string.IsNullOrWhiteSpace(externalUsername) ? null - : telegramUsername.Trim().TrimStart('@'); + : externalUsername.Trim().TrimStart('@'); - await sessionStore.AddGroupCoGmAsync(groupId, ownerTelegramId, coGmTelegramId, normalizedName, normalizedUsername); + await sessionStore.AddGroupCoGmAsync( + groupId, + identity.Value.Platform, identity.Value.ExternalUserId, + coGmPlatform, coGmExternalUserId, + normalizedName, normalizedUsername); } - public async Task RemoveCoGmForOwnerAsync(Guid groupId, long ownerTelegramId, long coGmTelegramId) + public async Task RemoveCoGmForOwnerAsync(Guid groupId, string coGmPlatform, string coGmExternalUserId) { - if (!await sessionStore.IsGroupOwnerAsync(groupId, ownerTelegramId)) + var identity = GetCurrentIdentity(); + if (identity is null) + throw new InvalidOperationException("User is not authenticated."); + + if (!await sessionStore.IsGroupOwnerAsync(groupId, identity.Value.Platform, identity.Value.ExternalUserId)) { - throw new SessionAccessDeniedException(groupId, ownerTelegramId); + throw new SessionAccessDeniedException(groupId, identity.Value.ExternalUserId); } - await sessionStore.RemoveGroupCoGmAsync(groupId, coGmTelegramId); + await sessionStore.RemoveGroupCoGmAsync(groupId, coGmPlatform, coGmExternalUserId); } - public async Task?> GetSessionParticipantsForGmAsync(Guid sessionId, long gmId) + public async Task?> GetSessionParticipantsForCurrentUserAsync(Guid sessionId) { - var session = await GetSessionForGmAsync(sessionId, gmId); + var session = await GetSessionForCurrentUserAsync(sessionId); if (session is null) - { return null; - } return await sessionStore.GetSessionParticipantsAsync(sessionId); } - public async Task?> GetSessionHistoryForGmAsync(Guid sessionId, long gmId) + public async Task?> GetSessionHistoryForCurrentUserAsync(Guid sessionId) { - var session = await GetSessionForGmAsync(sessionId, gmId); + var session = await GetSessionForCurrentUserAsync(sessionId); if (session is null) - { return null; - } return await sessionStore.GetSessionHistoryAsync(sessionId); } - public async Task RemovePlayerFromSessionForGmAsync(Guid sessionId, long gmId, Guid participantId) + public async Task RemovePlayerFromSessionForCurrentUserAsync(Guid sessionId, Guid participantId) { - var session = await GetSessionForGmAsync(sessionId, gmId); + var identity = GetCurrentIdentity(); + if (identity is null) + throw new InvalidOperationException("User is not authenticated."); + + var session = await GetSessionForCurrentUserAsync(sessionId); if (session is null) { - throw new SessionAccessDeniedException(sessionId, gmId); + throw new SessionAccessDeniedException(sessionId, identity.Value.ExternalUserId); } await sessionStore.RemovePlayerFromSessionAsync(sessionId, session.GroupId, participantId); - await sessionStore.LogSessionChangeAsync(sessionId, gmId, "ГМ", "PlayerRemoved", participantId.ToString(), null); + await sessionStore.LogSessionChangeAsync(sessionId, identity.Value.ExternalUserId, identity.Value.Name, "PlayerRemoved", participantId.ToString(), null); } - private async Task GroupBelongsToGmAsync(Guid groupId, long gmId) + public async Task?> GetGroupAttendanceStatsForCurrentUserAsync(Guid groupId) { - return await sessionStore.IsGroupManagerAsync(groupId, gmId); + var identity = GetCurrentIdentity(); + if (identity is null) + return null; + + if (!await sessionStore.IsGroupManagerAsync(groupId, identity.Value.Platform, identity.Value.ExternalUserId)) + return null; + + return await sessionStore.GetGroupAttendanceStatsAsync(groupId); } private static CreateCampaignTemplateRequest NormalizeCampaignTemplateRequest(CreateCampaignTemplateRequest request) diff --git a/src/GmRelay.Web/Services/ClaimsPrincipalExtensions.cs b/src/GmRelay.Web/Services/ClaimsPrincipalExtensions.cs index c0427f9..8d08087 100644 --- a/src/GmRelay.Web/Services/ClaimsPrincipalExtensions.cs +++ b/src/GmRelay.Web/Services/ClaimsPrincipalExtensions.cs @@ -6,4 +6,37 @@ public static class ClaimsPrincipalExtensions { public static bool TryGetTelegramId(this ClaimsPrincipal user, out long telegramId) => long.TryParse(user.FindFirst("TelegramId")?.Value, out telegramId); + + public static bool TryGetDiscordId(this ClaimsPrincipal user, out string? discordId) + { + discordId = user.FindFirst("DiscordId")?.Value; + return !string.IsNullOrWhiteSpace(discordId); + } + + public static bool TryGetPlatformIdentity(this ClaimsPrincipal user, out string platform, out string externalUserId) + { + platform = string.Empty; + externalUserId = string.Empty; + + var platformClaim = user.FindFirst("Platform")?.Value; + if (!string.IsNullOrWhiteSpace(platformClaim)) + { + platform = platformClaim; + externalUserId = user.FindFirst(ClaimTypes.NameIdentifier)?.Value ?? string.Empty; + return !string.IsNullOrWhiteSpace(externalUserId); + } + + // Fallback for legacy Telegram users before Platform claim was added + if (TryGetTelegramId(user, out var telegramId)) + { + platform = "Telegram"; + externalUserId = telegramId.ToString(System.Globalization.CultureInfo.InvariantCulture); + return true; + } + + return false; + } + + public static string? GetAvatarUrl(this ClaimsPrincipal user) => + user.FindFirst("AvatarUrl")?.Value; } diff --git a/src/GmRelay.Web/Services/DiscordAuthService.cs b/src/GmRelay.Web/Services/DiscordAuthService.cs new file mode 100644 index 0000000..6733c2c --- /dev/null +++ b/src/GmRelay.Web/Services/DiscordAuthService.cs @@ -0,0 +1,82 @@ +using System.Net.Http.Headers; +using System.Security.Claims; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace GmRelay.Web.Services; + +public sealed class DiscordAuthService(IHttpClientFactory httpClientFactory, IConfiguration configuration) +{ + private readonly DiscordOAuthOptions _options = configuration.GetSection("Discord").Get() ?? new DiscordOAuthOptions(); + + public string BuildAuthorizeUrl(string state) + { + _options.Validate(); + var scopes = string.Join(" ", _options.Scopes); + return $"https://discord.com/oauth2/authorize?client_id={_options.ClientId}&redirect_uri={Uri.EscapeDataString(_options.RedirectUri)}&response_type=code&scope={Uri.EscapeDataString(scopes)}&state={Uri.EscapeDataString(state)}"; + } + + public async Task ExchangeCodeAsync(string code) + { + _options.Validate(); + var client = httpClientFactory.CreateClient(); + + var tokenResponse = await ExchangeCodeForTokenAsync(client, code); + if (tokenResponse is null) + return null; + + return await FetchUserProfileAsync(client, tokenResponse.AccessToken); + } + + private async Task ExchangeCodeForTokenAsync(HttpClient client, string code) + { + var content = new FormUrlEncodedContent(new Dictionary + { + ["grant_type"] = "authorization_code", + ["code"] = code, + ["redirect_uri"] = _options.RedirectUri, + ["client_id"] = _options.ClientId, + ["client_secret"] = _options.ClientSecret + }); + + var response = await client.PostAsync("https://discord.com/api/oauth2/token", content); + if (!response.IsSuccessStatusCode) + return null; + + var json = await response.Content.ReadAsStringAsync(); + return JsonSerializer.Deserialize(json); + } + + private static async Task FetchUserProfileAsync(HttpClient client, string accessToken) + { + client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", accessToken); + var response = await client.GetAsync("https://discord.com/api/users/@me"); + if (!response.IsSuccessStatusCode) + return null; + + var json = await response.Content.ReadAsStringAsync(); + return JsonSerializer.Deserialize(json); + } +} + +public sealed record DiscordTokenResponse( + [property: JsonPropertyName("access_token")] string AccessToken, + [property: JsonPropertyName("token_type")] string TokenType, + [property: JsonPropertyName("expires_in")] int ExpiresIn, + [property: JsonPropertyName("scope")] string Scope); + +public sealed record DiscordUser( + [property: JsonPropertyName("id")] string Id, + [property: JsonPropertyName("username")] string Username, + [property: JsonPropertyName("discriminator")] string Discriminator, + [property: JsonPropertyName("avatar")] string? Avatar, + [property: JsonPropertyName("email")] string? Email) +{ + public string DisplayName => + Discriminator == "0" ? Username : $"{Username}#{Discriminator}"; + + public string? AvatarUrl => + !string.IsNullOrWhiteSpace(Avatar) + ? $"https://cdn.discordapp.com/avatars/{Id}/{Avatar}.png" + : null; +} diff --git a/src/GmRelay.Web/Services/ISessionStore.cs b/src/GmRelay.Web/Services/ISessionStore.cs index aebbbdf..c1745f7 100644 --- a/src/GmRelay.Web/Services/ISessionStore.cs +++ b/src/GmRelay.Web/Services/ISessionStore.cs @@ -5,33 +5,31 @@ namespace GmRelay.Web.Services; public sealed record PlayerAttendanceStats( Guid PlayerId, string DisplayName, - string? TelegramUsername, + string? ExternalUsername, long TotalSessions, long ConfirmedCount, long DeclinedCount, long NoResponseCount, long WaitlistedCount, long CancellationAffectedCount, - decimal AttendanceRate -); + decimal AttendanceRate); public sealed record SessionAuditLogEntry( Guid Id, Guid SessionId, - long ActorTelegramId, + string ActorExternalUserId, string ActorName, string ChangeType, string? OldValue, string? NewValue, - DateTime ChangedAt -); + DateTime ChangedAt); public interface ISessionStore { - Task> GetGroupsForGmAsync(long gmId); + Task> GetGroupsForUserAsync(string platform, string externalUserId); Task GetGroupAsync(Guid groupId); - Task IsGroupManagerAsync(Guid groupId, long telegramId); - Task IsGroupOwnerAsync(Guid groupId, long telegramId); + Task IsGroupManagerAsync(Guid groupId, string platform, string externalUserId); + Task IsGroupOwnerAsync(Guid groupId, string platform, string externalUserId); Task> GetGroupManagersAsync(Guid groupId); Task> GetUpcomingSessionsAsync(Guid groupId); Task GetSessionAsync(Guid sessionId); @@ -47,11 +45,12 @@ public interface ISessionStore Task CreateCampaignTemplateAsync(Guid groupId, CreateCampaignTemplateRequest request); Task DeleteCampaignTemplateAsync(Guid templateId, Guid groupId); Task CreateBatchFromTemplateAsync(Guid templateId, Guid groupId, DateTime firstScheduledAt); - Task AddGroupCoGmAsync(Guid groupId, long ownerTelegramId, long coGmTelegramId, string displayName, string? telegramUsername); - Task RemoveGroupCoGmAsync(Guid groupId, long coGmTelegramId); + Task AddGroupCoGmAsync(Guid groupId, string ownerPlatform, string ownerExternalUserId, string coGmPlatform, string coGmExternalUserId, string displayName, string? externalUsername); + Task RemoveGroupCoGmAsync(Guid groupId, string coGmPlatform, string coGmExternalUserId); Task> GetSessionParticipantsAsync(Guid sessionId); Task RemovePlayerFromSessionAsync(Guid sessionId, Guid groupId, Guid participantId); Task> GetGroupAttendanceStatsAsync(Guid groupId); - Task LogSessionChangeAsync(Guid sessionId, long actorTelegramId, string actorName, string changeType, string? oldValue, string? newValue); + Task LogSessionChangeAsync(Guid sessionId, string actorExternalUserId, string actorName, string changeType, string? oldValue, string? newValue); Task> GetSessionHistoryAsync(Guid sessionId); + Task UpsertDiscordUserAsync(string discordId, string displayName, string? avatarUrl); } diff --git a/src/GmRelay.Web/Services/SessionAccessDeniedException.cs b/src/GmRelay.Web/Services/SessionAccessDeniedException.cs index 9b30b1a..44e5462 100644 --- a/src/GmRelay.Web/Services/SessionAccessDeniedException.cs +++ b/src/GmRelay.Web/Services/SessionAccessDeniedException.cs @@ -1,4 +1,4 @@ namespace GmRelay.Web.Services; -public sealed class SessionAccessDeniedException(Guid sessionId, long gmId) - : InvalidOperationException($"Session '{sessionId}' is not accessible for GM '{gmId}'."); +public sealed class SessionAccessDeniedException(Guid sessionId, string externalUserId) + : InvalidOperationException($"Session '{sessionId}' is not accessible for user '{externalUserId}'."); diff --git a/src/GmRelay.Web/Services/SessionService.cs b/src/GmRelay.Web/Services/SessionService.cs index 42ee77a..3318d3c 100644 --- a/src/GmRelay.Web/Services/SessionService.cs +++ b/src/GmRelay.Web/Services/SessionService.cs @@ -10,16 +10,32 @@ namespace GmRelay.Web.Services; public sealed record WebGameGroup( Guid Id, long TelegramChatId, + string? ExternalGroupId, string Name, - long GmTelegramId, - string ManagerRole = GroupManagerRoleExtensions.OwnerValue); + string? Platform, + string ManagerRole = GroupManagerRoleExtensions.OwnerValue) +{ + public long GmTelegramId { get; init; } + + public WebGameGroup(Guid id, long telegramChatId, string name, long gmTelegramId) + : this(id, telegramChatId, null, name, null) + { + GmTelegramId = gmTelegramId; + } +} public sealed record WebGroupManager( long TelegramId, + string? ExternalUserId, string DisplayName, string? TelegramUsername, + string? ExternalUsername, string Role, - DateTime AddedAt); + DateTime AddedAt) +{ + public WebGroupManager(long telegramId, string displayName, string? telegramUsername, string role, DateTime addedAt) + : this(telegramId, null, displayName, telegramUsername, null, role, addedAt) { } +} public sealed record WebGroupManagement( WebGameGroup Group, @@ -44,8 +60,10 @@ public sealed record WebSession( public sealed record WebParticipant( Guid Id, long TelegramId, + string? ExternalUserId, string DisplayName, string? TelegramUsername, + string? ExternalUsername, string RsvpStatus, string RegistrationStatus, bool IsGm, @@ -83,23 +101,25 @@ public sealed class SessionService( ITelegramBotClient bot, ILogger logger) : ISessionStore { - public async Task> GetGroupsForGmAsync(long gmId) + public async Task> GetGroupsForUserAsync(string platform, string externalUserId) { await using var conn = await dataSource.OpenConnectionAsync(); return (await conn.QueryAsync( """ SELECT g.id, g.telegram_chat_id AS TelegramChatId, + g.external_group_id AS ExternalGroupId, g.name, - g.gm_telegram_id AS GmTelegramId, + g.platform AS Platform, gm.role AS ManagerRole FROM group_managers gm JOIN players p ON p.id = gm.player_id JOIN game_groups g ON g.id = gm.group_id - WHERE p.telegram_id = @GmId + WHERE p.platform = @Platform + AND p.external_user_id = @ExternalUserId ORDER BY g.name """, - new { GmId = gmId })).ToList(); + new { Platform = platform, ExternalUserId = externalUserId })).ToList(); } public async Task GetGroupAsync(Guid groupId) @@ -109,8 +129,9 @@ public sealed class SessionService( """ SELECT g.id, g.telegram_chat_id AS TelegramChatId, + g.external_group_id AS ExternalGroupId, g.name, - g.gm_telegram_id AS GmTelegramId, + g.platform AS Platform, @OwnerRole AS ManagerRole FROM game_groups g WHERE g.id = @GroupId @@ -118,7 +139,7 @@ public sealed class SessionService( new { GroupId = groupId, OwnerRole = GroupManagerRoleExtensions.OwnerValue }); } - public async Task IsGroupManagerAsync(Guid groupId, long telegramId) + public async Task IsGroupManagerAsync(Guid groupId, string platform, string externalUserId) { await using var conn = await dataSource.OpenConnectionAsync(); return await conn.ExecuteScalarAsync( @@ -128,13 +149,14 @@ public sealed class SessionService( FROM group_managers gm JOIN players p ON p.id = gm.player_id WHERE gm.group_id = @GroupId - AND p.telegram_id = @TelegramId + AND p.platform = @Platform + AND p.external_user_id = @ExternalUserId ) """, - new { GroupId = groupId, TelegramId = telegramId }); + new { GroupId = groupId, Platform = platform, ExternalUserId = externalUserId }); } - public async Task IsGroupOwnerAsync(Guid groupId, long telegramId) + public async Task IsGroupOwnerAsync(Guid groupId, string platform, string externalUserId) { await using var conn = await dataSource.OpenConnectionAsync(); return await conn.ExecuteScalarAsync( @@ -144,11 +166,12 @@ public sealed class SessionService( FROM group_managers gm JOIN players p ON p.id = gm.player_id WHERE gm.group_id = @GroupId - AND p.telegram_id = @TelegramId + AND p.platform = @Platform + AND p.external_user_id = @ExternalUserId AND gm.role = @OwnerRole ) """, - new { GroupId = groupId, TelegramId = telegramId, OwnerRole = GroupManagerRoleExtensions.OwnerValue }); + new { GroupId = groupId, Platform = platform, ExternalUserId = externalUserId, OwnerRole = GroupManagerRoleExtensions.OwnerValue }); } public async Task> GetGroupManagersAsync(Guid groupId) @@ -156,9 +179,11 @@ public sealed class SessionService( await using var conn = await dataSource.OpenConnectionAsync(); return (await conn.QueryAsync( """ - SELECT p.telegram_id AS TelegramId, + SELECT COALESCE(p.telegram_id, 0) AS TelegramId, + COALESCE(p.external_user_id, p.telegram_id::TEXT) AS ExternalUserId, p.display_name AS DisplayName, p.telegram_username AS TelegramUsername, + COALESCE(p.external_username, p.telegram_username) AS ExternalUsername, gm.role AS Role, gm.created_at AS AddedAt FROM group_managers gm @@ -179,7 +204,7 @@ public sealed class SessionService( SELECT p.id AS PlayerId, p.display_name AS DisplayName, - p.telegram_username AS TelegramUsername, + COALESCE(p.external_username, p.telegram_username) AS ExternalUsername, COUNT(DISTINCT s.id) AS TotalSessions, COUNT(DISTINCT CASE WHEN sp.rsvp_status = 'Confirmed' THEN s.id END) AS ConfirmedCount, COUNT(DISTINCT CASE WHEN sp.rsvp_status = 'Declined' THEN s.id END) AS DeclinedCount, @@ -198,21 +223,21 @@ public sealed class SessionService( WHERE s.group_id = @GroupId AND s.scheduled_at <= now() AND sp.is_gm = false - GROUP BY p.id, p.display_name, p.telegram_username + GROUP BY p.id, p.display_name, p.external_username, p.telegram_username ORDER BY AttendanceRate DESC, ConfirmedCount DESC """, new { GroupId = groupId })).ToList(); } - public async Task LogSessionChangeAsync(Guid sessionId, long actorTelegramId, string actorName, string changeType, string? oldValue, string? newValue) + public async Task LogSessionChangeAsync(Guid sessionId, string actorExternalUserId, string actorName, string changeType, string? oldValue, string? newValue) { await using var conn = await dataSource.OpenConnectionAsync(); await conn.ExecuteAsync( """ - INSERT INTO session_audit_log (session_id, actor_telegram_id, actor_name, change_type, old_value, new_value) - VALUES (@SessionId, @ActorTelegramId, @ActorName, @ChangeType, @OldValue, @NewValue) + INSERT INTO session_audit_log (session_id, actor_external_user_id, actor_name, change_type, old_value, new_value) + VALUES (@SessionId, @ActorExternalUserId, @ActorName, @ChangeType, @OldValue, @NewValue) """, - new { SessionId = sessionId, ActorTelegramId = actorTelegramId, ActorName = actorName, ChangeType = changeType, OldValue = oldValue, NewValue = newValue }); + new { SessionId = sessionId, ActorExternalUserId = actorExternalUserId, ActorName = actorName, ChangeType = changeType, OldValue = oldValue, NewValue = newValue }); } public async Task> GetSessionHistoryAsync(Guid sessionId) @@ -220,7 +245,7 @@ public sealed class SessionService( await using var conn = await dataSource.OpenConnectionAsync(); var entries = await conn.QueryAsync( """ - SELECT id, session_id AS SessionId, actor_telegram_id AS ActorTelegramId, actor_name AS ActorName, + SELECT id, session_id AS SessionId, actor_external_user_id AS ActorExternalUserId, actor_name AS ActorName, change_type AS ChangeType, old_value AS OldValue, new_value AS NewValue, changed_at AS ChangedAt FROM session_audit_log WHERE session_id = @SessionId @@ -230,32 +255,47 @@ public sealed class SessionService( return entries.ToList(); } + public async Task UpsertDiscordUserAsync(string discordId, string displayName, string? avatarUrl) + { + await using var conn = await dataSource.OpenConnectionAsync(); + await conn.ExecuteAsync( + """ + INSERT INTO players (display_name, platform, external_user_id, external_username) + VALUES (@DisplayName, 'Discord', @DiscordId, @DisplayName) + ON CONFLICT (platform, external_user_id) + WHERE platform IS NOT NULL AND external_user_id IS NOT NULL + DO UPDATE + SET display_name = EXCLUDED.display_name, + external_username = EXCLUDED.external_username + """, + new { DisplayName = displayName, DiscordId = discordId }); + } + public async Task AddGroupCoGmAsync( Guid groupId, - long ownerTelegramId, - long coGmTelegramId, - string displayName, - string? telegramUsername) + string ownerPlatform, string ownerExternalUserId, + string coGmPlatform, string coGmExternalUserId, + string displayName, string? externalUsername) { await using var conn = await dataSource.OpenConnectionAsync(); await using var transaction = await conn.BeginTransactionAsync(); await conn.ExecuteAsync( """ - INSERT INTO players (telegram_id, display_name, telegram_username, platform, external_user_id, external_username) - VALUES (@TelegramId, @DisplayName, @TelegramUsername, 'Telegram', @TelegramId::TEXT, @TelegramUsername) - ON CONFLICT (telegram_id) DO UPDATE + INSERT INTO players (display_name, telegram_username, platform, external_user_id, external_username) + VALUES (@DisplayName, @ExternalUsername, @Platform, @ExternalUserId, @ExternalUsername) + ON CONFLICT (platform, external_user_id) + WHERE platform IS NOT NULL AND external_user_id IS NOT NULL + DO UPDATE SET display_name = EXCLUDED.display_name, - telegram_username = EXCLUDED.telegram_username, - platform = COALESCE(players.platform, 'Telegram'), - external_user_id = COALESCE(players.external_user_id, EXCLUDED.telegram_id::TEXT), - external_username = COALESCE(players.external_username, EXCLUDED.telegram_username) + external_username = EXCLUDED.external_username """, new { - TelegramId = coGmTelegramId, DisplayName = displayName, - TelegramUsername = telegramUsername + ExternalUsername = externalUsername, + Platform = coGmPlatform, + ExternalUserId = coGmExternalUserId }, transaction); @@ -267,8 +307,8 @@ public sealed class SessionService( @CoGmRole, owner_player.id FROM players co_gm - LEFT JOIN players owner_player ON owner_player.telegram_id = @OwnerTelegramId - WHERE co_gm.telegram_id = @CoGmTelegramId + LEFT JOIN players owner_player ON owner_player.platform = @OwnerPlatform AND owner_player.external_user_id = @OwnerExternalUserId + WHERE co_gm.platform = @CoGmPlatform AND co_gm.external_user_id = @CoGmExternalUserId ON CONFLICT (group_id, player_id) DO UPDATE SET role = CASE WHEN group_managers.role = @OwnerRole THEN group_managers.role @@ -279,8 +319,10 @@ public sealed class SessionService( new { GroupId = groupId, - OwnerTelegramId = ownerTelegramId, - CoGmTelegramId = coGmTelegramId, + OwnerPlatform = ownerPlatform, + OwnerExternalUserId = ownerExternalUserId, + CoGmPlatform = coGmPlatform, + CoGmExternalUserId = coGmExternalUserId, OwnerRole = GroupManagerRoleExtensions.OwnerValue, CoGmRole = GroupManagerRoleExtensions.CoGmValue }, @@ -289,7 +331,7 @@ public sealed class SessionService( await transaction.CommitAsync(); } - public async Task RemoveGroupCoGmAsync(Guid groupId, long coGmTelegramId) + public async Task RemoveGroupCoGmAsync(Guid groupId, string coGmPlatform, string coGmExternalUserId) { await using var conn = await dataSource.OpenConnectionAsync(); await conn.ExecuteAsync( @@ -298,13 +340,15 @@ public sealed class SessionService( USING players p WHERE gm.player_id = p.id AND gm.group_id = @GroupId - AND p.telegram_id = @CoGmTelegramId + AND p.platform = @Platform + AND p.external_user_id = @ExternalUserId AND gm.role = @CoGmRole """, new { GroupId = groupId, - CoGmTelegramId = coGmTelegramId, + Platform = coGmPlatform, + ExternalUserId = coGmExternalUserId, CoGmRole = GroupManagerRoleExtensions.CoGmValue }); } @@ -426,7 +470,7 @@ public sealed class SessionService( if (oldSession is null) { - throw new SessionAccessDeniedException(sessionId, 0); + throw new SessionAccessDeniedException(sessionId, "0"); } var updatedRows = await conn.ExecuteAsync( @@ -454,7 +498,7 @@ public sealed class SessionService( if (updatedRows == 0) { - throw new SessionAccessDeniedException(sessionId, 0); + throw new SessionAccessDeniedException(sessionId, "0"); } await conn.ExecuteAsync( @@ -513,7 +557,7 @@ public sealed class SessionService( if (session is null) { - throw new SessionAccessDeniedException(sessionId, 0); + throw new SessionAccessDeniedException(sessionId, "0"); } var activeParticipants = await conn.ExecuteScalarAsync( @@ -597,9 +641,11 @@ public sealed class SessionService( return (await conn.QueryAsync( """ SELECT sp.id AS Id, - p.telegram_id AS TelegramId, + COALESCE(p.telegram_id, 0) AS TelegramId, + COALESCE(p.external_user_id, p.telegram_id::TEXT) AS ExternalUserId, p.display_name AS DisplayName, p.telegram_username AS TelegramUsername, + COALESCE(p.external_username, p.telegram_username) AS ExternalUsername, sp.rsvp_status AS RsvpStatus, sp.registration_status AS RegistrationStatus, sp.is_gm AS IsGm, @@ -637,7 +683,7 @@ public sealed class SessionService( if (session is null) { - throw new SessionAccessDeniedException(sessionId, 0); + throw new SessionAccessDeniedException(sessionId, "0"); } var participant = await conn.QuerySingleOrDefaultAsync( @@ -744,7 +790,7 @@ public sealed class SessionService( var batch = await GetBatchInfoAsync(conn, batchId, groupId, transaction); if (batch is null) { - throw new SessionAccessDeniedException(batchId, 0); + throw new SessionAccessDeniedException(batchId, "0"); } var updatedRows = await conn.ExecuteAsync( @@ -767,7 +813,7 @@ public sealed class SessionService( if (updatedRows == 0) { - throw new SessionAccessDeniedException(batchId, 0); + throw new SessionAccessDeniedException(batchId, "0"); } await transaction.CommitAsync(); @@ -786,7 +832,7 @@ public sealed class SessionService( var batch = await GetBatchInfoAsync(conn, batchId, groupId, transaction); if (batch is null) { - throw new SessionAccessDeniedException(batchId, 0); + throw new SessionAccessDeniedException(batchId, "0"); } var updatedRows = await conn.ExecuteAsync( @@ -807,7 +853,7 @@ public sealed class SessionService( if (updatedRows == 0) { - throw new SessionAccessDeniedException(batchId, 0); + throw new SessionAccessDeniedException(batchId, "0"); } await transaction.CommitAsync(); @@ -844,7 +890,7 @@ public sealed class SessionService( if (batchSessions.Count == 0) { - throw new SessionAccessDeniedException(batchId, 0); + throw new SessionAccessDeniedException(batchId, "0"); } var newSchedule = BatchSchedulePlanner.BuildFixedIntervalSchedule( @@ -928,7 +974,7 @@ public sealed class SessionService( if (sourceSessions.Count == 0) { - throw new SessionAccessDeniedException(batchId, 0); + throw new SessionAccessDeniedException(batchId, "0"); } var newBatchId = Guid.NewGuid(); @@ -1130,7 +1176,7 @@ public sealed class SessionService( if (template is null) { - throw new SessionAccessDeniedException(templateId, 0); + throw new SessionAccessDeniedException(templateId, "0"); } var group = await conn.QuerySingleOrDefaultAsync( @@ -1140,7 +1186,7 @@ public sealed class SessionService( if (group is null) { - throw new SessionAccessDeniedException(groupId, 0); + throw new SessionAccessDeniedException(groupId, "0"); } var schedule = BatchSchedulePlanner.BuildRecurringSchedule( diff --git a/src/GmRelay.Web/wwwroot/app.css b/src/GmRelay.Web/wwwroot/app.css index 3561089..317e56b 100644 --- a/src/GmRelay.Web/wwwroot/app.css +++ b/src/GmRelay.Web/wwwroot/app.css @@ -1619,3 +1619,78 @@ body.telegram-mini-app .session-card-mobile { display: flex; } } + +/* === Discord Login Button === */ +.login-btn-discord { + display: flex; + align-items: center; + justify-content: center; + gap: 0.75rem; + width: 100%; + padding: 0.75rem 1.25rem; + border-radius: var(--radius-md); + font-weight: 500; + font-size: 0.9375rem; + text-decoration: none; + transition: all 0.2s ease; + background-color: #5865F2; + color: white; + border: none; + cursor: pointer; +} + +.login-btn-discord:hover { + background-color: #4752C4; + transform: translateY(-1px); +} + +.login-btn-discord:active { + transform: translateY(0); +} + +.login-btn-discord .login-btn-icon { + width: 20px; + height: 20px; + flex-shrink: 0; +} + +.login-divider { + display: flex; + align-items: center; + margin: 1.25rem 0; + color: var(--text-muted); + font-size: 0.875rem; +} + +.login-divider::before, +.login-divider::after { + content: ""; + flex: 1; + height: 1px; + background: var(--border-color); +} + +.login-divider span { + padding: 0 1rem; +} + +/* === Platform Indicator in Nav === */ +.nav-user-info { + display: flex; + flex-direction: column; + gap: 0.125rem; +} + +.nav-user-platform { + font-size: 0.6875rem; + color: var(--text-muted); + text-transform: uppercase; + letter-spacing: 0.025em; +} + +.nav-user-avatar img { + width: 100%; + height: 100%; + object-fit: cover; + border-radius: 50%; +} diff --git a/tests/GmRelay.Bot.Tests/Discord/DiscordProjectStructureTests.cs b/tests/GmRelay.Bot.Tests/Discord/DiscordProjectStructureTests.cs index 0b151e8..63b8172 100644 --- a/tests/GmRelay.Bot.Tests/Discord/DiscordProjectStructureTests.cs +++ b/tests/GmRelay.Bot.Tests/Discord/DiscordProjectStructureTests.cs @@ -61,7 +61,7 @@ public sealed class DiscordProjectStructureTests var prChecks = File.ReadAllText(Path.Combine(repoRoot, ".gitea", "workflows", "pr-checks.yml")); var deploy = File.ReadAllText(Path.Combine(repoRoot, ".gitea", "workflows", "deploy.yml")); - Assert.Contains("gmrelay-discord-bot:2.7.2", compose); + Assert.Contains("gmrelay-discord-bot:2.8.0", compose); Assert.Contains("Discord__Token=${DISCORD_BOT_TOKEN:?Set DISCORD_BOT_TOKEN in .env}", compose); Assert.Contains("src/GmRelay.DiscordBot/Dockerfile", deploy); Assert.Contains("DISCORD_BOT_TOKEN", deploy); @@ -75,13 +75,13 @@ public sealed class DiscordProjectStructureTests { var repoRoot = GetRepoRoot(); - Assert.Contains("2.7.2", File.ReadAllText(Path.Combine(repoRoot, "Directory.Build.props"))); - Assert.Contains("VERSION: 2.7.2", File.ReadAllText(Path.Combine(repoRoot, ".gitea", "workflows", "deploy.yml"))); - Assert.Contains("gmrelay-bot:2.7.2", File.ReadAllText(Path.Combine(repoRoot, "compose.yaml"))); - Assert.Contains("gmrelay-web:2.7.2", File.ReadAllText(Path.Combine(repoRoot, "compose.yaml"))); - Assert.Contains("gmrelay-discord-bot:2.7.2", File.ReadAllText(Path.Combine(repoRoot, "compose.yaml"))); + Assert.Contains("2.8.0", File.ReadAllText(Path.Combine(repoRoot, "Directory.Build.props"))); + Assert.Contains("VERSION: 2.8.0", File.ReadAllText(Path.Combine(repoRoot, ".gitea", "workflows", "deploy.yml"))); + Assert.Contains("gmrelay-bot:2.8.0", File.ReadAllText(Path.Combine(repoRoot, "compose.yaml"))); + Assert.Contains("gmrelay-web:2.8.0", File.ReadAllText(Path.Combine(repoRoot, "compose.yaml"))); + Assert.Contains("gmrelay-discord-bot:2.8.0", File.ReadAllText(Path.Combine(repoRoot, "compose.yaml"))); Assert.Contains( - "v2.7.2", + "v2.8.0", File.ReadAllText(Path.Combine(repoRoot, "src", "GmRelay.Web", "Components", "Layout", "NavMenu.razor"))); } diff --git a/tests/GmRelay.Bot.Tests/Web/AuthorizedSessionServiceTests.cs b/tests/GmRelay.Bot.Tests/Web/AuthorizedSessionServiceTests.cs index 1fdf051..1b1b70d 100644 --- a/tests/GmRelay.Bot.Tests/Web/AuthorizedSessionServiceTests.cs +++ b/tests/GmRelay.Bot.Tests/Web/AuthorizedSessionServiceTests.cs @@ -1,10 +1,29 @@ using GmRelay.Web.Services; +using System.Security.Claims; +using Microsoft.AspNetCore.Http; using GmRelay.Shared.Domain; namespace GmRelay.Bot.Tests.Web; public sealed class AuthorizedSessionServiceTests { + private static IHttpContextAccessor CreateAccessor(string externalUserId, string? name = null) + { + var claims = new List + { + new Claim(ClaimTypes.NameIdentifier, externalUserId), + new Claim("TelegramId", externalUserId), + new Claim("Platform", "Telegram") + }; + if (name is not null) + claims.Add(new Claim(ClaimTypes.Name, name)); + + var identity = new ClaimsIdentity(claims, "Test"); + var principal = new ClaimsPrincipal(identity); + var httpContext = new DefaultHttpContext { User = principal }; + return new HttpContextAccessor { HttpContext = httpContext }; + } + [Fact] public async Task GetUpcomingSessionsForGmAsync_ReturnsSessions_WhenGroupBelongsToGm() { @@ -19,9 +38,10 @@ public sealed class AuthorizedSessionServiceTests [ new(Guid.NewGuid(), groupId, "Session A", DateTime.UtcNow, "Planned", "https://example.test/a", Guid.NewGuid(), 10, 42, 4, 1, 0) ]); - var service = new AuthorizedSessionService(store); + var accessor = CreateAccessor(gmId.ToString()); + var service = new AuthorizedSessionService(store, accessor); - var sessions = await service.GetUpcomingSessionsForGmAsync(groupId, gmId); + var sessions = await service.GetUpcomingSessionsForCurrentUserAsync(groupId); Assert.NotNull(sessions); Assert.Single(sessions); @@ -47,9 +67,10 @@ public sealed class AuthorizedSessionServiceTests [ new(groupId, coGmId, GroupManagerRole.CoGm) ]); - var service = new AuthorizedSessionService(store); + var accessor = CreateAccessor(coGmId.ToString()); + var service = new AuthorizedSessionService(store, accessor); - var sessions = await service.GetUpcomingSessionsForGmAsync(groupId, coGmId); + var sessions = await service.GetUpcomingSessionsForCurrentUserAsync(groupId); Assert.NotNull(sessions); Assert.Single(sessions); @@ -65,9 +86,10 @@ public sealed class AuthorizedSessionServiceTests [ new(groupId, 42, "Alpha", 2002L) ]); - var service = new AuthorizedSessionService(store); + var accessor = CreateAccessor("1001"); + var service = new AuthorizedSessionService(store, accessor); - var sessions = await service.GetUpcomingSessionsForGmAsync(groupId, 1001L); + var sessions = await service.GetUpcomingSessionsForCurrentUserAsync(groupId); Assert.Null(sessions); } @@ -87,9 +109,10 @@ public sealed class AuthorizedSessionServiceTests [ new(sessionId, groupId, "Session A", DateTime.UtcNow, "Planned", "https://example.test/a", Guid.NewGuid(), 10, 42, 4, 1, 0) ]); - var service = new AuthorizedSessionService(store); + var accessor = CreateAccessor(gmId.ToString()); + var service = new AuthorizedSessionService(store, accessor); - var session = await service.GetSessionForGmAsync(sessionId, gmId); + var session = await service.GetSessionForCurrentUserAsync(sessionId); Assert.NotNull(session); Assert.Equal(sessionId, session.Id); @@ -109,9 +132,10 @@ public sealed class AuthorizedSessionServiceTests [ new(sessionId, groupId, "Session A", DateTime.UtcNow, "Planned", "https://example.test/a", Guid.NewGuid(), 10, 42, 4, 1, 0) ]); - var service = new AuthorizedSessionService(store); + var accessor = CreateAccessor("1001"); + var service = new AuthorizedSessionService(store, accessor); - var session = await service.GetSessionForGmAsync(sessionId, 1001L); + var session = await service.GetSessionForCurrentUserAsync(sessionId); Assert.Null(session); } @@ -130,9 +154,10 @@ public sealed class AuthorizedSessionServiceTests [ new(sessionId, groupId, "Session A", DateTime.UtcNow, "Planned", "https://example.test/a", Guid.NewGuid(), 10, 42, 4, 1, 0) ]); - var service = new AuthorizedSessionService(store); + var accessor = CreateAccessor("1001"); + var service = new AuthorizedSessionService(store, accessor); - var action = () => service.UpdateSessionForGmAsync(sessionId, 1001L, "Updated", DateTime.UtcNow.AddDays(1), "https://example.test/b", 5); + var action = () => service.UpdateSessionForCurrentUserAsync(sessionId, "Updated", DateTime.UtcNow.AddDays(1), "https://example.test/b", 5); await Assert.ThrowsAsync(action); Assert.False(store.UpdateCalled); @@ -154,9 +179,10 @@ public sealed class AuthorizedSessionServiceTests [ new(sessionId, groupId, "Session A", DateTime.UtcNow, "Planned", "https://example.test/a", Guid.NewGuid(), 10, 42, 4, 1, 0) ]); - var service = new AuthorizedSessionService(store); + var accessor = CreateAccessor(gmId.ToString()); + var service = new AuthorizedSessionService(store, accessor); - await service.UpdateSessionForGmAsync(sessionId, gmId, "Updated", scheduledAt, "https://example.test/b", 5); + await service.UpdateSessionForCurrentUserAsync(sessionId, "Updated", scheduledAt, "https://example.test/b", 5); Assert.True(store.UpdateCalled); Assert.Equal(groupId, store.LastUpdatedGroupId); @@ -183,9 +209,10 @@ public sealed class AuthorizedSessionServiceTests [ new(sessionId, groupId, "Session A", originalTime, "Planned", "https://example.test/a", Guid.NewGuid(), 10, 42, 4, 1, 0) ]); - var service = new AuthorizedSessionService(store); + var accessor = CreateAccessor(gmId.ToString()); + var service = new AuthorizedSessionService(store, accessor); - await service.UpdateSessionForGmAsync(sessionId, gmId, "Updated Title", originalTime, "https://example.test/a", 4); + await service.UpdateSessionForCurrentUserAsync(sessionId, "Updated Title", originalTime, "https://example.test/a", 4); Assert.Single(store.LogEntries); Assert.Equal("Title", store.LogEntries[0].ChangeType); @@ -209,10 +236,11 @@ public sealed class AuthorizedSessionServiceTests [ new(sessionId, groupId, "Session A", originalTime, "Planned", "https://example.test/a", Guid.NewGuid(), 10, 42, 4, 1, 0) ]); - var service = new AuthorizedSessionService(store); + var accessor = CreateAccessor(gmId.ToString()); + var service = new AuthorizedSessionService(store, accessor); var newTime = originalTime.AddDays(1); - await service.UpdateSessionForGmAsync(sessionId, gmId, "Updated Title", newTime, "https://example.test/b", 5); + await service.UpdateSessionForCurrentUserAsync(sessionId, "Updated Title", newTime, "https://example.test/b", 5); Assert.Equal(4, store.LogEntries.Count); Assert.Contains(store.LogEntries, e => e.ChangeType == "Title"); @@ -237,9 +265,10 @@ public sealed class AuthorizedSessionServiceTests [ new(sessionId, groupId, "Session A", originalTime, "Planned", "https://example.test/a", Guid.NewGuid(), 10, 42, 4, 1, 0) ]); - var service = new AuthorizedSessionService(store); + var accessor = CreateAccessor(gmId.ToString()); + var service = new AuthorizedSessionService(store, accessor); - await service.UpdateSessionForGmAsync(sessionId, gmId, "Session A", originalTime, "https://example.test/a", 4); + await service.UpdateSessionForCurrentUserAsync(sessionId, "Session A", originalTime, "https://example.test/a", 4); Assert.Empty(store.LogEntries); } @@ -259,9 +288,10 @@ public sealed class AuthorizedSessionServiceTests [ new(sessionId, groupId, "Session A", DateTime.UtcNow, "Planned", "https://example.test/a", Guid.NewGuid(), 10, 42, 4, 1, 0) ]); - var service = new AuthorizedSessionService(store); + var accessor = CreateAccessor(gmId.ToString()); + var service = new AuthorizedSessionService(store, accessor); - var history = await service.GetSessionHistoryForGmAsync(sessionId, gmId); + var history = await service.GetSessionHistoryForCurrentUserAsync(sessionId); Assert.NotNull(history); Assert.Empty(history); @@ -281,9 +311,10 @@ public sealed class AuthorizedSessionServiceTests [ new(sessionId, groupId, "Session A", DateTime.UtcNow, "Planned", "https://example.test/a", Guid.NewGuid(), 10, 42, 4, 1, 0) ]); - var service = new AuthorizedSessionService(store); + var accessor = CreateAccessor("1001"); + var service = new AuthorizedSessionService(store, accessor); - var history = await service.GetSessionHistoryForGmAsync(sessionId, 1001L); + var history = await service.GetSessionHistoryForCurrentUserAsync(sessionId); Assert.Null(history); } @@ -303,9 +334,10 @@ public sealed class AuthorizedSessionServiceTests [ new(sessionId, groupId, "Session A", DateTime.UtcNow, "Planned", "https://example.test/a", Guid.NewGuid(), 10, 42, 4, 3, 1) ]); - var service = new AuthorizedSessionService(store); + var accessor = CreateAccessor(gmId.ToString()); + var service = new AuthorizedSessionService(store, accessor); - await service.PromoteWaitlistedPlayerForGmAsync(sessionId, gmId); + await service.PromoteWaitlistedPlayerForCurrentUserAsync(sessionId); Assert.True(store.PromoteCalled); Assert.Equal(groupId, store.LastPromotedGroupId); @@ -326,9 +358,10 @@ public sealed class AuthorizedSessionServiceTests [ new(Guid.NewGuid(), groupId, "Session A", DateTime.UtcNow, "Planned", "https://example.test/a", batchId, 10, 42, 4, 1, 0) ]); - var service = new AuthorizedSessionService(store); + var accessor = CreateAccessor("1001"); + var service = new AuthorizedSessionService(store, accessor); - var action = () => service.UpdateBatchDetailsForGmAsync(batchId, 1001L, "Updated", "https://example.test/b"); + var action = () => service.UpdateBatchDetailsForCurrentUserAsync(batchId, "Updated", "https://example.test/b"); await Assert.ThrowsAsync(action); Assert.False(store.UpdateBatchDetailsCalled); @@ -349,9 +382,10 @@ public sealed class AuthorizedSessionServiceTests [ new(Guid.NewGuid(), groupId, "Session A", DateTime.UtcNow, "Planned", "https://example.test/a", batchId, 10, 42, 4, 1, 0) ]); - var service = new AuthorizedSessionService(store); + var accessor = CreateAccessor(gmId.ToString()); + var service = new AuthorizedSessionService(store, accessor); - await service.UpdateBatchDetailsForGmAsync(batchId, gmId, "Updated", "https://example.test/b"); + await service.UpdateBatchDetailsForCurrentUserAsync(batchId, "Updated", "https://example.test/b"); Assert.True(store.UpdateBatchDetailsCalled); Assert.Equal(batchId, store.LastUpdatedBatchId); @@ -380,9 +414,10 @@ public sealed class AuthorizedSessionServiceTests [ new(groupId, coGmId, GroupManagerRole.CoGm) ]); - var service = new AuthorizedSessionService(store); + var accessor = CreateAccessor(coGmId.ToString()); + var service = new AuthorizedSessionService(store, accessor); - await service.UpdateBatchDetailsForGmAsync(batchId, coGmId, "Updated", "https://example.test/b"); + await service.UpdateBatchDetailsForCurrentUserAsync(batchId, "Updated", "https://example.test/b"); Assert.True(store.UpdateBatchDetailsCalled); Assert.Equal(batchId, store.LastUpdatedBatchId); @@ -400,9 +435,10 @@ public sealed class AuthorizedSessionServiceTests [ new(groupId, 42, "Alpha", ownerId) ]); - var service = new AuthorizedSessionService(store); + var accessor = CreateAccessor(ownerId.ToString()); + var service = new AuthorizedSessionService(store, accessor); - await service.AddCoGmForOwnerAsync(groupId, ownerId, coGmId, "Assistant GM", "assistant"); + await service.AddCoGmForOwnerAsync(groupId, "Telegram", coGmId.ToString(), "Assistant GM", "assistant"); Assert.True(store.AddCoGmCalled); Assert.Equal(groupId, store.LastAddedCoGmGroupId); @@ -427,9 +463,10 @@ public sealed class AuthorizedSessionServiceTests [ new(groupId, coGmId, GroupManagerRole.CoGm) ]); - var service = new AuthorizedSessionService(store); + var accessor = CreateAccessor(coGmId.ToString()); + var service = new AuthorizedSessionService(store, accessor); - var action = () => service.AddCoGmForOwnerAsync(groupId, coGmId, newCoGmId, "Second Assistant", null); + var action = () => service.AddCoGmForOwnerAsync(groupId, "Telegram", newCoGmId.ToString(), "Second Assistant", null); await Assert.ThrowsAsync(action); Assert.False(store.AddCoGmCalled); @@ -450,9 +487,10 @@ public sealed class AuthorizedSessionServiceTests [ new(groupId, coGmId, GroupManagerRole.CoGm) ]); - var service = new AuthorizedSessionService(store); + var accessor = CreateAccessor(ownerId.ToString()); + var service = new AuthorizedSessionService(store, accessor); - await service.RemoveCoGmForOwnerAsync(groupId, ownerId, coGmId); + await service.RemoveCoGmForOwnerAsync(groupId, "Telegram", coGmId.ToString()); Assert.True(store.RemoveCoGmCalled); Assert.Equal(groupId, store.LastRemovedCoGmGroupId); @@ -473,9 +511,10 @@ public sealed class AuthorizedSessionServiceTests [ new(Guid.NewGuid(), groupId, "Session A", DateTime.UtcNow, "Planned", "https://example.test/a", batchId, 10, 42, 4, 1, 0) ]); - var service = new AuthorizedSessionService(store); + var accessor = CreateAccessor("1001"); + var service = new AuthorizedSessionService(store, accessor); - var action = () => service.UpdateBatchNotificationModeForGmAsync(batchId, 1001L, SessionNotificationMode.GroupOnly); + var action = () => service.UpdateBatchNotificationModeForCurrentUserAsync(batchId, SessionNotificationMode.GroupOnly); await Assert.ThrowsAsync(action); Assert.False(store.UpdateBatchNotificationModeCalled); @@ -496,9 +535,10 @@ public sealed class AuthorizedSessionServiceTests [ new(Guid.NewGuid(), groupId, "Session A", DateTime.UtcNow, "Planned", "https://example.test/a", batchId, 10, 42, 4, 1, 0) ]); - var service = new AuthorizedSessionService(store); + var accessor = CreateAccessor(gmId.ToString()); + var service = new AuthorizedSessionService(store, accessor); - await service.UpdateBatchNotificationModeForGmAsync(batchId, gmId, SessionNotificationMode.GroupOnly); + await service.UpdateBatchNotificationModeForCurrentUserAsync(batchId, SessionNotificationMode.GroupOnly); Assert.True(store.UpdateBatchNotificationModeCalled); Assert.Equal(batchId, store.LastUpdatedNotificationBatchId); @@ -521,9 +561,10 @@ public sealed class AuthorizedSessionServiceTests [ new(Guid.NewGuid(), groupId, "Session A", DateTime.UtcNow, "Planned", "https://example.test/a", batchId, 10, 42, 4, 1, 0) ]); - var service = new AuthorizedSessionService(store); + var accessor = CreateAccessor(gmId.ToString()); + var service = new AuthorizedSessionService(store, accessor); - var action = () => service.RescheduleBatchForGmAsync(batchId, gmId, DateTime.UtcNow.AddDays(7), intervalDays: 0); + var action = () => service.RescheduleBatchForCurrentUserAsync(batchId, DateTime.UtcNow.AddDays(7), intervalDays: 0); await Assert.ThrowsAsync(action); Assert.False(store.RescheduleBatchCalled); @@ -545,9 +586,10 @@ public sealed class AuthorizedSessionServiceTests [ new(Guid.NewGuid(), groupId, "Session A", DateTime.UtcNow, "Planned", "https://example.test/a", batchId, 10, 42, 4, 1, 0) ]); - var service = new AuthorizedSessionService(store); + var accessor = CreateAccessor(gmId.ToString()); + var service = new AuthorizedSessionService(store, accessor); - await service.RescheduleBatchForGmAsync(batchId, gmId, firstScheduledAt, intervalDays: 14); + await service.RescheduleBatchForCurrentUserAsync(batchId, firstScheduledAt, intervalDays: 14); Assert.True(store.RescheduleBatchCalled); Assert.Equal(batchId, store.LastRescheduledBatchId); @@ -571,9 +613,10 @@ public sealed class AuthorizedSessionServiceTests [ new(Guid.NewGuid(), groupId, "Session A", DateTime.UtcNow, "Planned", "https://example.test/a", batchId, 10, 42, 4, 1, 0) ]); - var service = new AuthorizedSessionService(store); + var accessor = CreateAccessor(gmId.ToString()); + var service = new AuthorizedSessionService(store, accessor); - await service.CloneBatchForGmAsync(batchId, gmId, BatchCloneInterval.NextWeek); + await service.CloneBatchForCurrentUserAsync(batchId, BatchCloneInterval.NextWeek); Assert.True(store.CloneBatchCalled); Assert.Equal(batchId, store.LastClonedBatchId); @@ -591,11 +634,11 @@ public sealed class AuthorizedSessionServiceTests [ new(groupId, 42, "Alpha", gmId) ]); - var service = new AuthorizedSessionService(store); + var accessor = CreateAccessor(gmId.ToString()); + var service = new AuthorizedSessionService(store, accessor); - await service.CreateCampaignTemplateForGmAsync( + await service.CreateCampaignTemplateForCurrentUserAsync( groupId, - gmId, new CreateCampaignTemplateRequest( " Weekly arc ", " Kingmaker ", @@ -626,11 +669,11 @@ public sealed class AuthorizedSessionServiceTests [ new(groupId, 42, "Alpha", gmId) ]); - var service = new AuthorizedSessionService(store); + var accessor = CreateAccessor(gmId.ToString()); + var service = new AuthorizedSessionService(store, accessor); - await service.CreateCampaignTemplateForGmAsync( + await service.CreateCampaignTemplateForCurrentUserAsync( groupId, - gmId, new CreateCampaignTemplateRequest( "Open table", "West Marches", @@ -653,11 +696,11 @@ public sealed class AuthorizedSessionServiceTests [ new(groupId, 42, "Alpha", 2002L) ]); - var service = new AuthorizedSessionService(store); + var accessor = CreateAccessor("1001"); + var service = new AuthorizedSessionService(store, accessor); - var action = () => service.CreateCampaignTemplateForGmAsync( + var action = () => service.CreateCampaignTemplateForCurrentUserAsync( groupId, - 1001L, new CreateCampaignTemplateRequest( "Weekly arc", "Kingmaker", @@ -687,9 +730,10 @@ public sealed class AuthorizedSessionServiceTests [ new(templateId, groupId, "Weekly arc", "Kingmaker", "https://example.test/kingmaker", 6, 7, 5, SessionNotificationModeExtensions.GroupOnlyValue, DateTime.UtcNow, DateTime.UtcNow) ]); - var service = new AuthorizedSessionService(store); + var accessor = CreateAccessor(gmId.ToString()); + var service = new AuthorizedSessionService(store, accessor); - await service.CreateBatchFromCampaignTemplateForGmAsync(templateId, gmId, firstScheduledAt); + await service.CreateBatchFromCampaignTemplateForCurrentUserAsync(templateId, firstScheduledAt); Assert.True(store.CreateBatchFromTemplateCalled); Assert.Equal(templateId, store.LastCreatedBatchTemplateId); @@ -711,9 +755,10 @@ public sealed class AuthorizedSessionServiceTests [ new(templateId, groupId, "Weekly arc", "Kingmaker", "https://example.test/kingmaker", 6, 7, 5, SessionNotificationModeExtensions.GroupOnlyValue, DateTime.UtcNow, DateTime.UtcNow) ]); - var service = new AuthorizedSessionService(store); + var accessor = CreateAccessor("1001"); + var service = new AuthorizedSessionService(store, accessor); - var action = () => service.CreateBatchFromCampaignTemplateForGmAsync(templateId, 1001L, DateTime.UtcNow.AddDays(3)); + var action = () => service.CreateBatchFromCampaignTemplateForCurrentUserAsync(templateId, DateTime.UtcNow.AddDays(3)); await Assert.ThrowsAsync(action); Assert.False(store.CreateBatchFromTemplateCalled); @@ -1019,7 +1064,7 @@ public sealed class AuthorizedSessionServiceTests public Task LogSessionChangeAsync(Guid sessionId, long actorTelegramId, string actorName, string changeType, string? oldValue, string? newValue) { - var entry = new SessionAuditLogEntry(Guid.NewGuid(), sessionId, actorTelegramId, actorName, changeType, oldValue, newValue, DateTime.UtcNow); + var entry = new SessionAuditLogEntry(Guid.NewGuid(), sessionId, actorTelegramId.ToString(), actorName, changeType, oldValue, newValue, DateTime.UtcNow); LogEntries.Add(entry); LastLogSessionId = sessionId; LastLogActorTelegramId = actorTelegramId; @@ -1033,12 +1078,62 @@ public sealed class AuthorizedSessionServiceTests public Task> GetSessionHistoryAsync(Guid sessionId) => Task.FromResult(LogEntries.Where(e => e.SessionId == sessionId).OrderByDescending(e => e.ChangedAt).ToList()); + public Task> GetGroupsForUserAsync(string platform, string externalUserId) => + Task.FromResult(groupsById.Values.Where(group => IsManager(group.Id, externalUserId)).ToList()); + + public Task IsGroupManagerAsync(Guid groupId, string platform, string externalUserId) => + Task.FromResult(IsManager(groupId, externalUserId)); + + public Task IsGroupOwnerAsync(Guid groupId, string platform, string externalUserId) => + Task.FromResult(IsOwner(groupId, externalUserId)); + + public Task AddGroupCoGmAsync(Guid groupId, string ownerPlatform, string ownerExternalUserId, string coGmPlatform, string coGmExternalUserId, string displayName, string? externalUsername) + { + AddCoGmCalled = true; + LastAddedCoGmGroupId = groupId; + LastAddedCoGmTelegramId = long.TryParse(coGmExternalUserId, out var id) ? id : null; + LastAddedCoGmDisplayName = displayName; + LastAddedCoGmUsername = externalUsername; + return Task.CompletedTask; + } + + public Task RemoveGroupCoGmAsync(Guid groupId, string coGmPlatform, string coGmExternalUserId) + { + RemoveCoGmCalled = true; + LastRemovedCoGmGroupId = groupId; + LastRemovedCoGmTelegramId = long.TryParse(coGmExternalUserId, out var id) ? id : null; + return Task.CompletedTask; + } + + public Task LogSessionChangeAsync(Guid sessionId, string actorExternalUserId, string actorName, string changeType, string? oldValue, string? newValue) + { + var entry = new SessionAuditLogEntry(Guid.NewGuid(), sessionId, actorExternalUserId, actorName, changeType, oldValue, newValue, DateTime.UtcNow); + LogEntries.Add(entry); + LastLogSessionId = sessionId; + LastLogActorTelegramId = long.TryParse(actorExternalUserId, out var id) ? (long?)id : null; + LastLogActorName = actorName; + LastLogChangeType = changeType; + LastLogOldValue = oldValue; + LastLogNewValue = newValue; + return Task.CompletedTask; + } + + public Task UpsertDiscordUserAsync(string discordId, string displayName, string? avatarUrl) => + Task.CompletedTask; + private bool IsManager(Guid groupId, long telegramId) => IsOwner(groupId, telegramId) || managers.Any(manager => manager.GroupId == groupId && manager.TelegramId == telegramId); private bool IsOwner(Guid groupId, long telegramId) => groupsById.TryGetValue(groupId, out var group) && group.GmTelegramId == telegramId; + + private bool IsManager(Guid groupId, string externalUserId) => + IsOwner(groupId, externalUserId) || + managers.Any(manager => manager.GroupId == groupId && manager.TelegramId.ToString() == externalUserId); + + private bool IsOwner(Guid groupId, string externalUserId) => + groupsById.TryGetValue(groupId, out var group) && group.GmTelegramId.ToString() == externalUserId; } private sealed record FakeGroupManager(Guid GroupId, long TelegramId, GroupManagerRole Role); diff --git a/tests/GmRelay.Bot.Tests/Web/DiscordAuthServiceTests.cs b/tests/GmRelay.Bot.Tests/Web/DiscordAuthServiceTests.cs new file mode 100644 index 0000000..8e27711 --- /dev/null +++ b/tests/GmRelay.Bot.Tests/Web/DiscordAuthServiceTests.cs @@ -0,0 +1,139 @@ +using System.Net; +using System.Text.Json; +using GmRelay.Web; +using GmRelay.Web.Services; +using Microsoft.Extensions.Configuration; + +namespace GmRelay.Bot.Tests.Web; + +public class DiscordAuthServiceTests +{ + [Fact] + public void BuildAuthorizeUrl_GeneratesCorrectUrl() + { + var config = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["Discord:ClientId"] = "12345", + ["Discord:ClientSecret"] = "secret", + ["Discord:RedirectUri"] = "https://example.com/callback" + }) + .Build(); + + var service = new DiscordAuthService(new TestHttpClientFactory(), config); + var url = service.BuildAuthorizeUrl("state123"); + + Assert.Contains("client_id=12345", url); + Assert.Contains("response_type=code", url); + Assert.Contains("state=state123", url); + Assert.Contains("https%3A%2F%2Fexample.com%2Fcallback", url); + } + + [Fact] + public void BuildAuthorizeUrl_WithMissingConfig_ThrowsInvalidOperationException() + { + var config = new ConfigurationBuilder().Build(); + var service = new DiscordAuthService(new TestHttpClientFactory(), config); + + Assert.Throws(() => service.BuildAuthorizeUrl("state")); + } + + [Fact] + public async Task ExchangeCodeAsync_WithValidCode_ReturnsUser() + { + var handler = new TestHttpMessageHandler((request) => + { + if (request.RequestUri?.AbsolutePath == "/api/oauth2/token") + { + var response = new DiscordTokenResponse("access_123", "Bearer", 604800, "identify"); + return new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(JsonSerializer.Serialize(response)) + }; + } + + if (request.RequestUri?.AbsolutePath == "/api/users/@me") + { + var user = new DiscordUser("98765", "TestUser", "0", "avatar123", null); + return new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(JsonSerializer.Serialize(user)) + }; + } + + return new HttpResponseMessage(HttpStatusCode.NotFound); + }); + + var config = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["Discord:ClientId"] = "client", + ["Discord:ClientSecret"] = "secret", + ["Discord:RedirectUri"] = "https://example.com/callback" + }) + .Build(); + + var factory = new TestHttpClientFactory(handler); + var service = new DiscordAuthService(factory, config); + + var result = await service.ExchangeCodeAsync("valid_code"); + + Assert.NotNull(result); + Assert.Equal("98765", result.Id); + Assert.Equal("TestUser", result.Username); + Assert.Equal("https://cdn.discordapp.com/avatars/98765/avatar123.png", result.AvatarUrl); + } + + [Fact] + public async Task ExchangeCodeAsync_WithInvalidCode_ReturnsNull() + { + var handler = new TestHttpMessageHandler((request) => + { + return new HttpResponseMessage(HttpStatusCode.BadRequest); + }); + + var config = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["Discord:ClientId"] = "client", + ["Discord:ClientSecret"] = "secret", + ["Discord:RedirectUri"] = "https://example.com/callback" + }) + .Build(); + + var factory = new TestHttpClientFactory(handler); + var service = new DiscordAuthService(factory, config); + + var result = await service.ExchangeCodeAsync("invalid_code"); + + Assert.Null(result); + } + + private class TestHttpClientFactory : IHttpClientFactory + { + private readonly HttpMessageHandler? _handler; + + public TestHttpClientFactory(HttpMessageHandler? handler = null) + { + _handler = handler; + } + + public HttpClient CreateClient(string name) => + _handler is not null ? new HttpClient(_handler) : new HttpClient(); + } + + private class TestHttpMessageHandler : HttpMessageHandler + { + private readonly Func _handler; + + public TestHttpMessageHandler(Func handler) + { + _handler = handler; + } + + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + return Task.FromResult(_handler(request)); + } + } +} diff --git a/tests/GmRelay.Bot.Tests/Web/PlatformIdentityTests.cs b/tests/GmRelay.Bot.Tests/Web/PlatformIdentityTests.cs new file mode 100644 index 0000000..7ce8e86 --- /dev/null +++ b/tests/GmRelay.Bot.Tests/Web/PlatformIdentityTests.cs @@ -0,0 +1,114 @@ +using System.Security.Claims; +using GmRelay.Web.Services; + +namespace GmRelay.Bot.Tests.Web; + +public class PlatformIdentityTests +{ + [Fact] + public void TryGetPlatformIdentity_TelegramUser_ReturnsTelegramPlatform() + { + var user = new ClaimsPrincipal(new ClaimsIdentity(new[] + { + new Claim(ClaimTypes.NameIdentifier, "12345"), + new Claim("TelegramId", "12345") + })); + + var result = user.TryGetPlatformIdentity(out var platform, out var externalUserId); + + Assert.True(result); + Assert.Equal("Telegram", platform); + Assert.Equal("12345", externalUserId); + } + + [Fact] + public void TryGetPlatformIdentity_TelegramUserWithPlatformClaim_ReturnsTelegramPlatform() + { + var user = new ClaimsPrincipal(new ClaimsIdentity(new[] + { + new Claim(ClaimTypes.NameIdentifier, "12345"), + new Claim("Platform", "Telegram") + })); + + var result = user.TryGetPlatformIdentity(out var platform, out var externalUserId); + + Assert.True(result); + Assert.Equal("Telegram", platform); + Assert.Equal("12345", externalUserId); + } + + [Fact] + public void TryGetPlatformIdentity_DiscordUser_ReturnsDiscordPlatform() + { + var user = new ClaimsPrincipal(new ClaimsIdentity(new[] + { + new Claim(ClaimTypes.NameIdentifier, "98765"), + new Claim("DiscordId", "98765"), + new Claim("Platform", "Discord") + })); + + var result = user.TryGetPlatformIdentity(out var platform, out var externalUserId); + + Assert.True(result); + Assert.Equal("Discord", platform); + Assert.Equal("98765", externalUserId); + } + + [Fact] + public void TryGetPlatformIdentity_UnknownUser_ReturnsFalse() + { + var user = new ClaimsPrincipal(new ClaimsIdentity(new[] + { + new Claim(ClaimTypes.Name, "Anonymous") + })); + + var result = user.TryGetPlatformIdentity(out _, out _); + Assert.False(result); + } + + [Fact] + public void TryGetDiscordId_DiscordUser_ReturnsTrue() + { + var user = new ClaimsPrincipal(new ClaimsIdentity(new[] + { + new Claim("DiscordId", "123") + })); + + var result = user.TryGetDiscordId(out var discordId); + Assert.True(result); + Assert.Equal("123", discordId); + } + + [Fact] + public void TryGetDiscordId_TelegramUser_ReturnsFalse() + { + var user = new ClaimsPrincipal(new ClaimsIdentity(new[] + { + new Claim("TelegramId", "123") + })); + + var result = user.TryGetDiscordId(out _); + Assert.False(result); + } + + [Fact] + public void GetAvatarUrl_DiscordUserWithAvatar_ReturnsUrl() + { + var user = new ClaimsPrincipal(new ClaimsIdentity(new[] + { + new Claim("AvatarUrl", "https://cdn.discordapp.com/avatars/1/abc.png") + })); + + var result = user.GetAvatarUrl(); + Assert.Equal("https://cdn.discordapp.com/avatars/1/abc.png", result); + } + + [Fact] + public void GetAvatarUrl_UserWithoutAvatar_ReturnsNull() + { + var user = new ClaimsPrincipal(new ClaimsIdentity(Array.Empty())); + + var result = user.GetAvatarUrl(); + Assert.Null(result); + } +}