fix: close web access to foreign groups and sessions
Deploy Telegram Bot / build-and-push (push) Successful in 7m25s
Deploy Telegram Bot / deploy (push) Successful in 18s

This commit is contained in:
2026-04-23 20:09:22 +03:00
parent ecc2236937
commit 1c4cfb71c0
14 changed files with 352 additions and 49 deletions
+1 -1
View File
@@ -22,7 +22,7 @@
</ItemGroup>
<ItemGroup>
<PackageReference Include="Aspire.Npgsql" Version="13.2.1" />
<PackageReference Include="Aspire.Npgsql" Version="13.2.2" />
<PackageReference Include="Dapper" Version="2.1.72" />
<PackageReference Include="Dapper.AOT" Version="1.0.48" />
<PackageReference Include="dbup-postgresql" Version="7.0.1" />
@@ -0,0 +1,27 @@
@page "/access-denied"
<PageTitle>Доступ запрещен — GM-Relay</PageTitle>
<div class="page-container">
<div class="glass-card" style="max-width: 640px;">
<div class="empty-state">
<div class="empty-state-icon">⛔</div>
<div class="empty-state-title">Доступ запрещен</div>
<p class="empty-state-text">Эта группа или сессия недоступна для вашей учётной записи.</p>
<a href="/" class="btn-gm btn-gm-primary">← На главную</a>
</div>
</div>
</div>
@code {
[CascadingParameter]
private HttpContext? HttpContext { get; set; }
protected override void OnInitialized()
{
if (HttpContext is not null && !HttpContext.Response.HasStarted)
{
HttpContext.Response.StatusCode = StatusCodes.Status403Forbidden;
}
}
}
@@ -2,8 +2,10 @@
@using GmRelay.Web.Services
@using GmRelay.Shared.Domain
@using Microsoft.AspNetCore.Authorization
@using Microsoft.AspNetCore.Components.Authorization
@attribute [Authorize]
@inject SessionService SessionService
@inject AuthorizedSessionService SessionService
@inject AuthenticationStateProvider AuthStateProvider
@inject NavigationManager Navigation
<PageTitle>Редактирование сессии — GM-Relay</PageTitle>
@@ -73,20 +75,29 @@
[Parameter] public Guid SessionId { get; set; }
private WebSession? session;
private SessionEditModel model = new();
private bool isSubmitting = false;
private bool isSubmitting;
private string? errorMessage;
protected override async Task OnInitializedAsync()
{
session = await SessionService.GetSessionAsync(SessionId);
if (session != null)
var authState = await AuthStateProvider.GetAuthenticationStateAsync();
if (!authState.User.TryGetTelegramId(out var telegramId))
{
Navigation.NavigateTo("/access-denied");
return;
}
session = await SessionService.GetSessionForGmAsync(SessionId, telegramId);
if (session is null)
{
Navigation.NavigateTo("/access-denied");
return;
}
model.Title = session.Title;
// Convert UTC to Moscow for the picker
model.ScheduledAtLocal = session.ScheduledAt.ToMoscow();
model.JoinLink = session.JoinLink;
}
}
private async Task HandleSubmit()
{
@@ -95,13 +106,22 @@
try
{
// The value from <input type="datetime-local"> is considered as "unspecified" or local to browser.
// We treat it as Moscow time (UTC+3) and convert to UTC.
var authState = await AuthStateProvider.GetAuthenticationStateAsync();
if (!authState.User.TryGetTelegramId(out var telegramId))
{
Navigation.NavigateTo("/access-denied");
return;
}
var utcTime = new DateTimeOffset(model.ScheduledAtLocal, TimeSpan.FromHours(3)).ToUniversalTime().UtcDateTime;
await SessionService.UpdateSessionAsync(SessionId, model.Title, utcTime, model.JoinLink);
await SessionService.UpdateSessionForGmAsync(SessionId, telegramId, model.Title, utcTime, model.JoinLink);
Navigation.NavigateTo($"/group/{session!.GroupId}");
}
catch (SessionAccessDeniedException)
{
Navigation.NavigateTo("/access-denied");
}
catch (Exception ex)
{
errorMessage = "Не удалось сохранить изменения: " + ex.Message;
@@ -2,8 +2,11 @@
@using GmRelay.Web.Services
@using GmRelay.Shared.Domain
@using Microsoft.AspNetCore.Authorization
@using Microsoft.AspNetCore.Components.Authorization
@attribute [Authorize]
@inject SessionService SessionService
@inject AuthorizedSessionService SessionService
@inject AuthenticationStateProvider AuthStateProvider
@inject NavigationManager Navigation
<PageTitle>Сессии группы — GM-Relay</PageTitle>
@@ -112,7 +115,18 @@
protected override async Task OnInitializedAsync()
{
sessions = await SessionService.GetUpcomingSessionsAsync(GroupId);
var authState = await AuthStateProvider.GetAuthenticationStateAsync();
if (!authState.User.TryGetTelegramId(out var telegramId))
{
Navigation.NavigateTo("/access-denied");
return;
}
sessions = await SessionService.GetUpcomingSessionsForGmAsync(GroupId, telegramId);
if (sessions is null)
{
Navigation.NavigateTo("/access-denied");
}
}
private string GetStatusClass(string status) => status switch
+7 -4
View File
@@ -3,8 +3,9 @@
@using Microsoft.AspNetCore.Components.Authorization
@using GmRelay.Web.Services
@attribute [Authorize]
@inject SessionService SessionService
@inject AuthorizedSessionService SessionService
@inject AuthenticationStateProvider AuthStateProvider
@inject NavigationManager Navigation
<PageTitle>Панель управления — GM-Relay</PageTitle>
@@ -88,10 +89,12 @@
var user = authState.User;
userName = user.Identity?.Name ?? "Мастер Игры";
var telegramIdClaim = user.FindFirst("TelegramId")?.Value;
if (long.TryParse(telegramIdClaim, out var telegramId))
if (!user.TryGetTelegramId(out var telegramId))
{
Navigation.NavigateTo("/access-denied");
return;
}
groups = await SessionService.GetGroupsForGmAsync(telegramId);
}
}
}
+2 -1
View File
@@ -21,7 +21,8 @@ builder.AddNpgsqlDataSource("gmrelaydb");
// Add Services
builder.Services.AddSingleton<TelegramAuthService>();
builder.Services.AddSingleton<SessionService>();
builder.Services.AddSingleton<ISessionStore, SessionService>();
builder.Services.AddScoped<AuthorizedSessionService>();
// Add Bot Client
builder.Services.AddSingleton<ITelegramBotClient>(sp =>
@@ -0,0 +1,45 @@
namespace GmRelay.Web.Services;
public sealed class AuthorizedSessionService(ISessionStore sessionStore)
{
public Task<List<WebGameGroup>> GetGroupsForGmAsync(long gmId) =>
sessionStore.GetGroupsForGmAsync(gmId);
public async Task<List<WebSession>?> GetUpcomingSessionsForGmAsync(Guid groupId, long gmId)
{
if (!await GroupBelongsToGmAsync(groupId, gmId))
{
return null;
}
return await sessionStore.GetUpcomingSessionsAsync(groupId);
}
public async Task<WebSession?> GetSessionForGmAsync(Guid sessionId, long gmId)
{
var session = await sessionStore.GetSessionAsync(sessionId);
if (session is null)
{
return null;
}
return await GroupBelongsToGmAsync(session.GroupId, gmId) ? session : null;
}
public async Task UpdateSessionForGmAsync(Guid sessionId, long gmId, string title, DateTime scheduledAt, string joinLink)
{
var session = await GetSessionForGmAsync(sessionId, gmId);
if (session is null)
{
throw new SessionAccessDeniedException(sessionId, gmId);
}
await sessionStore.UpdateSessionAsync(sessionId, session.GroupId, title, scheduledAt, joinLink);
}
private async Task<bool> GroupBelongsToGmAsync(Guid groupId, long gmId)
{
var group = await sessionStore.GetGroupAsync(groupId);
return group?.GmTelegramId == gmId;
}
}
@@ -0,0 +1,9 @@
using System.Security.Claims;
namespace GmRelay.Web.Services;
public static class ClaimsPrincipalExtensions
{
public static bool TryGetTelegramId(this ClaimsPrincipal user, out long telegramId) =>
long.TryParse(user.FindFirst("TelegramId")?.Value, out telegramId);
}
+10
View File
@@ -0,0 +1,10 @@
namespace GmRelay.Web.Services;
public interface ISessionStore
{
Task<List<WebGameGroup>> GetGroupsForGmAsync(long gmId);
Task<WebGameGroup?> GetGroupAsync(Guid groupId);
Task<List<WebSession>> GetUpcomingSessionsAsync(Guid groupId);
Task<WebSession?> GetSessionAsync(Guid sessionId);
Task UpdateSessionAsync(Guid sessionId, Guid groupId, string title, DateTime scheduledAt, string joinLink);
}
@@ -0,0 +1,4 @@
namespace GmRelay.Web.Services;
public sealed class SessionAccessDeniedException(Guid sessionId, long gmId)
: InvalidOperationException($"Session '{sessionId}' is not accessible for GM '{gmId}'.");
+33 -16
View File
@@ -12,7 +12,7 @@ public sealed record WebSession(Guid Id, Guid GroupId, string Title, DateTime Sc
public sealed class SessionService(
NpgsqlDataSource dataSource,
ITelegramBotClient bot,
ILogger<SessionService> logger)
ILogger<SessionService> logger) : ISessionStore
{
public async Task<List<WebGameGroup>> GetGroupsForGmAsync(long gmId)
{
@@ -22,6 +22,14 @@ public sealed class SessionService(
new { GmId = gmId })).ToList();
}
public async Task<WebGameGroup?> GetGroupAsync(Guid groupId)
{
await using var conn = await dataSource.OpenConnectionAsync();
return await conn.QuerySingleOrDefaultAsync<WebGameGroup>(
"SELECT id, telegram_chat_id AS TelegramChatId, name, gm_telegram_id AS GmTelegramId FROM game_groups WHERE id = @GroupId",
new { GroupId = groupId });
}
public async Task<List<WebSession>> GetUpcomingSessionsAsync(Guid groupId)
{
await using var conn = await dataSource.OpenConnectionAsync();
@@ -49,29 +57,41 @@ public sealed class SessionService(
new { SessionId = sessionId });
}
public async Task UpdateSessionAsync(Guid sessionId, string title, DateTime scheduledAt, string joinLink)
public async Task UpdateSessionAsync(Guid sessionId, Guid groupId, string title, DateTime scheduledAt, string joinLink)
{
await using var conn = await dataSource.OpenConnectionAsync();
await using var transaction = await conn.BeginTransactionAsync();
// 1. Fetch current session with all required columns for WebSession record
var oldSession = await conn.QuerySingleAsync<WebSession>(
var oldSession = await conn.QuerySingleOrDefaultAsync<WebSession>(
@"SELECT s.id, s.group_id AS GroupId, s.title, s.scheduled_at AS ScheduledAt, s.status, s.join_link AS JoinLink,
s.batch_id AS BatchId, s.batch_message_id AS BatchMessageId,
g.telegram_chat_id AS TelegramChatId
FROM sessions s
JOIN game_groups g ON g.id = s.group_id
WHERE s.id = @Id",
new { Id = sessionId }, transaction);
// 2. Update Session
await conn.ExecuteAsync(
@"UPDATE sessions SET title = @Title, scheduled_at = @ScheduledAt, join_link = @JoinLink, updated_at = now()
WHERE id = @Id",
new { Id = sessionId, Title = title, ScheduledAt = scheduledAt, JoinLink = joinLink },
WHERE s.id = @Id AND s.group_id = @GroupId",
new { Id = sessionId, GroupId = groupId },
transaction);
// 3. Update all sessions in the same batch with new title (optional, usually batch shares title)
if (oldSession is null)
{
throw new SessionAccessDeniedException(sessionId, 0);
}
var updatedRows = await conn.ExecuteAsync(
@"UPDATE sessions
SET title = @Title,
scheduled_at = @ScheduledAt,
join_link = @JoinLink,
updated_at = now()
WHERE id = @Id AND group_id = @GroupId",
new { Id = sessionId, GroupId = groupId, Title = title, ScheduledAt = scheduledAt, JoinLink = joinLink },
transaction);
if (updatedRows == 0)
{
throw new SessionAccessDeniedException(sessionId, 0);
}
await conn.ExecuteAsync(
"UPDATE sessions SET title = @Title WHERE batch_id = @BatchId",
new { Title = title, BatchId = oldSession.BatchId },
@@ -79,7 +99,6 @@ public sealed class SessionService(
await transaction.CommitAsync();
// 4. Send Telegram Notification
var timeChanged = oldSession.ScheduledAt != scheduledAt;
var notification = $"🔄 <b>Мастер обновил игру!</b>\n\n" +
$"📌 <b>{System.Net.WebUtility.HtmlEncode(title)}</b>\n" +
@@ -87,7 +106,6 @@ public sealed class SessionService(
await bot.SendMessage(oldSession.TelegramChatId, notification, parseMode: Telegram.Bot.Types.Enums.ParseMode.Html);
// 5. Update Original Batch Message
if (oldSession.BatchMessageId.HasValue)
{
await TryUpdateBatchMessageAsync(oldSession.BatchId, oldSession.TelegramChatId, oldSession.BatchMessageId.Value, title);
@@ -124,7 +142,6 @@ public sealed class SessionService(
}
catch (Exception ex)
{
// Log but don't throw — message may be too old or have same content
logger.LogWarning(ex, "Failed to update batch message {MessageId} in chat {ChatId}", messageId, chatId);
}
}
@@ -20,6 +20,7 @@
<ItemGroup>
<ProjectReference Include="..\..\src\GmRelay.Bot\GmRelay.Bot.csproj" />
<ProjectReference Include="..\..\src\GmRelay.Web\GmRelay.Web.csproj" />
</ItemGroup>
</Project>
-10
View File
@@ -1,10 +0,0 @@
namespace GmRelay.Bot.Tests;
public class UnitTest1
{
[Fact]
public void Test1()
{
}
}
@@ -0,0 +1,162 @@
using GmRelay.Web.Services;
namespace GmRelay.Bot.Tests.Web;
public sealed class AuthorizedSessionServiceTests
{
[Fact]
public async Task GetUpcomingSessionsForGmAsync_ReturnsSessions_WhenGroupBelongsToGm()
{
var gmId = 1001L;
var groupId = Guid.NewGuid();
var store = new FakeSessionStore(
groups:
[
new(groupId, 42, "Alpha", gmId)
],
sessions:
[
new(Guid.NewGuid(), groupId, "Session A", DateTime.UtcNow, "Planned", "https://example.test/a", Guid.NewGuid(), 10, 42)
]);
var service = new AuthorizedSessionService(store);
var sessions = await service.GetUpcomingSessionsForGmAsync(groupId, gmId);
Assert.NotNull(sessions);
Assert.Single(sessions);
Assert.Equal("Session A", sessions[0].Title);
}
[Fact]
public async Task GetUpcomingSessionsForGmAsync_ReturnsNull_WhenGroupBelongsToAnotherGm()
{
var groupId = Guid.NewGuid();
var store = new FakeSessionStore(
groups:
[
new(groupId, 42, "Alpha", 2002L)
]);
var service = new AuthorizedSessionService(store);
var sessions = await service.GetUpcomingSessionsForGmAsync(groupId, 1001L);
Assert.Null(sessions);
}
[Fact]
public async Task GetSessionForGmAsync_ReturnsSession_WhenSessionBelongsToOwnedGroup()
{
var gmId = 1001L;
var groupId = Guid.NewGuid();
var sessionId = Guid.NewGuid();
var store = new FakeSessionStore(
groups:
[
new(groupId, 42, "Alpha", gmId)
],
sessions:
[
new(sessionId, groupId, "Session A", DateTime.UtcNow, "Planned", "https://example.test/a", Guid.NewGuid(), 10, 42)
]);
var service = new AuthorizedSessionService(store);
var session = await service.GetSessionForGmAsync(sessionId, gmId);
Assert.NotNull(session);
Assert.Equal(sessionId, session.Id);
}
[Fact]
public async Task UpdateSessionForGmAsync_Throws_WhenSessionBelongsToAnotherGm()
{
var groupId = Guid.NewGuid();
var sessionId = Guid.NewGuid();
var store = new FakeSessionStore(
groups:
[
new(groupId, 42, "Alpha", 2002L)
],
sessions:
[
new(sessionId, groupId, "Session A", DateTime.UtcNow, "Planned", "https://example.test/a", Guid.NewGuid(), 10, 42)
]);
var service = new AuthorizedSessionService(store);
var action = () => service.UpdateSessionForGmAsync(sessionId, 1001L, "Updated", DateTime.UtcNow.AddDays(1), "https://example.test/b");
await Assert.ThrowsAsync<SessionAccessDeniedException>(action);
Assert.False(store.UpdateCalled);
}
[Fact]
public async Task UpdateSessionForGmAsync_UpdatesOwnedSession()
{
var gmId = 1001L;
var groupId = Guid.NewGuid();
var sessionId = Guid.NewGuid();
var scheduledAt = DateTime.UtcNow.AddDays(1);
var store = new FakeSessionStore(
groups:
[
new(groupId, 42, "Alpha", gmId)
],
sessions:
[
new(sessionId, groupId, "Session A", DateTime.UtcNow, "Planned", "https://example.test/a", Guid.NewGuid(), 10, 42)
]);
var service = new AuthorizedSessionService(store);
await service.UpdateSessionForGmAsync(sessionId, gmId, "Updated", scheduledAt, "https://example.test/b");
Assert.True(store.UpdateCalled);
Assert.Equal(groupId, store.LastUpdatedGroupId);
Assert.Equal(sessionId, store.LastUpdatedSessionId);
Assert.Equal("Updated", store.LastUpdatedTitle);
Assert.Equal(scheduledAt, store.LastUpdatedScheduledAt);
Assert.Equal("https://example.test/b", store.LastUpdatedJoinLink);
}
private sealed class FakeSessionStore(
IEnumerable<WebGameGroup>? groups = null,
IEnumerable<WebSession>? sessions = null) : ISessionStore
{
private readonly Dictionary<Guid, WebGameGroup> groupsById = groups?.ToDictionary(group => group.Id) ?? [];
private readonly Dictionary<Guid, WebSession> sessionsById = sessions?.ToDictionary(session => session.Id) ?? [];
public bool UpdateCalled { get; private set; }
public Guid? LastUpdatedSessionId { get; private set; }
public Guid? LastUpdatedGroupId { get; private set; }
public string? LastUpdatedTitle { get; private set; }
public DateTime? LastUpdatedScheduledAt { get; private set; }
public string? LastUpdatedJoinLink { get; private set; }
public Task<List<WebGameGroup>> GetGroupsForGmAsync(long gmId) =>
Task.FromResult(groupsById.Values.Where(group => group.GmTelegramId == gmId).ToList());
public Task<WebGameGroup?> GetGroupAsync(Guid groupId)
{
groupsById.TryGetValue(groupId, out var group);
return Task.FromResult(group);
}
public Task<List<WebSession>> GetUpcomingSessionsAsync(Guid groupId) =>
Task.FromResult(sessionsById.Values.Where(session => session.GroupId == groupId).ToList());
public Task<WebSession?> GetSessionAsync(Guid sessionId)
{
sessionsById.TryGetValue(sessionId, out var session);
return Task.FromResult(session);
}
public Task UpdateSessionAsync(Guid sessionId, Guid groupId, string title, DateTime scheduledAt, string joinLink)
{
UpdateCalled = true;
LastUpdatedSessionId = sessionId;
LastUpdatedGroupId = groupId;
LastUpdatedTitle = title;
LastUpdatedScheduledAt = scheduledAt;
LastUpdatedJoinLink = joinLink;
return Task.CompletedTask;
}
}
}