diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml index 0cf95a2..e833f01 100644 --- a/.gitea/workflows/deploy.yml +++ b/.gitea/workflows/deploy.yml @@ -6,7 +6,7 @@ on: - main env: - VERSION: 2.8.1 + VERSION: 3.0.0 jobs: # ЧАСТЬ 1: Собираем образы и кладем в Gitea (чтобы делиться с ребятами) diff --git a/Directory.Build.props b/Directory.Build.props index 400b286..7b5cdf4 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -1,6 +1,6 @@ - 2.8.1 + 3.0.0 net10.0 preview enable diff --git a/compose.yaml b/compose.yaml index 6d6ce7f..bf16153 100644 --- a/compose.yaml +++ b/compose.yaml @@ -49,7 +49,7 @@ services: crond -f bot: - image: git.codeanddice.ru/toutsu/gmrelay-bot:2.8.1 + image: git.codeanddice.ru/toutsu/gmrelay-bot:3.0.0 restart: always depends_on: db: @@ -67,7 +67,7 @@ services: retries: 3 discord: - image: git.codeanddice.ru/toutsu/gmrelay-discord-bot:2.8.1 + image: git.codeanddice.ru/toutsu/gmrelay-discord-bot:3.0.0 restart: always depends_on: db: @@ -84,7 +84,7 @@ services: retries: 3 web: - image: git.codeanddice.ru/toutsu/gmrelay-web:2.8.1 + image: git.codeanddice.ru/toutsu/gmrelay-web:3.0.0 restart: always depends_on: db: diff --git a/src/GmRelay.Bot/Migrations/V020__player_identity_links.sql b/src/GmRelay.Bot/Migrations/V020__player_identity_links.sql new file mode 100644 index 0000000..3d08824 --- /dev/null +++ b/src/GmRelay.Bot/Migrations/V020__player_identity_links.sql @@ -0,0 +1,37 @@ +-- ============================================================= +-- V020: Player identity linking for unified multi-platform accounts +-- ============================================================= +-- Scope: Allow linking multiple platform identities (Telegram, Discord) +-- to a single "primary" player account. All group/session permissions +-- resolve through the effective (primary) player id. +-- ============================================================= + +-- player_links: secondary player → primary player (1:1 on secondary) +CREATE TABLE player_links ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + primary_player_id UUID NOT NULL REFERENCES players(id) ON DELETE CASCADE, + secondary_player_id UUID NOT NULL UNIQUE REFERENCES players(id) ON DELETE CASCADE, + linked_at TIMESTAMPTZ NOT NULL DEFAULT now(), + linked_by_player_id UUID REFERENCES players(id) ON DELETE SET NULL, + -- Prevent self-linking at the DB level + CONSTRAINT no_self_link CHECK (primary_player_id <> secondary_player_id) +); + +CREATE INDEX ix_player_links_primary_player_id + ON player_links(primary_player_id); + +-- identity_audit_log: security-sensitive link/unlink actions +CREATE TABLE identity_audit_log ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + player_id UUID NOT NULL REFERENCES players(id) ON DELETE CASCADE, + action VARCHAR(50) NOT NULL, -- 'link', 'unlink', 'link_attempt_conflict' + target_platform VARCHAR(50), + target_external_user_id VARCHAR(255), + performed_at TIMESTAMPTZ NOT NULL DEFAULT now(), + performed_by_player_id UUID REFERENCES players(id) ON DELETE SET NULL +); + +CREATE INDEX ix_identity_audit_log_player_id + ON identity_audit_log(player_id); +CREATE INDEX ix_identity_audit_log_performed_at + ON identity_audit_log(performed_at DESC); diff --git a/src/GmRelay.Web/Components/Layout/NavMenu.razor b/src/GmRelay.Web/Components/Layout/NavMenu.razor index 70ad5ad..a060d16 100644 --- a/src/GmRelay.Web/Components/Layout/NavMenu.razor +++ b/src/GmRelay.Web/Components/Layout/NavMenu.razor @@ -34,6 +34,13 @@ Шаблоны + + + + + + Профиль + diff --git a/src/GmRelay.Web/Components/Pages/Profile.razor b/src/GmRelay.Web/Components/Pages/Profile.razor new file mode 100644 index 0000000..f565102 --- /dev/null +++ b/src/GmRelay.Web/Components/Pages/Profile.razor @@ -0,0 +1,156 @@ +@page "/profile" +@using Microsoft.AspNetCore.Authorization +@using Microsoft.AspNetCore.Components.Authorization +@using System.Net.Http.Json +@attribute [Authorize] +@inject IHttpClientFactory HttpClientFactory +@inject NavigationManager Navigation + +Профиль — GM-Relay + +
+

Профиль

+ + @if (identities is null) + { +

Загрузка...

+ } + else if (identities.Count == 0) + { +
+

Связанные аккаунты не найдены.

+
+ } + else + { +
+

Связанные аккаунты

+
    + @foreach (var id in identities) + { +
  • +
    + @id.Platform + @id.DisplayName +
    + @if (id.Platform != currentPlatform || id.ExternalUserId != currentExternalUserId) + { + + } + else + { + Текущий + } +
  • + } +
+
+ } + +
+

Добавить аккаунт

+ @if (!HasLinkedPlatform("Discord")) + { + + Привязать Discord + + } + else + { +

Discord уже привязан.

+ } +
+ + @if (!string.IsNullOrWhiteSpace(errorMessage)) + { +
@errorMessage
+ } + + @if (!string.IsNullOrWhiteSpace(successMessage)) + { +
@successMessage
+ } +
+ +@code { + private List? identities; + private string? currentPlatform; + private string? currentExternalUserId; + private bool isUnlinking; + private string? errorMessage; + private string? successMessage; + + [CascadingParameter] + private Task? AuthenticationStateTask { get; set; } + + protected override async Task OnInitializedAsync() + { + if (AuthenticationStateTask is not null) + { + var authState = await AuthenticationStateTask; + var user = authState.User; + if (user.TryGetPlatformIdentity(out var plat, out var extId)) + { + currentPlatform = plat; + currentExternalUserId = extId; + } + } + + await LoadIdentities(); + } + + private async Task LoadIdentities() + { + try + { + var http = HttpClientFactory.CreateClient(); + http.BaseAddress = new Uri(Navigation.BaseUri); + identities = await http.GetFromJsonAsync>("api/me/identities"); + } + catch (Exception ex) + { + errorMessage = $"Не удалось загрузить аккаунты: {ex.Message}"; + } + } + + private bool HasLinkedPlatform(string platform) + { + return identities?.Any(i => i.Platform == platform) ?? false; + } + + private async Task Unlink(string platform, string externalUserId) + { + isUnlinking = true; + errorMessage = null; + successMessage = null; + + try + { + var http = HttpClientFactory.CreateClient(); + http.BaseAddress = new Uri(Navigation.BaseUri); + var response = await http.DeleteAsync($"api/me/identities/{Uri.EscapeDataString(platform)}/{Uri.EscapeDataString(externalUserId)}"); + if (response.IsSuccessStatusCode) + { + successMessage = $"{platform} аккаунт отвязан."; + await LoadIdentities(); + } + else + { + var body = await response.Content.ReadAsStringAsync(); + errorMessage = $"Ошибка отвязки: {body}"; + } + } + catch (Exception ex) + { + errorMessage = $"Ошибка отвязки: {ex.Message}"; + } + finally + { + isUnlinking = false; + } + } +} diff --git a/src/GmRelay.Web/Program.cs b/src/GmRelay.Web/Program.cs index 6619de8..7cb7367 100644 --- a/src/GmRelay.Web/Program.cs +++ b/src/GmRelay.Web/Program.cs @@ -215,6 +215,25 @@ app.MapGet("/auth/discord/callback", async ( await sessionStore.UpsertDiscordUserAsync(user.Id, user.DisplayName, user.AvatarUrl); + // If already authenticated via another platform, link instead of replacing session + if (context.User.Identity?.IsAuthenticated == true + && context.User.TryGetPlatformIdentity(out var currentPlatform, out var currentExternalUserId) + && currentPlatform != "Discord") + { + try + { + await sessionStore.LinkIdentityAsync( + currentPlatform, currentExternalUserId, + "Discord", user.Id, + user.DisplayName); + return Results.Redirect("/profile?linked=discord"); + } + catch (InvalidOperationException ex) + { + return Results.Redirect($"/profile?link_error={Uri.EscapeDataString(ex.Message)}"); + } + } + var authProperties = new AuthenticationProperties { IsPersistent = true }; await context.SignInAsync( CookieAuthenticationDefaults.AuthenticationScheme, @@ -224,6 +243,38 @@ app.MapGet("/auth/discord/callback", async ( return Results.Redirect("/"); }); +// Identity linking API endpoints +app.MapGet("/api/me/identities", async ( + HttpContext context, + ISessionStore sessionStore) => +{ + if (!context.User.TryGetPlatformIdentity(out var platform, out var externalUserId)) + return Results.Unauthorized(); + + var identities = await sessionStore.GetLinkedIdentitiesAsync(platform, externalUserId); + return Results.Ok(identities); +}).RequireAuthorization(); + +app.MapDelete("/api/me/identities/{targetPlatform}/{targetExternalUserId}", async ( + HttpContext context, + ISessionStore sessionStore, + string targetPlatform, + string targetExternalUserId) => +{ + if (!context.User.TryGetPlatformIdentity(out var platform, out var externalUserId)) + return Results.Unauthorized(); + + try + { + await sessionStore.UnlinkIdentityAsync(platform, externalUserId, targetPlatform, targetExternalUserId); + return Results.NoContent(); + } + catch (InvalidOperationException ex) + { + return Results.BadRequest(new { error = ex.Message }); + } +}).RequireAuthorization(); + // Public calendar subscription endpoint (no auth required) app.MapGet("/calendar/{token}.ics", async ( string token, diff --git a/src/GmRelay.Web/Services/ISessionStore.cs b/src/GmRelay.Web/Services/ISessionStore.cs index c1745f7..e24ec0a 100644 --- a/src/GmRelay.Web/Services/ISessionStore.cs +++ b/src/GmRelay.Web/Services/ISessionStore.cs @@ -53,4 +53,19 @@ public interface ISessionStore 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); + + // --- Identity linking (issue #35) --- + Task ResolveEffectivePlayerIdAsync(string platform, string externalUserId); + Task> GetLinkedIdentitiesAsync(string platform, string externalUserId); + Task LinkIdentityAsync(string currentPlatform, string currentExternalUserId, string targetPlatform, string targetExternalUserId, string? currentName); + Task UnlinkIdentityAsync(string currentPlatform, string currentExternalUserId, string targetPlatform, string targetExternalUserId); + Task UpsertPlayerAsync(string platform, string externalUserId, string displayName, string? avatarUrl); } + +public sealed record LinkedIdentity( + string Platform, + string ExternalUserId, + string DisplayName, + string? ExternalUsername, + string? AvatarUrl, + DateTime LinkedAt); diff --git a/src/GmRelay.Web/Services/SessionService.cs b/src/GmRelay.Web/Services/SessionService.cs index 3318d3c..0ac82ad 100644 --- a/src/GmRelay.Web/Services/SessionService.cs +++ b/src/GmRelay.Web/Services/SessionService.cs @@ -104,6 +104,10 @@ public sealed class SessionService( public async Task> GetGroupsForUserAsync(string platform, string externalUserId) { await using var conn = await dataSource.OpenConnectionAsync(); + var effectiveId = await _ResolveEffectivePlayerIdAsync(conn, platform, externalUserId); + if (effectiveId is null) + return []; + return (await conn.QueryAsync( """ SELECT g.id, @@ -113,13 +117,11 @@ public sealed class SessionService( 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.platform = @Platform - AND p.external_user_id = @ExternalUserId + WHERE gm.player_id = @PlayerId ORDER BY g.name """, - new { Platform = platform, ExternalUserId = externalUserId })).ToList(); + new { PlayerId = effectiveId.Value })).ToList(); } public async Task GetGroupAsync(Guid groupId) @@ -142,36 +144,40 @@ public sealed class SessionService( public async Task IsGroupManagerAsync(Guid groupId, string platform, string externalUserId) { await using var conn = await dataSource.OpenConnectionAsync(); + var effectiveId = await _ResolveEffectivePlayerIdAsync(conn, platform, externalUserId); + if (effectiveId is null) + return false; + return await conn.ExecuteScalarAsync( """ SELECT EXISTS ( SELECT 1 - FROM group_managers gm - JOIN players p ON p.id = gm.player_id - WHERE gm.group_id = @GroupId - AND p.platform = @Platform - AND p.external_user_id = @ExternalUserId + FROM group_managers + WHERE group_id = @GroupId + AND player_id = @PlayerId ) """, - new { GroupId = groupId, Platform = platform, ExternalUserId = externalUserId }); + new { GroupId = groupId, PlayerId = effectiveId.Value }); } public async Task IsGroupOwnerAsync(Guid groupId, string platform, string externalUserId) { await using var conn = await dataSource.OpenConnectionAsync(); + var effectiveId = await _ResolveEffectivePlayerIdAsync(conn, platform, externalUserId); + if (effectiveId is null) + return false; + return await conn.ExecuteScalarAsync( """ SELECT EXISTS ( SELECT 1 - FROM group_managers gm - JOIN players p ON p.id = gm.player_id - WHERE gm.group_id = @GroupId - AND p.platform = @Platform - AND p.external_user_id = @ExternalUserId - AND gm.role = @OwnerRole + FROM group_managers + WHERE group_id = @GroupId + AND player_id = @PlayerId + AND role = @OwnerRole ) """, - new { GroupId = groupId, Platform = platform, ExternalUserId = externalUserId, OwnerRole = GroupManagerRoleExtensions.OwnerValue }); + new { GroupId = groupId, PlayerId = effectiveId.Value, OwnerRole = GroupManagerRoleExtensions.OwnerValue }); } public async Task> GetGroupManagersAsync(Guid groupId) @@ -255,22 +261,6 @@ 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, string ownerPlatform, string ownerExternalUserId, @@ -280,35 +270,16 @@ public sealed class SessionService( await using var conn = await dataSource.OpenConnectionAsync(); await using var transaction = await conn.BeginTransactionAsync(); - await conn.ExecuteAsync( - """ - 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, - external_username = EXCLUDED.external_username - """, - new - { - DisplayName = displayName, - ExternalUsername = externalUsername, - Platform = coGmPlatform, - ExternalUserId = coGmExternalUserId - }, - transaction); + var ownerPlayerId = await _ResolveEffectivePlayerIdAsync(conn, ownerPlatform, ownerExternalUserId); + if (ownerPlayerId is null) + throw new InvalidOperationException("Owner player not found."); + + var coGmPlayerId = await _UpsertPlayerAndGetIdAsync(conn, coGmPlatform, coGmExternalUserId, displayName, externalUsername, transaction); await conn.ExecuteAsync( """ INSERT INTO group_managers (group_id, player_id, role, added_by_player_id) - SELECT @GroupId, - co_gm.id, - @CoGmRole, - owner_player.id - FROM players co_gm - 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 + VALUES (@GroupId, @CoGmPlayerId, @CoGmRole, @OwnerPlayerId) ON CONFLICT (group_id, player_id) DO UPDATE SET role = CASE WHEN group_managers.role = @OwnerRole THEN group_managers.role @@ -319,10 +290,8 @@ public sealed class SessionService( new { GroupId = groupId, - OwnerPlatform = ownerPlatform, - OwnerExternalUserId = ownerExternalUserId, - CoGmPlatform = coGmPlatform, - CoGmExternalUserId = coGmExternalUserId, + OwnerPlayerId = ownerPlayerId.Value, + CoGmPlayerId = coGmPlayerId, OwnerRole = GroupManagerRoleExtensions.OwnerValue, CoGmRole = GroupManagerRoleExtensions.CoGmValue }, @@ -334,21 +303,21 @@ public sealed class SessionService( public async Task RemoveGroupCoGmAsync(Guid groupId, string coGmPlatform, string coGmExternalUserId) { await using var conn = await dataSource.OpenConnectionAsync(); + var coGmPlayerId = await _ResolveEffectivePlayerIdAsync(conn, coGmPlatform, coGmExternalUserId); + if (coGmPlayerId is null) + return; + await conn.ExecuteAsync( """ - DELETE FROM group_managers gm - USING players p - WHERE gm.player_id = p.id - AND gm.group_id = @GroupId - AND p.platform = @Platform - AND p.external_user_id = @ExternalUserId - AND gm.role = @CoGmRole + DELETE FROM group_managers + WHERE group_id = @GroupId + AND player_id = @PlayerId + AND role = @CoGmRole """, new { GroupId = groupId, - Platform = coGmPlatform, - ExternalUserId = coGmExternalUserId, + PlayerId = coGmPlayerId.Value, CoGmRole = GroupManagerRoleExtensions.CoGmValue }); } @@ -1371,4 +1340,255 @@ public sealed class SessionService( new { BatchId = batchId, GroupId = groupId }, transaction); } + + // --- Identity linking (issue #35) --- + + public async Task ResolveEffectivePlayerIdAsync(string platform, string externalUserId) + { + await using var conn = await dataSource.OpenConnectionAsync(); + return await _ResolveEffectivePlayerIdAsync(conn, platform, externalUserId); + } + + public async Task> GetLinkedIdentitiesAsync(string platform, string externalUserId) + { + await using var conn = await dataSource.OpenConnectionAsync(); + + var effectiveId = await _ResolveEffectivePlayerIdAsync(conn, platform, externalUserId); + if (effectiveId is null) + return []; + + return (await conn.QueryAsync( + """ + SELECT p.platform AS Platform, + p.external_user_id AS ExternalUserId, + p.display_name AS DisplayName, + p.external_username AS ExternalUsername, + p.avatar_url AS AvatarUrl, + COALESCE(pl.linked_at, p.created_at) AS LinkedAt + FROM players p + LEFT JOIN player_links pl ON pl.secondary_player_id = p.id + WHERE pl.primary_player_id = @EffectiveId + OR p.id = @EffectiveId + ORDER BY CASE WHEN p.id = @EffectiveId THEN 0 ELSE 1 END, + p.platform + """, + new { EffectiveId = effectiveId.Value })).ToList(); + } + + public async Task LinkIdentityAsync( + string currentPlatform, string currentExternalUserId, + string targetPlatform, string targetExternalUserId, + string? currentName) + { + if (currentPlatform == targetPlatform && currentExternalUserId == targetExternalUserId) + throw new InvalidOperationException("Cannot link an identity to itself."); + + await using var conn = await dataSource.OpenConnectionAsync(); + await using var transaction = await conn.BeginTransactionAsync(); + + // Resolve current player (must exist — they are logged in) + var currentPlayerId = await _ResolvePlayerIdAsync(conn, currentPlatform, currentExternalUserId); + if (currentPlayerId is null) + throw new InvalidOperationException("Current player not found."); + + // Upsert target player so it exists + var targetDisplayName = currentName ?? $"{targetPlatform} {targetExternalUserId}"; + var targetPlayerId = await _UpsertPlayerAndGetIdAsync(conn, targetPlatform, targetExternalUserId, targetDisplayName, null, transaction); + + // Check if target is already a primary of another link chain (conflict) + var targetIsPrimary = await conn.ExecuteScalarAsync( + """ + SELECT EXISTS ( + SELECT 1 FROM player_links WHERE primary_player_id = @TargetPlayerId + ) + """, + new { TargetPlayerId = targetPlayerId }, transaction); + + if (targetIsPrimary) + { + await _LogIdentityAuditAsync(conn, currentPlayerId.Value, "link_attempt_conflict", + targetPlatform, targetExternalUserId, currentPlayerId.Value, transaction); + await transaction.CommitAsync(); + throw new InvalidOperationException("Target identity is already the primary account of another linked set."); + } + + // Check if target is already linked to someone else as secondary + var existingLink = await conn.QuerySingleOrDefaultAsync( + """ + SELECT primary_player_id + FROM player_links + WHERE secondary_player_id = @TargetPlayerId + """, + new { TargetPlayerId = targetPlayerId }, transaction); + + if (existingLink is not null && existingLink.Value != currentPlayerId.Value) + { + await _LogIdentityAuditAsync(conn, currentPlayerId.Value, "link_attempt_conflict", + targetPlatform, targetExternalUserId, currentPlayerId.Value, transaction); + await transaction.CommitAsync(); + throw new InvalidOperationException("Target identity is already linked to another account."); + } + + // Check if current is already a secondary (then their primary becomes the effective primary) + var currentPrimaryId = await conn.QuerySingleOrDefaultAsync( + """ + SELECT primary_player_id + FROM player_links + WHERE secondary_player_id = @CurrentPlayerId + """, + new { CurrentPlayerId = currentPlayerId.Value }, transaction); + + var effectivePrimary = currentPrimaryId ?? currentPlayerId.Value; + + // Check if already linked + var alreadyLinked = await conn.ExecuteScalarAsync( + """ + SELECT EXISTS ( + SELECT 1 FROM player_links + WHERE primary_player_id = @EffectivePrimary AND secondary_player_id = @TargetPlayerId + ) + """, + new { EffectivePrimary = effectivePrimary, TargetPlayerId = targetPlayerId }, transaction); + + if (alreadyLinked) + { + await transaction.CommitAsync(); + return; // Already linked, idempotent + } + + await conn.ExecuteAsync( + """ + INSERT INTO player_links (primary_player_id, secondary_player_id, linked_by_player_id) + VALUES (@PrimaryPlayerId, @SecondaryPlayerId, @LinkedByPlayerId) + """, + new + { + PrimaryPlayerId = effectivePrimary, + SecondaryPlayerId = targetPlayerId, + LinkedByPlayerId = currentPlayerId.Value + }, + transaction); + + await _LogIdentityAuditAsync(conn, effectivePrimary, "link", + targetPlatform, targetExternalUserId, currentPlayerId.Value, transaction); + + await transaction.CommitAsync(); + } + + public async Task UnlinkIdentityAsync( + string currentPlatform, string currentExternalUserId, + string targetPlatform, string targetExternalUserId) + { + if (currentPlatform == targetPlatform && currentExternalUserId == targetExternalUserId) + throw new InvalidOperationException("Cannot unlink your own primary identity from itself."); + + await using var conn = await dataSource.OpenConnectionAsync(); + await using var transaction = await conn.BeginTransactionAsync(); + + var currentPlayerId = await _ResolvePlayerIdAsync(conn, currentPlatform, currentExternalUserId); + if (currentPlayerId is null) + throw new InvalidOperationException("Current player not found."); + + var targetPlayerId = await _ResolvePlayerIdAsync(conn, targetPlatform, targetExternalUserId); + if (targetPlayerId is null) + throw new InvalidOperationException("Target identity not found."); + + var effectivePrimary = await _ResolveEffectivePlayerIdAsync(conn, currentPlatform, currentExternalUserId); + if (effectivePrimary is null) + throw new InvalidOperationException("Effective primary not found."); + + // Only the primary account owner (or the linked identity itself) can unlink + var rows = await conn.ExecuteAsync( + """ + DELETE FROM player_links + WHERE primary_player_id = @EffectivePrimary + AND secondary_player_id = @TargetPlayerId + """, + new { EffectivePrimary = effectivePrimary.Value, TargetPlayerId = targetPlayerId.Value }, + transaction); + + if (rows == 0) + { + await transaction.RollbackAsync(); + throw new InvalidOperationException("Identity is not linked to your account."); + } + + await _LogIdentityAuditAsync(conn, effectivePrimary.Value, "unlink", + targetPlatform, targetExternalUserId, currentPlayerId.Value, transaction); + + await transaction.CommitAsync(); + } + + public async Task UpsertPlayerAsync(string platform, string externalUserId, string displayName, string? avatarUrl) + { + await using var conn = await dataSource.OpenConnectionAsync(); + await _UpsertPlayerAndGetIdAsync(conn, platform, externalUserId, displayName, avatarUrl, null); + } + + public async Task UpsertDiscordUserAsync(string discordId, string displayName, string? avatarUrl) + { + await _UpsertPlayerAndGetIdAsync(await dataSource.OpenConnectionAsync(), "Discord", discordId, displayName, avatarUrl, null); + } + + // --- Private helpers --- + + private static async Task _ResolvePlayerIdAsync(NpgsqlConnection conn, string platform, string externalUserId) + { + return await conn.QuerySingleOrDefaultAsync( + """ + SELECT id FROM players + WHERE platform = @Platform AND external_user_id = @ExternalUserId + """, + new { Platform = platform, ExternalUserId = externalUserId }); + } + + private static async Task _ResolveEffectivePlayerIdAsync(NpgsqlConnection conn, string platform, string externalUserId) + { + var playerId = await _ResolvePlayerIdAsync(conn, platform, externalUserId); + if (playerId is null) + return null; + + var primaryId = await conn.QuerySingleOrDefaultAsync( + """ + SELECT primary_player_id FROM player_links + WHERE secondary_player_id = @PlayerId + """, + new { PlayerId = playerId.Value }); + + return primaryId ?? playerId; + } + + private static async Task _UpsertPlayerAndGetIdAsync( + NpgsqlConnection conn, string platform, string externalUserId, + string displayName, string? avatarUrl, NpgsqlTransaction? transaction) + { + return await conn.QuerySingleAsync( + """ + INSERT INTO players (display_name, platform, external_user_id, external_username, avatar_url) + VALUES (@DisplayName, @Platform, @ExternalUserId, @DisplayName, @AvatarUrl) + 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, + avatar_url = COALESCE(EXCLUDED.avatar_url, players.avatar_url) + RETURNING id + """, + new { DisplayName = displayName, Platform = platform, ExternalUserId = externalUserId, AvatarUrl = avatarUrl }, + transaction); + } + + private static async Task _LogIdentityAuditAsync( + NpgsqlConnection conn, Guid playerId, string action, + string? targetPlatform, string? targetExternalUserId, + Guid? performedByPlayerId, NpgsqlTransaction? transaction) + { + await conn.ExecuteAsync( + """ + INSERT INTO identity_audit_log (player_id, action, target_platform, target_external_user_id, performed_by_player_id) + VALUES (@PlayerId, @Action, @TargetPlatform, @TargetExternalUserId, @PerformedByPlayerId) + """, + new { PlayerId = playerId, Action = action, TargetPlatform = targetPlatform, TargetExternalUserId = targetExternalUserId, PerformedByPlayerId = performedByPlayerId }, + transaction); + } } diff --git a/tests/GmRelay.Bot.Tests/Discord/DiscordProjectStructureTests.cs b/tests/GmRelay.Bot.Tests/Discord/DiscordProjectStructureTests.cs index 4dff594..887b8c9 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.8.1", compose); + Assert.Contains("gmrelay-discord-bot:3.0.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.8.1", File.ReadAllText(Path.Combine(repoRoot, "Directory.Build.props"))); - Assert.Contains("VERSION: 2.8.1", File.ReadAllText(Path.Combine(repoRoot, ".gitea", "workflows", "deploy.yml"))); - Assert.Contains("gmrelay-bot:2.8.1", File.ReadAllText(Path.Combine(repoRoot, "compose.yaml"))); - Assert.Contains("gmrelay-web:2.8.1", File.ReadAllText(Path.Combine(repoRoot, "compose.yaml"))); - Assert.Contains("gmrelay-discord-bot:2.8.1", File.ReadAllText(Path.Combine(repoRoot, "compose.yaml"))); + Assert.Contains("3.0.0", File.ReadAllText(Path.Combine(repoRoot, "Directory.Build.props"))); + Assert.Contains("VERSION: 3.0.0", File.ReadAllText(Path.Combine(repoRoot, ".gitea", "workflows", "deploy.yml"))); + Assert.Contains("gmrelay-bot:3.0.0", File.ReadAllText(Path.Combine(repoRoot, "compose.yaml"))); + Assert.Contains("gmrelay-web:3.0.0", File.ReadAllText(Path.Combine(repoRoot, "compose.yaml"))); + Assert.Contains("gmrelay-discord-bot:3.0.0", File.ReadAllText(Path.Combine(repoRoot, "compose.yaml"))); Assert.Contains( - "v2.8.1", + "v3.0.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 1b1b70d..a88ec44 100644 --- a/tests/GmRelay.Bot.Tests/Web/AuthorizedSessionServiceTests.cs +++ b/tests/GmRelay.Bot.Tests/Web/AuthorizedSessionServiceTests.cs @@ -1121,6 +1121,21 @@ public sealed class AuthorizedSessionServiceTests public Task UpsertDiscordUserAsync(string discordId, string displayName, string? avatarUrl) => Task.CompletedTask; + public Task ResolveEffectivePlayerIdAsync(string platform, string externalUserId) => + Task.FromResult(Guid.NewGuid()); + + public Task> GetLinkedIdentitiesAsync(string platform, string externalUserId) => + Task.FromResult(new List()); + + public Task LinkIdentityAsync(string currentPlatform, string currentExternalUserId, string targetPlatform, string targetExternalUserId, string? currentName) => + Task.CompletedTask; + + public Task UnlinkIdentityAsync(string currentPlatform, string currentExternalUserId, string targetPlatform, string targetExternalUserId) => + Task.CompletedTask; + + public Task UpsertPlayerAsync(string platform, string externalUserId, 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);