diff --git a/ikar_server/ADMIN.md b/ikar_server/ADMIN.md new file mode 100644 index 0000000..98d147f --- /dev/null +++ b/ikar_server/ADMIN.md @@ -0,0 +1,66 @@ +# Администрирование ИКАР + +Панель доступна по адресу `https://ikar.kusoft.xyz/admin/`. Вход использует +`Admin__Username` и `Admin__Password` из закрытой серверной конфигурации. +Учётная запись обычного пользователя мессенджера доступа к панели не даёт. + +Панель включает: + +- Обзор: база, пользователи, чаты, сообщения, вложения и активные сессии. +- Пользователи: поиск, фильтры, страницы, профиль, устройства уведомлений, + отзыв одной или всех сессий, блокировка, разблокировка и удаление. +- Чаты и каналы: настройки приватности, публичное имя, срок жизни сообщений, + темы, роли и разрешения, передача владения, приглашения и заявки на вход. +- Сообщения: поиск в чате, скрытие и восстановление. Вложения сохраняются + при скрытии сообщения для возможности восстановления; истёкшие сообщения + восстанавливать нельзя. Зашифрованное содержимое отображается в том виде, + в котором оно хранится на сервере: расшифровка на сервере отсутствует. +- Жалобы: очередь, исходные данные, просмотр объекта, подтверждение или + отклонение, блокировка автора или скрытие сообщения, повторное открытие. + Старые жалобы автоматически переносятся из журнала модерации. +- Боты: поиск, включение и отключение, имя, описание, HTTPS-адрес + мини-приложения и замена токена. Новый токен показывается один раз. + Создание ботов сохраняет существующий процесс через `@botfatherbot`. +- Медиа: поиск, наличие файла, скачивание и удаление. +- Обновления: текущая версия Android, размер APK, SHA-256, сохранённые пакеты, + совместимость клиентов и переход к каталогу Аргус. Публикация релизов + выполняется существующими проверенными инструментами `tool/publish_*`. +- Обслуживание: состояние диска, память, резервные копии, отложенные + сообщения, очередь push, повтор доставки, удаление задач и очистка TTL + партиями до 500 сообщений. +- Журнал администратора: действия, объект, результат, комментарий, IP, + поиск, страницы и экспорт текущей страницы в CSV. +- Доступ: действующие административные сессии и их отзыв. + +Сессия администратора действует 12 часов. Выход, отзыв сессии и смена +серверных реквизитов делают её cookie недействительным. Запросы изменения +требуют CSRF-токена. В production cookie передаётся только по HTTPS. +Административные страницы и ответы не кешируются; в журнал не записываются +пароли, токены ботов и содержимое запросов. Вход ограничен десятью попытками +за пять минут с одного IP. Отзыв сессии пользователя закрывает её SignalR +соединения и отключает токены API. + +Ключи cookie хранятся в `Data/AdminKeys`, на постоянном томе данных. Для +работы за reverse proxy параметр `ReverseProxy__KnownProxies` задаёт список +точных доверенных IP через запятую. На рабочем сервере инструмент +`tool/deploy_server_fixes.py` определяет адрес существующего Docker gateway; +порт API остаётся привязан к `127.0.0.1`. + +## Проверка + +```powershell +dotnet test ikar_server/src/Ikar.Server.Tests/Ikar.Server.Tests.csproj +./tool/start_admin_test_server.ps1 +python tool/seed_admin_ui_test.py +node tool/verify_admin_ui.cjs +``` + +Для браузерной проверки нужны Playwright и Chromium/Edge. При необходимости +`PLAYWRIGHT_MODULE` указывает путь к установленному модулю Playwright, а +`ADMIN_BROWSER` — к браузеру. Тестовый сервер слушает только loopback, +использует отдельную временную базу и тестовые реквизиты. После проверки +остановите PID из `artifacts/admin-local-server.json`. + +После установки `tool/verify_admin_console.py` запускается на Pi вместе с +`tool/admin_http.py`: реквизиты читаются внутри сервера, проверяется доступ, +защита изменений, резервные копии и целостность базы. В отчёт секреты не входят. diff --git a/ikar_server/src/Ikar.Server.Tests/AdminConsoleIntegrationTests.cs b/ikar_server/src/Ikar.Server.Tests/AdminConsoleIntegrationTests.cs new file mode 100644 index 0000000..3100e58 --- /dev/null +++ b/ikar_server/src/Ikar.Server.Tests/AdminConsoleIntegrationTests.cs @@ -0,0 +1,270 @@ +using System.Net; +using System.Net.Http.Json; +using System.Text.Json; +using Ikar.Server.Data; +using Ikar.Server.Data.Entities; +using Ikar.Server.Infrastructure.Admin; +using Ikar.Server.Infrastructure.Auth; +using Ikar.Shared; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.AspNetCore.SignalR.Client; +using Microsoft.AspNetCore.Http.Connections; +using Ikar.Server.Infrastructure.Storage; + +namespace Ikar.Server.Tests; + +public sealed class AdminConsoleIntegrationTests +{ + [Fact] + public async Task ChatAttentionFilterSearchesRecordsOutsideTheFirstPage() + { + await using var host = new AdminTestHost(); using var client = host.NewClient(); await host.LoginAsync(client); + Guid target; + using (var scope = host.Services.CreateScope()) { + var db = scope.ServiceProvider.GetRequiredService(); var owner = User("filter-owner"); db.Users.Add(owner); + var chats = Enumerable.Range(0, 35).Select(i => new Chat { Title = "Фильтр " + i, CreatedBy = owner, Type = ChatType.Group }).ToList(); + chats[0].CreatedAt = DateTimeOffset.UtcNow.AddDays(-10); chats[0].HasProtectedContent = true; target = chats[0].Id; + db.Chats.AddRange(chats); await db.SaveChangesAsync(); + } + var page = await JsonAsync(client, "/admin/api/chats/list?attention=protected&size=25"); + Assert.Equal(1, page.GetProperty("total").GetInt32()); Assert.Single(page.GetProperty("items").EnumerateArray()); + Assert.Equal(target, page.GetProperty("items")[0].GetProperty("id").GetGuid()); + } + + [Fact] + public async Task ProductionProxyUsesHttpsCookiesAndPreservesClientIp() + { + await using var host = new AdminTestHost(production: true); + using var client = host.NewClient(); + client.DefaultRequestHeaders.Add("X-Forwarded-Proto", "https"); + client.DefaultRequestHeaders.Add("X-Forwarded-For", "203.0.113.55"); + var response = await client.GetAsync("http://localhost/admin/api/session"); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Contains("secure", string.Join(";", response.Headers.GetValues("Set-Cookie"))); + await host.LoginAsync(client); + var sessions = await client.GetFromJsonAsync("/admin/api/admin-sessions"); + Assert.Equal("203.0.113.55", sessions[0].GetProperty("remoteIp").GetString()); + } + + [Fact] + public async Task ConsoleRequiresCookieAndCsrf_AndLogoutInvalidatesReplayedCookie() + { + await using var host = new AdminTestHost(); + using var client = host.NewClient(); + Assert.Equal(HttpStatusCode.Unauthorized, (await client.GetAsync("/admin/api/users/list")).StatusCode); + Assert.Equal(HttpStatusCode.BadRequest, (await client.PostAsJsonAsync("/admin/api/session/login", new { username = "admin-test", password = "test-only-password" })).StatusCode); + await host.LoginAsync(client); + var session = await JsonAsync(client, "/admin/api/session"); + Assert.True(session.GetProperty("authenticated").GetBoolean()); + // Obtain a cookie explicitly so the replay assertion does not depend on the cookie handler's internals. + using var replay = host.NewClient(false); + var csrfResponse = await replay.GetAsync("/admin/api/session"); + var csrfCookie = string.Join("; ", csrfResponse.Headers.GetValues("Set-Cookie").Select(x => x.Split(';')[0])); + var csrf = (await csrfResponse.Content.ReadFromJsonAsync()).GetProperty("csrfToken").GetString(); + replay.DefaultRequestHeaders.Add("Cookie", csrfCookie); + replay.DefaultRequestHeaders.Add("X-Ikar-CSRF", csrf); + var login = await replay.PostAsJsonAsync("/admin/api/session/login", new { username = "admin-test", password = "test-only-password" }); + Assert.Equal(HttpStatusCode.NoContent, login.StatusCode); + var authCookie = string.Join("; ", login.Headers.GetValues("Set-Cookie").Select(x => x.Split(';')[0])); + replay.DefaultRequestHeaders.Remove("Cookie"); replay.DefaultRequestHeaders.Add("Cookie", csrfCookie + "; " + authCookie); + var authenticated = await JsonAsync(replay, "/admin/api/session"); + replay.DefaultRequestHeaders.Remove("X-Ikar-CSRF"); replay.DefaultRequestHeaders.Add("X-Ikar-CSRF", authenticated.GetProperty("csrfToken").GetString()); + Assert.Equal(HttpStatusCode.NoContent, (await replay.PostAsync("/admin/api/session/logout", null)).StatusCode); + Assert.Equal(HttpStatusCode.Unauthorized, (await replay.GetAsync("/admin/api/users/list")).StatusCode); + var secured = await client.GetAsync("/admin/api/users/list"); + Assert.Contains("no-store", secured.Headers.CacheControl!.ToString()); + Assert.Contains("frame-ancestors 'none'", secured.Headers.GetValues("Content-Security-Policy").Single()); + client.DefaultRequestHeaders.Remove("X-Ikar-CSRF"); + Assert.Equal(HttpStatusCode.BadRequest, (await client.PostAsync("/admin/api/maintenance/cleanup-expired-messages", null)).StatusCode); + } + + [Fact] + public async Task UserManagementPagesBeyondLegacyLimit_RevokesSessionsAndAuditsActionsWithoutSecrets() + { + await using var host = new AdminTestHost(); using var client = host.NewClient(); await host.LoginAsync(client); + Guid userId, sessionId; string accessToken; + using (var scope = host.Services.CreateScope()) { + var db = scope.ServiceProvider.GetRequiredService(); + var users = Enumerable.Range(0, 261).Select(i => User("page-user-" + i.ToString("D3"))).ToList(); + db.Users.AddRange(users); await db.SaveChangesAsync(); userId = users[0].Id; + var auth = await scope.ServiceProvider.GetRequiredService().CreateSessionAsync(users[0]); + accessToken = auth.AccessToken; sessionId = await db.UserSessions.Where(x => x.UserId == userId).Select(x => x.Id).SingleAsync(); + } + var last = await JsonAsync(client, "/admin/api/users/list?q=page-user-&page=11&size=25"); + Assert.Equal(261, last.GetProperty("total").GetInt32()); Assert.Equal(11, last.GetProperty("items").GetArrayLength()); + using var native = host.NewClient(false); native.DefaultRequestHeaders.Authorization = new("Bearer", accessToken); + Assert.Equal(HttpStatusCode.Unauthorized, (await native.GetAsync("/admin/api/users/list")).StatusCode); + var details = await JsonAsync(client, $"/admin/api/users/{userId}/details"); + Assert.DoesNotContain("tokenHash", details.ToString()); Assert.DoesNotContain("passwordHash", details.ToString()); + Assert.Equal(HttpStatusCode.NoContent, (await client.PutAsJsonAsync($"/admin/api/users/{userId}/profile", new { displayName = "Новое имя", about = "Описание" })).StatusCode); + var closed = false; host.Services.GetRequiredService().Add(sessionId, "test-connection", () => closed = true); + client.DefaultRequestHeaders.Add("X-Admin-Reason", Uri.EscapeDataString("Проверка отзыва")); + Assert.Equal(HttpStatusCode.NoContent, (await client.DeleteAsync($"/admin/api/users/{userId}/sessions/{sessionId}")).StatusCode); + Assert.True(closed); + Assert.Equal(HttpStatusCode.Unauthorized, (await native.GetAsync("/api/users/me")).StatusCode); + using var check = host.Services.CreateScope(); var verify = check.ServiceProvider.GetRequiredService(); + Assert.False(await verify.UserSessions.AnyAsync(x => x.Id == sessionId)); + Assert.Equal("Новое имя", (await verify.Users.FindAsync(userId))!.DisplayName); + Assert.True(await verify.AdminAuditEntries.AnyAsync(x => x.Reason == "Проверка отзыва" && x.StatusCode == 204)); + Assert.DoesNotContain("test-only-password", (await JsonAsync(client, "/admin/api/audit")).ToString()); + } + + [Fact] + public async Task ReportsAndMessagesResolveRestoreAndRejectExpiredRestoration() + { + await using var host = new AdminTestHost(); using var client = host.NewClient(); await host.LoginAsync(client); + Guid messageId, expiredId, chatId, userId; long reportId; + using (var scope = host.Services.CreateScope()) { + var db = scope.ServiceProvider.GetRequiredService(); var user = User("moderation-user"); + var chat = new Chat { CreatedBy = user, Type = ChatType.Group, Title = "Модерация" }; + chat.Members.Add(new ChatMember { User = user, Role = ChatMemberRole.Owner, IsOwner = true }); + var message = new Message { Chat = chat, Sender = user, Text = "Сохранённый текст" }; + var expired = new Message { Chat = chat, Sender = user, Text = "Истёк", DeletedAt = DateTimeOffset.UtcNow, ExpiresAt = DateTimeOffset.UtcNow.AddDays(-1) }; + var report = new ModerationCase { SourceLogId = 9001, ReporterId = user.Id, ReporterName = "Заявитель", TargetKind = "message", TargetId = message.Id, ChatId = chat.Id }; + db.AddRange(user, chat, message, expired, report); await db.SaveChangesAsync(); + messageId = message.Id; expiredId = expired.Id; chatId = chat.Id; userId = user.Id; reportId = report.Id; + } + Assert.Equal(HttpStatusCode.OK, (await client.GetAsync($"/admin/api/chats/{chatId}/messages")).StatusCode); + Assert.Equal(HttpStatusCode.OK, (await client.PostAsJsonAsync($"/admin/api/reports/{reportId}/resolve", new { status = "resolved", resolution = "Спам подтверждён", action = "delete-message" })).StatusCode); + Assert.Equal(HttpStatusCode.BadRequest, (await client.PostAsJsonAsync($"/admin/api/reports/{reportId}/resolve", new { status = "resolved", resolution = "Повторное действие" })).StatusCode); + Assert.Equal(HttpStatusCode.NoContent, (await client.PostAsync($"/admin/api/messages/{messageId}/restore", null)).StatusCode); + Assert.Equal(HttpStatusCode.BadRequest, (await client.PostAsync($"/admin/api/messages/{expiredId}/restore", null)).StatusCode); + using var check = host.Services.CreateScope(); var dbCheck = check.ServiceProvider.GetRequiredService(); + var messageCheck = (await dbCheck.Messages.FindAsync(messageId))!; Assert.Null(messageCheck.DeletedAt); Assert.Equal("Сохранённый текст", messageCheck.Text); + Assert.Equal("resolved", (await dbCheck.ModerationCases.FindAsync(reportId))!.Status); + Assert.False((await dbCheck.Users.FindAsync(userId))!.IsBlocked); + } + + [Fact] + public async Task BotsHideSecretsRotateTokensAndValidateWebApps() + { + await using var host = new AdminTestHost(); using var client = host.NewClient(); await host.LoginAsync(client); + Guid id; + using (var scope = host.Services.CreateScope()) { + var db = scope.ServiceProvider.GetRequiredService(); var owner = User("bot-owner"); var botUser = User("managed-bot"); botUser.IsBot = true; + var bot = new Bot { User = botUser, OwnerUser = owner, TokenHash = "old-secret-hash", IsEnabled = true }; + db.AddRange(owner, botUser, bot); await db.SaveChangesAsync(); id = bot.Id; + } + Assert.DoesNotContain("old-secret-hash", (await JsonAsync(client, "/admin/api/bots")).ToString()); + Assert.Equal(HttpStatusCode.BadRequest, (await client.PutAsJsonAsync($"/admin/api/bots/{id}", new { name = "Бот", enabled = false, webAppUrl = "http://example.org" })).StatusCode); + Assert.Equal(HttpStatusCode.NoContent, (await client.PutAsJsonAsync($"/admin/api/bots/{id}", new { name = "Бот", enabled = false, webAppUrl = "https://example.org" })).StatusCode); + var rotated = await (await client.PostAsync($"/admin/api/bots/{id}/rotate-token", null)).Content.ReadFromJsonAsync(); + var token = rotated.GetProperty("token").GetString()!; + using var check = host.Services.CreateScope(); var dbCheck = check.ServiceProvider.GetRequiredService(); + var botCheck = (await dbCheck.Bots.FindAsync(id))!; Assert.False(botCheck.IsEnabled); + Assert.Equal(check.ServiceProvider.GetRequiredService().HashToken(token), botCheck.TokenHash); + Assert.DoesNotContain(token, (await JsonAsync(client, "/admin/api/audit")).ToString()); + foreach (var endpoint in new[] { "operations", "releases", "admin-sessions", "push-queue", "status" }) Assert.Equal(HttpStatusCode.OK, (await client.GetAsync("/admin/api/" + endpoint)).StatusCode); + Assert.True((await JsonAsync(client, "/admin/api/admin-sessions"))[0].GetProperty("current").GetBoolean()); + } + + [Fact] + public async Task RevocationClosesConnectedSignalRSessionAndRemovesPresence() + { + await using var host = new AdminTestHost(); using var client = host.NewClient(); await host.LoginAsync(client); + Guid userId, sessionId; string token; + using (var scope = host.Services.CreateScope()) { + var db = scope.ServiceProvider.GetRequiredService(); var user = User("signalr-admin-test"); + db.Users.Add(user); await db.SaveChangesAsync(); userId = user.Id; + token = (await scope.ServiceProvider.GetRequiredService().CreateSessionAsync(user)).AccessToken; + sessionId = await db.UserSessions.Where(x => x.UserId == userId).Select(x => x.Id).SingleAsync(); + } + await using var connection = new HubConnectionBuilder().WithUrl("https://localhost/hubs/messenger", options => { + options.Transports = HttpTransportType.LongPolling; + options.HttpMessageHandlerFactory = _ => host.Server.CreateHandler(); + options.AccessTokenProvider = () => Task.FromResult(token); + }).Build(); + var closed = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + connection.Closed += _ => { closed.TrySetResult(); return Task.CompletedTask; }; + await connection.StartAsync(); + Assert.NotEmpty(host.Services.GetRequiredService().ConnectionIds(sessionId)); + Assert.Equal(HttpStatusCode.NoContent, (await client.DeleteAsync($"/admin/api/users/{userId}/sessions/{sessionId}")).StatusCode); + await closed.Task.WaitAsync(TimeSpan.FromSeconds(10)); + Assert.Empty(host.Services.GetRequiredService().ConnectionIds(sessionId)); + Assert.Equal(HubConnectionState.Disconnected, connection.State); + } + + [Fact] + public async Task ChatOwnershipTransferPreservesAnOwnerAndRejectsInvalidRoles() + { + await using var host = new AdminTestHost(); using var client = host.NewClient(); await host.LoginAsync(client); + Guid chatId, firstId, secondId; + using (var scope = host.Services.CreateScope()) { + var db = scope.ServiceProvider.GetRequiredService(); var first = User("first-owner"); var second = User("next-owner"); + var chat = new Chat { CreatedBy = first, Type = ChatType.Group, Title = "Владельцы" }; + chat.Members.Add(new ChatMember { User = first, IsOwner = true, Role = ChatMemberRole.Owner }); + chat.Members.Add(new ChatMember { User = second, Role = ChatMemberRole.Member }); + db.AddRange(first, second, chat); await db.SaveChangesAsync(); chatId = chat.Id; firstId = first.Id; secondId = second.Id; + } + Assert.Equal(HttpStatusCode.BadRequest, (await client.PutAsJsonAsync($"/admin/api/chats/{chatId}/members/{firstId}/permissions", new { role = 0 })).StatusCode); + Assert.Equal(HttpStatusCode.BadRequest, (await client.PutAsJsonAsync($"/admin/api/chats/{chatId}/members/{secondId}/permissions", new { role = 999 })).StatusCode); + Assert.Equal(HttpStatusCode.OK, (await client.PutAsJsonAsync($"/admin/api/chats/{chatId}/members/{secondId}/permissions", new { role = 2 })).StatusCode); + using var check = host.Services.CreateScope(); var verify = check.ServiceProvider.GetRequiredService(); + Assert.Equal(secondId, (await verify.Chats.FindAsync(chatId))!.CreatedById); + Assert.Single(await verify.ChatMembers.Where(x => x.ChatId == chatId && x.IsOwner).ToListAsync()); + } + + [Fact] + public async Task ExistingComplaintBackfillIsIdempotentAndPreservesResolution() + { + await using var host = new AdminTestHost(); using var client = host.NewClient(); + using var scope = host.Services.CreateScope(); var db = scope.ServiceProvider.GetRequiredService(); + var reporter = User("old-reporter"); db.Users.Add(reporter); + var log = new ModerationLogEntry { ActorUser = reporter, Action = "complaint.submitted", TargetType = ModerationLogTargetType.User, Reason = "{\"reasonId\":1,\"typeId\":6,\"ids\":[1]}" }; + db.ModerationLogEntries.Add(log); await db.SaveChangesAsync(); + var bootstrap = scope.ServiceProvider.GetRequiredService(); + await bootstrap.EnsureLatestAsync(); + var report = await db.ModerationCases.SingleAsync(x => x.SourceLogId == log.Id); + Assert.Equal("old-reporter", report.ReporterName); Assert.Equal(log.Reason, report.PayloadJson); + report.Status = "dismissed"; report.Resolution = "Проверено"; await db.SaveChangesAsync(); + await bootstrap.EnsureLatestAsync(); + Assert.Single(await db.ModerationCases.Where(x => x.SourceLogId == log.Id).ToListAsync()); + Assert.Equal("dismissed", (await db.ModerationCases.AsNoTracking().SingleAsync(x => x.SourceLogId == log.Id)).Status); + } + + [Fact] + public async Task LoginThrottlesRepeatedAttempts() + { + await using var host = new AdminTestHost(); using var client = host.NewClient(); + var session = await JsonAsync(client, "/admin/api/session"); client.DefaultRequestHeaders.Add("X-Ikar-CSRF", session.GetProperty("csrfToken").GetString()); + for (var i = 0; i < 10; i++) Assert.Equal(HttpStatusCode.Unauthorized, (await client.PostAsJsonAsync("/admin/api/session/login", new { username = "admin-test", password = "incorrect" })).StatusCode); + Assert.Equal(HttpStatusCode.TooManyRequests, (await client.PostAsJsonAsync("/admin/api/session/login", new { username = "admin-test", password = "incorrect" })).StatusCode); + } + + private static User User(string name) => new() { Username = name, NormalizedUsername = name.ToUpperInvariant(), DisplayName = name }; + private static async Task JsonAsync(HttpClient client, string url) { var response = await client.GetAsync(url); response.EnsureSuccessStatusCode(); return await response.Content.ReadFromJsonAsync(); } +} + +internal sealed class AdminTestHost(bool production = false) : WebApplicationFactory +{ + private readonly string root = Path.Combine(Path.GetTempPath(), "ikar-admin-test-" + Guid.NewGuid().ToString("N")); + private readonly IPAddress address = IPAddress.Parse("10.99." + Random.Shared.Next(1, 255) + "." + Random.Shared.Next(1, 255)); + protected override void ConfigureWebHost(IWebHostBuilder builder) + { + Directory.CreateDirectory(root); + builder.UseEnvironment(production ? "Production" : "Development").UseContentRoot(root); + if (production) builder.UseSetting("ReverseProxy:KnownProxies", address.ToString()); + builder.UseSetting("SeedDemoData", "false").UseSetting("ConnectionStrings:IkarDb", "Data Source=" + Path.Combine(root, "ikar.db") + ";Pooling=False") + .UseSetting("Admin:Username", "admin-test").UseSetting("Admin:Password", "test-only-password") + .UseSetting("DataProtection:KeysDirectory", Path.Combine(root, "keys")).UseSetting("IkarUpdates:LocalOnly", "true"); + builder.ConfigureServices(services => services.AddSingleton(new TestRemoteAddress(address))); + } + public HttpClient NewClient(bool cookies = true) => CreateClient(new WebApplicationFactoryClientOptions { BaseAddress = new Uri("https://localhost"), HandleCookies = cookies, AllowAutoRedirect = false }); + public async Task LoginAsync(HttpClient client) + { + var before = await client.GetFromJsonAsync("/admin/api/session"); client.DefaultRequestHeaders.Add("X-Ikar-CSRF", before.GetProperty("csrfToken").GetString()); + var response = await client.PostAsJsonAsync("/admin/api/session/login", new { username = "admin-test", password = "test-only-password" }); response.EnsureSuccessStatusCode(); + var after = await client.GetFromJsonAsync("/admin/api/session"); client.DefaultRequestHeaders.Remove("X-Ikar-CSRF"); client.DefaultRequestHeaders.Add("X-Ikar-CSRF", after.GetProperty("csrfToken").GetString()); + } + public override async ValueTask DisposeAsync() { await base.DisposeAsync(); if (Directory.Exists(root)) Directory.Delete(root, true); } + private sealed class TestRemoteAddress(IPAddress address) : Microsoft.AspNetCore.Hosting.IStartupFilter + { + public Action Configure(Action next) => app => { + app.Use((context, continuation) => { context.Connection.RemoteIpAddress = address; return continuation(); }); next(app); + }; + } +} diff --git a/ikar_server/src/Ikar.Server.Tests/Ikar.Server.Tests.csproj b/ikar_server/src/Ikar.Server.Tests/Ikar.Server.Tests.csproj index 8336950..4b5195d 100644 --- a/ikar_server/src/Ikar.Server.Tests/Ikar.Server.Tests.csproj +++ b/ikar_server/src/Ikar.Server.Tests/Ikar.Server.Tests.csproj @@ -8,6 +8,8 @@ + + diff --git a/ikar_server/src/Ikar.Server/Controllers/AdminConsoleController.cs b/ikar_server/src/Ikar.Server/Controllers/AdminConsoleController.cs new file mode 100644 index 0000000..0ce28eb --- /dev/null +++ b/ikar_server/src/Ikar.Server/Controllers/AdminConsoleController.cs @@ -0,0 +1,116 @@ +using System.Security.Claims; +using Ikar.Server.Infrastructure.Admin; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; + +namespace Ikar.Server.Controllers; + +[Authorize(Policy = AdminAuthConstants.Policy)] +public sealed partial class AdminController +{ + [HttpGet("users/list")] + public async Task UserList(string? q, string? status, int page = 1, int size = 25, CancellationToken ct = default) => + Ok(await adminService.UserPageAsync(q, status, page, size, ct)); + + [HttpGet("chats/list")] + public async Task ChatList(string? q, string? type, string? attention, int page = 1, int size = 25, CancellationToken ct = default) => + Ok(await adminService.ChatPageAsync(q, type, page, size, ct, attention)); + + [HttpGet("attachments/list")] + public async Task AttachmentList(string? q, int page = 1, int size = 25, CancellationToken ct = default) => + Ok(await adminService.AttachmentPageAsync(q, page, size, ct)); + + [HttpGet("users/{id:guid}/details")] + public async Task UserDetails(Guid id, CancellationToken ct) => + await console.UserDetailsAsync(id, ct) is { } result ? Ok(result) : NotFound(); + + [HttpPut("users/{id:guid}/profile")] + public async Task EditUser(Guid id, EditUserRequest request, CancellationToken ct) => + await console.EditUserAsync(id, request, ct) ? NoContent() : NotFound(); + + [HttpPost("users/{id:guid}/sessions/revoke")] + public async Task RevokeUserSessions(Guid id, CancellationToken ct) => Ok(new { revoked = await console.RevokeSessionsAsync(id, null, ct) }); + + [HttpDelete("users/{id:guid}/sessions/{sessionId:guid}")] + public async Task RevokeUserSession(Guid id, Guid sessionId, CancellationToken ct) => + await console.RevokeSessionsAsync(id, sessionId, ct) > 0 ? NoContent() : NotFound(); + + [HttpDelete("users/{id:guid}/devices/{deviceId:guid}")] + public async Task RemovePushDevice(Guid id, Guid deviceId, CancellationToken ct) => + await dbContext.PushDevices.Where(x => x.Id == deviceId && x.UserId == id).ExecuteDeleteAsync(ct) > 0 ? NoContent() : NotFound(); + + [HttpGet("reports")] + public async Task Reports(string? q, string? status = "open", int page = 1, int size = 25, CancellationToken ct = default) => + Ok(await console.ReportsAsync(q, status, page, size, ct)); + + [HttpGet("reports/{id:long}")] + public async Task ReportDetails(long id, CancellationToken ct) => + await console.ReportDetailsAsync(id, ct) is { } result ? Ok(result) : NotFound(); + + [HttpPost("reports/{id:long}/resolve")] + public async Task ResolveReport(long id, ResolveReportRequest request, CancellationToken ct) => + await console.ResolveReportAsync(id, request, User.Identity!.Name!, ct) is { } result ? Ok(result) : NotFound(); + + [HttpGet("chats/{id:guid}/messages")] + public async Task ChatMessages(Guid id, string? q, int page = 1, int size = 25, CancellationToken ct = default) => + Ok(await console.MessagesAsync(id, q, page, size, ct)); + + [HttpPost("messages/{id:guid}/{operation}")] + public async Task ModerateMessage(Guid id, string operation, CancellationToken ct) + { + if (operation is not ("hide" or "restore")) return BadRequest(new { error = "Неизвестное действие." }); + return await console.SetMessageDeletedAsync(id, operation == "hide", ct) ? NoContent() : NotFound(); + } + + [HttpGet("bots")] + public async Task Bots(string? q, int page = 1, int size = 25, CancellationToken ct = default) => Ok(await console.BotsAsync(q, page, size, ct)); + + [HttpPut("bots/{id:guid}")] + public async Task EditBot(Guid id, EditBotRequest request, CancellationToken ct) => await console.EditBotAsync(id, request, ct) ? NoContent() : NotFound(); + + [HttpPost("bots/{id:guid}/rotate-token")] + public async Task RotateBotToken(Guid id, CancellationToken ct) => await console.RotateBotTokenAsync(id, ct) is { } token ? Ok(new { token }) : NotFound(); + + [HttpGet("operations")] + public async Task Operations(CancellationToken ct) => Ok(await console.OperationsAsync(ct)); + + [HttpGet("releases")] + public async Task Releases(CancellationToken ct) => Ok(await console.ReleasesAsync(ct)); + + [HttpGet("push-queue")] + public async Task PushQueue(int page = 1, int size = 25, CancellationToken ct = default) => + Ok(await AdminConsoleService.PageAsync(dbContext.PushDispatchOutbox.AsNoTracking().OrderByDescending(x => x.AttemptCount).ThenBy(x => x.Id), page, size, ct)); + + [HttpPost("push-queue/{id:long}/retry")] + public async Task RetryPush(long id, CancellationToken ct) + { + var item = await dbContext.PushDispatchOutbox.FindAsync([id], ct); + if (item is null) return NotFound(); + item.NextAttemptAt = DateTimeOffset.UtcNow; item.LastError = null; item.AttemptCount = 0; + await dbContext.SaveChangesAsync(ct); return NoContent(); + } + + [HttpDelete("push-queue/{id:long}")] + public async Task RemovePush(long id, CancellationToken ct) => await dbContext.PushDispatchOutbox.Where(x => x.Id == id).ExecuteDeleteAsync(ct) > 0 ? NoContent() : NotFound(); + + [HttpGet("audit")] + public async Task Audit(string? q, int page = 1, int size = 25, CancellationToken ct = default) + { + var query = dbContext.AdminAuditEntries.AsNoTracking(); + if (!string.IsNullOrWhiteSpace(q)) query = query.Where(x => x.Actor.Contains(q) || x.Action.Contains(q) || x.Target.Contains(q) || x.Reason != null && x.Reason.Contains(q)); + return Ok(await AdminConsoleService.PageAsync(query.OrderByDescending(x => x.Id), page, size, ct)); + } + + [HttpGet("admin-sessions")] + public async Task AdminSessions(CancellationToken ct) + { + var now = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); + Guid.TryParse(User.FindFirstValue("admin_sid"), out var current); + return Ok(await dbContext.AdminLoginSessions.AsNoTracking().Where(x => x.ExpiresUnixMs > now).OrderByDescending(x => x.CreatedUnixMs) + .Select(x => new { x.Id, x.Username, x.RemoteIp, x.CreatedUnixMs, x.ExpiresUnixMs, current = x.Id == current }).ToListAsync(ct)); + } + + [HttpDelete("admin-sessions/{id:guid}")] + public async Task RevokeAdminSession(Guid id, CancellationToken ct) => await dbContext.AdminLoginSessions.Where(x => x.Id == id).ExecuteDeleteAsync(ct) > 0 ? NoContent() : NotFound(); +} diff --git a/ikar_server/src/Ikar.Server/Controllers/AdminController.cs b/ikar_server/src/Ikar.Server/Controllers/AdminController.cs index f82c731..60595fd 100644 --- a/ikar_server/src/Ikar.Server/Controllers/AdminController.cs +++ b/ikar_server/src/Ikar.Server/Controllers/AdminController.cs @@ -8,25 +8,31 @@ using Microsoft.Extensions.Options; using Ikar.Server.Data; using Ikar.Server.Infrastructure.Admin; using Ikar.Shared; +using Microsoft.AspNetCore.Antiforgery; namespace Ikar.Server.Controllers; [ApiController] [Route("admin/api")] -public sealed class AdminController( +[ServiceFilter(typeof(AdminAuditFilter))] +public sealed partial class AdminController( IOptions adminOptions, AdminService adminService, - IkarDbContext dbContext) : ControllerBase + IkarDbContext dbContext, + IAntiforgery antiforgery, + AdminConsoleService console) : ControllerBase { [HttpGet("session")] [AllowAnonymous] public async Task> GetSessionState() { var auth = await HttpContext.AuthenticateAsync(AdminAuthConstants.Scheme); + if (auth.Succeeded) HttpContext.User = auth.Principal!; return Ok(new AdminSessionStateDto( adminOptions.Value.IsConfigured, auth.Succeeded, - auth.Principal?.Identity?.Name)); + auth.Principal?.Identity?.Name, + antiforgery.GetAndStoreTokens(HttpContext).RequestToken)); } [HttpPost("session/login")] @@ -35,26 +41,35 @@ public sealed class AdminController( { if (!adminOptions.Value.IsConfigured) { - return StatusCode(StatusCodes.Status503ServiceUnavailable, "Admin login is not configured."); + return StatusCode(StatusCodes.Status503ServiceUnavailable, "Административный доступ не настроен на сервере."); } var username = request.Username?.Trim() ?? string.Empty; var password = request.Password ?? string.Empty; if (string.IsNullOrWhiteSpace(username) || string.IsNullOrWhiteSpace(password)) { - return ValidationProblem("Username and password are required."); + return ValidationProblem("Введите логин и пароль."); } if (!SecureEquals(adminOptions.Value.Username, username) || !SecureEquals(adminOptions.Value.Password, password)) { - return Unauthorized("Invalid admin credentials."); + return Unauthorized("Неверный логин или пароль."); } + var now = DateTimeOffset.UtcNow; + var session = new Ikar.Server.Data.Entities.AdminLoginSession { + Username = adminOptions.Value.Username, CredentialVersion = AdminSecurity.CredentialVersion(adminOptions.Value), + RemoteIp = HttpContext.Connection.RemoteIpAddress?.ToString(), CreatedUnixMs = now.ToUnixTimeMilliseconds(), + ExpiresUnixMs = now.AddHours(12).ToUnixTimeMilliseconds() }; + await dbContext.AdminLoginSessions.Where(x => x.ExpiresUnixMs <= now.ToUnixTimeMilliseconds()).ExecuteDeleteAsync(); + dbContext.AdminLoginSessions.Add(session); + await dbContext.SaveChangesAsync(); var claims = new List { new(ClaimTypes.Name, adminOptions.Value.Username), - new(ClaimTypes.Role, "Admin") + new(ClaimTypes.Role, "Admin"), + new("admin_sid", session.Id.ToString()) }; var identity = new ClaimsIdentity(claims, AdminAuthConstants.Scheme); @@ -68,6 +83,8 @@ public sealed class AdminController( [Authorize(Policy = AdminAuthConstants.Policy)] public async Task Logout() { + if (Guid.TryParse(User.FindFirstValue("admin_sid"), out var id)) + await dbContext.AdminLoginSessions.Where(x => x.Id == id).ExecuteDeleteAsync(); await HttpContext.SignOutAsync(AdminAuthConstants.Scheme); return NoContent(); } @@ -150,8 +167,10 @@ public sealed class AdminController( AdminChatMemberPermissionsRequest request, CancellationToken cancellationToken) { - var details = await adminService.UpdateMemberPermissionsAsync(chatId, memberUserId, request, cancellationToken); - return details is null ? NotFound() : Ok(details); + try { + var details = await adminService.UpdateMemberPermissionsAsync(chatId, memberUserId, request, cancellationToken); + return details is null ? NotFound() : Ok(details); + } catch (InvalidOperationException error) { return BadRequest(new { error = error.Message }); } } [HttpPost("chats/{chatId:guid}/invite-links")] diff --git a/ikar_server/src/Ikar.Server/Controllers/QlyraCompatibilityController.cs b/ikar_server/src/Ikar.Server/Controllers/QlyraCompatibilityController.cs index 09b5a2a..ffcb319 100644 --- a/ikar_server/src/Ikar.Server/Controllers/QlyraCompatibilityController.cs +++ b/ikar_server/src/Ikar.Server/Controllers/QlyraCompatibilityController.cs @@ -1078,7 +1078,15 @@ VideoPreviewService? videoPreviewService = null) : ControllerBase var reasonId = ReadInt(payload, "reasonId"); var typeId = ReadInt(payload, "typeId"); if (reasonId is null || typeId is null) return BadRequest(QlyraCompatibilityResponse.Error(opcode, "COMPLAINT_INVALID", "Complaint reason and type are required.")); - dbContext.ModerationLogEntries.Add(new ModerationLogEntry { ActorUserId = user.Id, TargetType = ModerationLogTargetType.User, Action = "complaint.submitted", Reason = JsonSerializer.Serialize(new { reasonId, typeId, ids = ReadFlexibleIntList(payload, "ids"), parentId = ReadInt(payload, "parentId") }) }); + var ids = ReadFlexibleIntList(payload, "ids"); + if (ids.Count == 0 || ids.Count > 50 || reasonId is < 1 or > 4 || typeId is < 1 or > 6) + return BadRequest(QlyraCompatibilityResponse.Error(opcode, "COMPLAINT_INVALID", "Некорректная жалоба.")); + var evidence = JsonSerializer.Serialize(new { reasonId, typeId, ids, parentId = ReadInt(payload, "parentId") }); + var log = new ModerationLogEntry { ActorUserId = user.Id, TargetType = ModerationLogTargetType.User, Action = "complaint.submitted", Reason = evidence }; + dbContext.ModerationLogEntries.Add(log); + await dbContext.SaveChangesAsync(cancellationToken); + dbContext.ModerationCases.Add(new ModerationCase { SourceLogId = log.Id, ReporterId = user.Id, + ReporterName = user.DisplayName, PayloadJson = evidence }); await dbContext.SaveChangesAsync(cancellationToken); return Ok(QlyraCompatibilityResponse.Ok(opcode, new { success = true })); } @@ -1087,7 +1095,7 @@ VideoPreviewService? videoPreviewService = null) : ControllerBase { if (await RequireCurrentUserAsync(cancellationToken) is null) return Unauthorized(QlyraCompatibilityResponse.Error(opcode, "FAIL_LOGIN_TOKEN", "An IKAR access token is required.")); var reasons = new[] { new { reasonId = 1, reasonTitle = "Спам" }, new { reasonId = 2, reasonTitle = "Оскорбления" }, new { reasonId = 3, reasonTitle = "Мошенничество" }, new { reasonId = 4, reasonTitle = "Другое" } }; - return Ok(QlyraCompatibilityResponse.Ok(opcode, new { complains = new[] { new { typeId = 6, reasons }, new { typeId = 1, reasons }, new { typeId = 2, reasons } } })); + return Ok(QlyraCompatibilityResponse.Ok(opcode, new { complains = Enumerable.Range(1, 6).Select(typeId => new { typeId, reasons }) })); } private async Task GetTranscriptionAsync(int opcode, JsonElement payload, CancellationToken cancellationToken) @@ -3065,6 +3073,49 @@ VideoPreviewService? videoPreviewService = null) : ControllerBase }; } + [NonAction] + public async Task PublishAdministrativeMessageChangeAsync(Message message, bool deleted, + Ikar.Server.Infrastructure.Admin.SessionConnectionRegistry connections, CancellationToken cancellationToken) + { + var recipients = message.ScheduledForUnixMs is null ? message.Chat.Members.Select(x => x.UserId).ToList() : [message.SenderId]; + var chatId = await GetClientChatIdAsync(message.Chat, cancellationToken); + if (deleted) { + var id = await clientIdMapper.GetOrCreateAsync("message", message.Id, cancellationToken); + await PublishPacketAsync(recipients, 142, new { chatId, messageIds = new[] { id } }, cancellationToken); + return; + } + var sessions = await dbContext.UserSessions.Where(x => recipients.Contains(x.UserId)).ToListAsync(cancellationToken); + var original = ControllerContext; + try { + foreach (var session in sessions) { + var clients = connections.ConnectionIds(session.Id); + if (clients.Length == 0) continue; + var http = new DefaultHttpContext { User = new System.Security.Claims.ClaimsPrincipal(new System.Security.Claims.ClaimsIdentity([ + new System.Security.Claims.Claim(System.Security.Claims.ClaimTypes.NameIdentifier, session.UserId.ToString()), + new System.Security.Claims.Claim("sid", session.Id.ToString())], "AdminMessageUpdate")) }; + http.Request.Scheme = "https"; http.Request.Host = new HostString("ikar.kusoft.xyz"); + ControllerContext = new ControllerContext { HttpContext = http }; + await hubContext.Clients.Clients(clients).SendAsync("QlyraPacket", new { opcode = message.ScheduledForUnixMs is null ? 128 : 154, + payload = new { chatId, message = await BuildMessageAsync(message, cancellationToken), unread = 0 } }, cancellationToken); + } + } finally { ControllerContext = original; } + } + + [NonAction] + public async Task BuildProfileForSessionAsync(User user, Guid sessionId, CancellationToken cancellationToken) + { + var original = ControllerContext; + var http = new DefaultHttpContext { + User = new System.Security.Claims.ClaimsPrincipal(new System.Security.Claims.ClaimsIdentity([ + new System.Security.Claims.Claim(System.Security.Claims.ClaimTypes.NameIdentifier, user.Id.ToString()), + new System.Security.Claims.Claim("sid", sessionId.ToString())], "AdminProfileUpdate")) }; + http.Request.Scheme = "https"; + http.Request.Host = new HostString("ikar.kusoft.xyz"); + ControllerContext = new ControllerContext { HttpContext = http }; + try { return await BuildProfileAsync(user, cancellationToken); } + finally { ControllerContext = original; } + } + private Task PublishPacketAsync( IEnumerable userIds, int opcode, diff --git a/ikar_server/src/Ikar.Server/Data/Entities/AdminEntities.cs b/ikar_server/src/Ikar.Server/Data/Entities/AdminEntities.cs new file mode 100644 index 0000000..22a2c92 --- /dev/null +++ b/ikar_server/src/Ikar.Server/Data/Entities/AdminEntities.cs @@ -0,0 +1,40 @@ +namespace Ikar.Server.Data.Entities; + +public sealed class AdminAuditEntry +{ + public long Id { get; set; } + public string Actor { get; set; } = ""; + public string Action { get; set; } = ""; + public string Target { get; set; } = ""; + public string? Reason { get; set; } + public string? RemoteIp { get; set; } + public int StatusCode { get; set; } + public long CreatedUnixMs { get; set; } = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); +} + +public sealed class AdminLoginSession +{ + public Guid Id { get; set; } = Guid.NewGuid(); + public string Username { get; set; } = ""; + public string CredentialVersion { get; set; } = ""; + public string? RemoteIp { get; set; } + public long CreatedUnixMs { get; set; } + public long ExpiresUnixMs { get; set; } +} + +public sealed class ModerationCase +{ + public long Id { get; set; } + public long SourceLogId { get; set; } + public Guid? ReporterId { get; set; } + public string ReporterName { get; set; } = ""; + public string PayloadJson { get; set; } = "{}"; + public string TargetKind { get; set; } = "unknown"; + public Guid? TargetId { get; set; } + public Guid? ChatId { get; set; } + public string Status { get; set; } = "open"; + public string? Resolution { get; set; } + public string? ResolvedBy { get; set; } + public long CreatedUnixMs { get; set; } = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); + public long? ResolvedUnixMs { get; set; } +} diff --git a/ikar_server/src/Ikar.Server/Data/IkarDbContext.cs b/ikar_server/src/Ikar.Server/Data/IkarDbContext.cs index 7eefa63..3014c4f 100644 --- a/ikar_server/src/Ikar.Server/Data/IkarDbContext.cs +++ b/ikar_server/src/Ikar.Server/Data/IkarDbContext.cs @@ -47,9 +47,25 @@ public sealed class IkarDbContext(DbContextOptions options) : DbC public DbSet QlyraPollVotes => Set(); public DbSet UserTwoFactorSettings => Set(); public DbSet UserContacts => Set(); + public DbSet AdminAuditEntries => Set(); + public DbSet AdminLoginSessions => Set(); + public DbSet ModerationCases => Set(); protected override void OnModelCreating(ModelBuilder modelBuilder) { + modelBuilder.Entity(entity => { + entity.HasKey(x => x.Id); + entity.HasIndex(x => x.CreatedUnixMs); + }); + modelBuilder.Entity(entity => { + entity.HasKey(x => x.Id); + entity.HasIndex(x => x.ExpiresUnixMs); + }); + modelBuilder.Entity(entity => { + entity.HasKey(x => x.Id); + entity.HasIndex(x => x.SourceLogId).IsUnique(); + entity.HasIndex(x => new { x.Status, x.Id }); + }); ConfigureUsers(modelBuilder); ConfigureChats(modelBuilder); ConfigureMessages(modelBuilder); diff --git a/ikar_server/src/Ikar.Server/Infrastructure/Admin/AdminConsoleService.cs b/ikar_server/src/Ikar.Server/Infrastructure/Admin/AdminConsoleService.cs new file mode 100644 index 0000000..fa24b68 --- /dev/null +++ b/ikar_server/src/Ikar.Server/Infrastructure/Admin/AdminConsoleService.cs @@ -0,0 +1,229 @@ +using System.ComponentModel.DataAnnotations; +using System.Text.Json; +using Ikar.Server.Data; +using Ikar.Server.Data.Entities; +using Ikar.Server.Infrastructure.Auth; +using Ikar.Server.Infrastructure.Hubs; +using Ikar.Server.Infrastructure.Updates; +using Microsoft.AspNetCore.SignalR; +using Microsoft.EntityFrameworkCore; + +namespace Ikar.Server.Infrastructure.Admin; + +public sealed class AdminConsoleService(IkarDbContext db, AdminService admin, BotTokenService tokens, + IHubContext hub, SessionConnectionRegistry connections, IWebHostEnvironment environment, + IConfiguration configuration, PresenceTracker presence, AndroidAppUpdateService updates, + Ikar.Server.Controllers.QlyraCompatibilityController compatibility) +{ + public async Task UserDetailsAsync(Guid id, CancellationToken ct) + { + var user = await db.Users.AsNoTracking().Where(x => x.Id == id).Select(x => new { + x.Id, x.Username, x.DisplayName, x.PhoneNumber, x.Email, x.About, x.IsBlocked, x.IsBot, x.CreatedAt, x.BlockedAt, + x.ScheduledDeletionUnixMs, Messages = x.Messages.Count, Chats = x.ChatMemberships.Count }).SingleOrDefaultAsync(ct); + if (user is null) return null; + return new { user, online = presence.IsOnline(id), connected = presence.IsConnected(id), + sessions = await db.UserSessions.AsNoTracking().Where(x => x.UserId == id).OrderByDescending(x => x.LastSeenAt) + .Select(x => new { x.Id, x.CreatedAt, x.LastSeenAt, x.ClientPlatform, x.ClientVersionCode, x.ClientVersionName }).ToListAsync(ct), + devices = await db.PushDevices.AsNoTracking().Where(x => x.UserId == id).Select(x => new { + x.Id, x.DeviceName, x.Platform, x.NotificationsEnabled, x.ClientPlatform, x.ClientVersionName, x.UpdatedAt }).ToListAsync(ct), + chats = await db.ChatMembers.AsNoTracking().Where(x => x.UserId == id).OrderBy(x => x.ChatId).Take(100).Select(x => new { + x.ChatId, x.Chat.Title, x.Chat.Type, x.Role, x.IsOwner }).ToListAsync(ct) }; + } + + public async Task EditUserAsync(Guid id, EditUserRequest request, CancellationToken ct) + { + ValidateText(request.DisplayName, 1, 120, "Имя должно содержать от 1 до 120 символов."); + ValidateText(request.About ?? "", 0, 2000, "Описание слишком длинное."); + var user = await db.Users.FindAsync([id], ct); + if (user is null) return false; + user.DisplayName = request.DisplayName.Trim(); user.About = request.About?.Trim(); + await db.SaveChangesAsync(ct); + var sessions = await db.UserSessions.Where(x => x.UserId == id).Select(x => x.Id).ToListAsync(ct); + foreach (var session in sessions) { + var clients = connections.ConnectionIds(session); + if (clients.Length == 0) continue; + var profile = await compatibility.BuildProfileForSessionAsync(user, session, ct); + await hub.Clients.Clients(clients).SendAsync("QlyraPacket", new { opcode = 159, payload = new { profile } }, ct); + } + return true; + } + + public async Task RevokeSessionsAsync(Guid userId, Guid? sessionId, CancellationToken ct) + { + var query = db.UserSessions.Where(x => x.UserId == userId); + if (sessionId.HasValue) query = query.Where(x => x.Id == sessionId.Value); + var ids = await query.Select(x => x.Id).ToListAsync(ct); + var count = await query.ExecuteDeleteAsync(ct); + foreach (var id in ids) connections.AbortSession(id); + return count; + } + + public async Task> ReportsAsync(string? q, string? status, int page, int size, CancellationToken ct) + { + var query = db.ModerationCases.AsNoTracking(); + if (!string.IsNullOrEmpty(status) && status != "all") query = query.Where(x => x.Status == status); + if (!string.IsNullOrWhiteSpace(q)) query = query.Where(x => x.ReporterName.Contains(q) || x.PayloadJson.Contains(q) || x.Resolution != null && x.Resolution.Contains(q)); + return await PageAsync(query.OrderByDescending(x => x.Id), page, size, ct); + } + + public async Task ReportDetailsAsync(long id, CancellationToken ct) + { + var report = await db.ModerationCases.FindAsync([id], ct); + if (report is null) return null; + // Old complaints keep their original payload; resolve legacy IDs when first inspected. + await ResolveTargetAsync(report, ct); + var message = report.TargetKind == "message" && report.TargetId.HasValue ? + await db.Messages.AsNoTracking().Where(x => x.Id == report.TargetId).Select(x => new { + x.Id, x.ChatId, x.Text, SenderId = x.SenderId, Sender = x.Sender.DisplayName, x.SentAt, x.DeletedAt }).SingleOrDefaultAsync(ct) : null; + return new { report, message }; + } + + public async Task ResolveReportAsync(long id, ResolveReportRequest request, string actor, CancellationToken ct) + { + if (request.Status is not ("resolved" or "dismissed" or "open")) throw new ValidationException("Неизвестный статус жалобы."); + ValidateText(request.Resolution, 3, 2000, "Укажите решение: от 3 до 2000 символов."); + if (request.Action is not ("none" or "block-user" or "delete-message")) throw new ValidationException("Неизвестное действие."); + if (request.Status != "resolved" && request.Action != "none") throw new ValidationException("Санкция доступна только при подтверждении жалобы."); + await using var transaction = await db.Database.BeginTransactionAsync(ct); + var report = await db.ModerationCases.FindAsync([id], ct); + if (report is null) return null; + if (report.Status != "open" && request.Status != "open") throw new ValidationException("Жалоба уже обработана. Сначала откройте её повторно."); + await ResolveTargetAsync(report, ct); + if (request.Action == "block-user") { + var userId = report.TargetKind == "user" ? report.TargetId : report.TargetKind == "message" ? + await db.Messages.Where(x => x.Id == report.TargetId).Select(x => (Guid?)x.SenderId).SingleOrDefaultAsync(ct) : null; + if (userId is null || !await admin.SetUserBlockedAsync(userId.Value, true, ct)) throw new ValidationException("Пользователь не найден."); + } + if (request.Action == "delete-message" && (report.TargetKind != "message" || report.TargetId is null || !await SetMessageDeletedAsync(report.TargetId.Value, true, ct))) + throw new ValidationException("Сообщение не найдено."); + report.Status = request.Status; report.Resolution = request.Resolution.Trim(); report.ResolvedBy = actor; + report.ResolvedUnixMs = request.Status == "open" ? null : DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); + await db.SaveChangesAsync(ct); + await transaction.CommitAsync(ct); + return report; + } + + private async Task ResolveTargetAsync(ModerationCase report, CancellationToken ct) + { + if (report.TargetId is not null) return; + try { + using var json = JsonDocument.Parse(report.PayloadJson); + var root = json.RootElement; + var type = root.GetProperty("typeId").GetInt32(); + var ids = root.GetProperty("ids"); + if (ids.GetArrayLength() == 0) return; + report.TargetKind = type == 6 ? "user" : type == 2 || !root.TryGetProperty("parentId", out var parent) || parent.ValueKind == JsonValueKind.Null ? "chat" : "message"; + var kind = report.TargetKind == "user" ? "user" : report.TargetKind == "chat" ? "chat" : "message"; + var clientId = ids[0].GetInt32(); + report.TargetId = await db.QlyraClientIdMaps.Where(x => x.EntityKind == kind && x.Id == clientId).Select(x => (Guid?)x.EntityId).SingleOrDefaultAsync(ct); + if (report.TargetKind == "message") report.ChatId = await db.Messages.Where(x => x.Id == report.TargetId).Select(x => (Guid?)x.ChatId).SingleOrDefaultAsync(ct); + else if (report.TargetKind == "chat") report.ChatId = report.TargetId; + } catch (Exception e) when (e is JsonException or KeyNotFoundException or InvalidOperationException or FormatException) { /* Keep legacy evidence viewable. */ } + } + + public async Task> MessagesAsync(Guid chatId, string? q, int page, int size, CancellationToken ct) + { + var query = db.Messages.AsNoTracking().Where(x => x.ChatId == chatId); + if (!string.IsNullOrWhiteSpace(q)) query = query.Where(x => x.Text.Contains(q) || x.Sender.DisplayName.Contains(q)); + var total = await query.CountAsync(ct); page = ClampPage(page); size = ClampSize(size); + var values = await query.OrderByDescending(x => x.SentAt).ThenBy(x => x.Id).Skip((page - 1) * size).Take(size) + .Select(x => new { x.Id, x.ChatId, x.Text, Sender = x.Sender.DisplayName, x.SentAt, x.DeletedAt, x.HasProtectedContent, Attachments = x.Attachments.Count }).ToListAsync(ct); + return new(values.Cast().ToList(), total, page, size); + } + + public async Task SetMessageDeletedAsync(Guid id, bool deleted, CancellationToken ct) + { + var message = await db.Messages.Include(x => x.Chat).ThenInclude(x => x.Members).SingleOrDefaultAsync(x => x.Id == id, ct); + if (message is null) return false; + if (!deleted && message.ExpiresAt <= DateTimeOffset.UtcNow) throw new ValidationException("Срок жизни сообщения истёк."); + message.DeletedAt = deleted ? DateTimeOffset.UtcNow : null; + if (deleted) { message.PinnedAt = null; message.PinnedByUserId = null; } + await db.SaveChangesAsync(ct); + await compatibility.PublishAdministrativeMessageChangeAsync(message, deleted, connections, ct); + return true; + } + + public async Task> BotsAsync(string? q, int page, int size, CancellationToken ct) + { + var query = db.Bots.AsNoTracking(); + if (!string.IsNullOrWhiteSpace(q)) query = query.Where(x => x.User.DisplayName.Contains(q) || x.User.Username.Contains(q) || x.OwnerUser.DisplayName.Contains(q)); + var total = await query.CountAsync(ct); page = ClampPage(page); size = ClampSize(size); + var items = await query.OrderByDescending(x => x.CreatedAt).ThenBy(x => x.Id).Skip((page - 1) * size).Take(size).Select(x => new { + x.Id, x.UserId, x.OwnerUserId, Username = x.User.Username, Name = x.User.DisplayName, About = x.User.About, + Owner = x.OwnerUser.DisplayName, x.IsEnabled, x.WebAppUrl, x.CreatedAt, x.UpdatedAt, + Commands = x.Commands.Select(c => new { c.Command, c.Description }), PendingUpdates = x.Updates.Count }).ToListAsync(ct); + return new(items.Cast().ToList(), total, page, size); + } + + public async Task EditBotAsync(Guid id, EditBotRequest request, CancellationToken ct) + { + ValidateText(request.Name, 1, 120, "Имя бота должно содержать от 1 до 120 символов."); + ValidateText(request.About ?? "", 0, 2000, "Описание слишком длинное."); + var url = string.IsNullOrWhiteSpace(request.WebAppUrl) ? null : request.WebAppUrl.Trim(); + if (url is not null && (!Uri.TryCreate(url, UriKind.Absolute, out var uri) || uri.Scheme != "https" || !string.IsNullOrEmpty(uri.UserInfo))) throw new ValidationException("Мини-приложение должно иметь HTTPS-адрес без логина в URL."); + var bot = await db.Bots.Include(x => x.User).SingleOrDefaultAsync(x => x.Id == id, ct); + if (bot is null) return false; + bot.IsEnabled = request.Enabled; bot.WebAppUrl = url; bot.UpdatedAt = DateTimeOffset.UtcNow; + bot.User.DisplayName = request.Name.Trim(); bot.User.About = request.About?.Trim(); + await db.SaveChangesAsync(ct); return true; + } + + public async Task RotateBotTokenAsync(Guid id, CancellationToken ct) + { + var bot = await db.Bots.FindAsync([id], ct); if (bot is null) return null; + var token = tokens.GenerateToken(id); bot.TokenHash = tokens.HashToken(token); bot.UpdatedAt = DateTimeOffset.UtcNow; + await db.SaveChangesAsync(ct); return token; + } + + public async Task OperationsAsync(CancellationToken ct) + { + var now = DateTimeOffset.UtcNow; + var data = Path.Combine(environment.ContentRootPath, "Data"); + var fullPath = Path.GetFullPath(data); + var drive = DriveInfo.GetDrives().Where(x => fullPath.StartsWith(x.Name, StringComparison.Ordinal)) + .OrderByDescending(x => x.Name.Length).FirstOrDefault() ?? new DriveInfo(Path.GetPathRoot(fullPath)!); + var backupDir = configuration["Backup:Directory"]; + var backups = !string.IsNullOrWhiteSpace(backupDir) && Directory.Exists(backupDir) ? + Directory.EnumerateFiles(backupDir, "ikar-*.tar.gz").Select(x => new FileInfo(x)).OrderByDescending(x => x.LastWriteTimeUtc).Take(20) + .Select(x => new { name = x.Name, bytes = x.Length, createdAt = x.LastWriteTimeUtc }).ToList() : []; + return new { diskFreeBytes = drive.AvailableFreeSpace, diskTotalBytes = drive.TotalSize, backups, + processMemoryBytes = Environment.WorkingSet, processors = Environment.ProcessorCount, + openReports = await db.ModerationCases.CountAsync(x => x.Status == "open", ct), + queuedPush = await db.PushDispatchOutbox.CountAsync(ct), failedPush = await db.PushDispatchOutbox.CountAsync(x => x.AttemptCount > 0, ct), + scheduledMessages = await db.Messages.CountAsync(x => x.ScheduledForUnixMs != null && x.DeletedAt == null, ct), + expiredMessages = await db.Messages.CountAsync(x => x.ExpiresAt <= now && x.DeletedAt == null, ct), + dataProtectionPersistent = Directory.Exists(configuration["DataProtection:KeysDirectory"] ?? Path.Combine(data, "AdminKeys")) }; + } + + public async Task ReleasesAsync(CancellationToken ct) + { + var release = await updates.GetLatestReleaseAsync(ct); + var root = Path.Combine(environment.ContentRootPath, "Data", "AppUpdates", "Android"); + var apk = release is null ? null : Path.Combine(root, $"ikar-android-{release.VersionCode}.apk"); + long? bytes = apk is not null && File.Exists(apk) ? new FileInfo(apk).Length : null; + string? digest = null; + if (bytes is not null) { + await using var stream = File.OpenRead(apk!); + digest = Convert.ToHexString(await System.Security.Cryptography.SHA256.HashDataAsync(stream, ct)).ToLowerInvariant(); + } + var packages = Directory.Exists(root) ? Directory.EnumerateFiles(root, "*.apk").Select(x => new FileInfo(x)).OrderByDescending(x => x.LastWriteTimeUtc) + .Select(x => new { name = x.Name, bytes = x.Length, createdAt = x.LastWriteTimeUtc }).ToList() : []; + return new { release = release is null ? null : new { release.VersionCode, release.VersionName, release.DownloadPath, release.PublishedAt, release.Notes, packageSizeBytes = bytes, sha256 = digest }, packages, catalogUrl = "https://argus.kusoft.xyz/#app=ikar" }; + } + + public static int ClampPage(int page) => Math.Clamp(page, 1, 1000000); + public static int ClampSize(int size) => Math.Clamp(size, 1, 100); + public static async Task> PageAsync(IQueryable query, int page, int size, CancellationToken ct) + { + page = ClampPage(page); size = ClampSize(size); + return new(await query.Skip((page - 1) * size).Take(size).ToListAsync(ct), await query.CountAsync(ct), page, size); + } + private static void ValidateText(string value, int min, int max, string error) + { + if (value.Trim().Length < min || value.Length > max) throw new ValidationException(error); + } +} + +public sealed record EditUserRequest(string DisplayName, string? About); +public sealed record EditBotRequest(string Name, string? About, bool Enabled, string? WebAppUrl); +public sealed record ResolveReportRequest(string Status, string Resolution, string Action = "none"); diff --git a/ikar_server/src/Ikar.Server/Infrastructure/Admin/AdminDtos.cs b/ikar_server/src/Ikar.Server/Infrastructure/Admin/AdminDtos.cs index a7c200b..73838b4 100644 --- a/ikar_server/src/Ikar.Server/Infrastructure/Admin/AdminDtos.cs +++ b/ikar_server/src/Ikar.Server/Infrastructure/Admin/AdminDtos.cs @@ -5,7 +5,8 @@ namespace Ikar.Server.Infrastructure.Admin; public sealed record AdminSessionStateDto( bool Configured, bool Authenticated, - string? Username); + string? Username, + string? CsrfToken = null); public sealed record AdminStatusDto( string Environment, diff --git a/ikar_server/src/Ikar.Server/Infrastructure/Admin/AdminPages.cs b/ikar_server/src/Ikar.Server/Infrastructure/Admin/AdminPages.cs new file mode 100644 index 0000000..08b6058 --- /dev/null +++ b/ikar_server/src/Ikar.Server/Infrastructure/Admin/AdminPages.cs @@ -0,0 +1,50 @@ +using Ikar.Server.Data.Entities; +using Ikar.Shared; +using Microsoft.EntityFrameworkCore; + +namespace Ikar.Server.Infrastructure.Admin; + +public sealed record AdminPage(IReadOnlyList Items, int Total, int Page, int PageSize); + +public sealed partial class AdminService +{ + private static IQueryable FilterUsers(IQueryable query, string? status) => status switch { + "blocked" => query.Where(x => x.IsBlocked), "active" => query.Where(x => !x.IsBlocked), + "bots" => query.Where(x => x.IsBot), "people" => query.Where(x => !x.IsBot), _ => query }; + + public async Task> UserPageAsync(string? q, string? status, int page, int size, CancellationToken ct) + { + page = Math.Clamp(page, 1, 1000000); size = Math.Clamp(size, 1, 100); + var query = FilterUsers(dbContext.Users.AsNoTracking(), status); + if (!string.IsNullOrWhiteSpace(q)) { + var normalized = q.Trim().ToUpperInvariant(); var like = $"%{q.Trim()}%"; + query = query.Where(x => x.NormalizedUsername.Contains(normalized) || EF.Functions.Like(x.DisplayName, like) || x.PhoneNumber != null && EF.Functions.Like(x.PhoneNumber, like)); + } + return new(await GetUsersAsync(q, ct, page, size, status), await query.CountAsync(ct), page, size); + } + + private static IQueryable FilterChatAttention(IQueryable query, string? attention) => attention switch { + "pending" => query.Where(x => x.JoinRequests.Any(r => r.Status == ChatJoinRequestStatus.Pending)), + "protected" => query.Where(x => x.HasProtectedContent || x.Messages.Any(m => m.HasProtectedContent)), + "topics" => query.Where(x => x.TopicsEnabled || x.Topics.Any()), _ => query }; + + public async Task> ChatPageAsync(string? q, string? type, int page, int size, CancellationToken ct, string? attention = null) + { + page = Math.Clamp(page, 1, 1000000); size = Math.Clamp(size, 1, 100); + var query = BuildChatsQuery(q); + if (Enum.TryParse(type, true, out var kind)) query = query.Where(x => x.Type == kind); + query = FilterChatAttention(query, attention); + return new(await GetChatsAsync(q, ct, page, size, type, attention), await query.CountAsync(ct), page, size); + } + + public async Task> AttachmentPageAsync(string? q, int page, int size, CancellationToken ct) + { + page = Math.Clamp(page, 1, 1000000); size = Math.Clamp(size, 1, 100); + var query = dbContext.MessageAttachments.AsNoTracking(); + if (!string.IsNullOrWhiteSpace(q)) { + var like = $"%{q.Trim()}%"; + query = query.Where(x => EF.Functions.Like(x.OriginalFileName, like) || EF.Functions.Like(x.ContentType, like) || EF.Functions.Like(x.Message.Sender.DisplayName, like) || x.Message.Chat.Title != null && EF.Functions.Like(x.Message.Chat.Title, like)); + } + return new(await GetAttachmentsAsync(q, ct, page, size), await query.CountAsync(ct), page, size); + } +} diff --git a/ikar_server/src/Ikar.Server/Infrastructure/Admin/AdminSecurity.cs b/ikar_server/src/Ikar.Server/Infrastructure/Admin/AdminSecurity.cs new file mode 100644 index 0000000..5e75ab1 --- /dev/null +++ b/ikar_server/src/Ikar.Server/Infrastructure/Admin/AdminSecurity.cs @@ -0,0 +1,83 @@ +using System.Collections.Concurrent; +using System.Security.Claims; +using System.Security.Cryptography; +using System.Text; +using Ikar.Server.Data; +using Ikar.Server.Data.Entities; +using Microsoft.AspNetCore.Antiforgery; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Mvc.Filters; + +namespace Ikar.Server.Infrastructure.Admin; + +public static class AdminSecurity +{ + public static string CredentialVersion(AdminOptions options) => + Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(options.Username + "\n" + options.Password))); +} + +public sealed class AdminGuardMiddleware(RequestDelegate next) +{ + private static readonly ConcurrentDictionary Attempts = new(); + + public async Task InvokeAsync(HttpContext context, IAntiforgery antiforgery) + { + if (!context.Request.Path.StartsWithSegments("/admin")) { await next(context); return; } + context.Response.Headers.CacheControl = "no-store"; + context.Response.Headers["X-Frame-Options"] = "DENY"; + context.Response.Headers["X-Content-Type-Options"] = "nosniff"; + context.Response.Headers["Referrer-Policy"] = "no-referrer"; + context.Response.Headers["Content-Security-Policy"] = "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'"; + if (HttpMethods.IsPost(context.Request.Method) && context.Request.Path == "/admin/api/session/login") + { + var window = DateTimeOffset.UtcNow.ToUnixTimeSeconds() / 300; + var key = context.Connection.RemoteIpAddress?.ToString() ?? "unknown"; + var value = Attempts.AddOrUpdate(key, _ => (window, 1), (_, old) => old.Window == window ? (window, old.Count + 1) : (window, 1)); + if (Attempts.Count > 1000) foreach (var pair in Attempts) if (pair.Value.Window < window) Attempts.TryRemove(pair.Key, out _); + if (value.Count > 10) + { + context.Response.StatusCode = 429; + context.Response.Headers.RetryAfter = "300"; + await context.Response.WriteAsJsonAsync(new { error = "Слишком много попыток входа. Повторите через 5 минут." }); + return; + } + } + if (!HttpMethods.IsGet(context.Request.Method) && !HttpMethods.IsHead(context.Request.Method) && !HttpMethods.IsOptions(context.Request.Method)) + { + var authentication = await context.AuthenticateAsync(AdminAuthConstants.Scheme); + if (authentication.Succeeded) context.User = authentication.Principal!; + try { await antiforgery.ValidateRequestAsync(context); } + catch (AntiforgeryValidationException) + { + context.Response.StatusCode = 400; + await context.Response.WriteAsJsonAsync(new { error = "Защита запроса: обновите страницу и повторите действие." }); + return; + } + } + await next(context); + } +} + +public sealed class AdminAuditFilter(IkarDbContext db) : IAsyncActionFilter +{ + public async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next) + { + var http = context.HttpContext; + var actor = http.User.FindFirstValue(ClaimTypes.Name) ?? "anonymous"; + var result = await next(); + if (result.Exception is System.ComponentModel.DataAnnotations.ValidationException validation) { + result.ExceptionHandled = true; + result.Result = new Microsoft.AspNetCore.Mvc.BadRequestObjectResult(new { error = validation.Message }); + } + if (HttpMethods.IsGet(http.Request.Method) || HttpMethods.IsHead(http.Request.Method)) return; + var status = result.Exception is not null && !result.ExceptionHandled ? 500 : (result.Result as Microsoft.AspNetCore.Mvc.Infrastructure.IStatusCodeActionResult)?.StatusCode ?? 200; + if (status >= 400) db.ChangeTracker.Clear(); + var reason = Uri.UnescapeDataString(http.Request.Headers["X-Admin-Reason"].ToString()); + db.AdminAuditEntries.Add(new AdminAuditEntry { Actor = actor, + Action = http.Request.Method + " " + context.ActionDescriptor.RouteValues["action"], + Target = http.Request.Path.Value ?? "", StatusCode = status, + Reason = string.IsNullOrWhiteSpace(reason) ? null : reason[..Math.Min(reason.Length, 2000)], + RemoteIp = http.Connection.RemoteIpAddress?.ToString() }); + await db.SaveChangesAsync(CancellationToken.None); + } +} diff --git a/ikar_server/src/Ikar.Server/Infrastructure/Admin/AdminService.cs b/ikar_server/src/Ikar.Server/Infrastructure/Admin/AdminService.cs index 2b50806..ebeb7a4 100644 --- a/ikar_server/src/Ikar.Server/Infrastructure/Admin/AdminService.cs +++ b/ikar_server/src/Ikar.Server/Infrastructure/Admin/AdminService.cs @@ -10,13 +10,14 @@ using Ikar.Shared; namespace Ikar.Server.Infrastructure.Admin; -public sealed class AdminService( +public sealed partial class AdminService( IkarDbContext dbContext, IWebHostEnvironment environment, ServerRuntimeState runtimeState, AttachmentStorageService attachmentStorageService, BotDeletionService botDeletionService, - IHubContext hubContext) + IHubContext hubContext, + SessionConnectionRegistry? connections = null) { public async Task GetStatusAsync(CancellationToken cancellationToken) { @@ -134,9 +135,10 @@ public sealed class AdminService( recentHits); } - public async Task> GetUsersAsync(string? query, CancellationToken cancellationToken) + public async Task> GetUsersAsync(string? query, CancellationToken cancellationToken, int page = 1, int size = 250, string? status = null) { var usersQuery = dbContext.Users.AsNoTracking(); + usersQuery = FilterUsers(usersQuery, status); if (!string.IsNullOrWhiteSpace(query)) { var normalized = query.Trim().ToUpperInvariant(); @@ -149,7 +151,9 @@ public sealed class AdminService( var users = await usersQuery .OrderByDescending(x => x.CreatedAt) - .Take(250) + .ThenBy(x => x.Id) + .Skip((Math.Max(1, page) - 1) * Math.Clamp(size, 1, 250)) + .Take(Math.Clamp(size, 1, 250)) .Select(x => new { x.Id, @@ -246,11 +250,14 @@ public sealed class AdminService( .ToList(); } - public async Task> GetChatsAsync(string? query, CancellationToken cancellationToken) + public async Task> GetChatsAsync(string? query, CancellationToken cancellationToken, int page = 1, int size = 250, string? type = null, string? attention = null) { var chatsQuery = BuildChatsQuery(query); - var projections = await ProjectChats(chatsQuery.OrderByDescending(x => x.LastActivityAt ?? x.CreatedAt)) - .Take(250) + if (Enum.TryParse(type, true, out var kind)) chatsQuery = chatsQuery.Where(x => x.Type == kind); + chatsQuery = FilterChatAttention(chatsQuery, attention); + var projections = await ProjectChats(chatsQuery.OrderByDescending(x => x.LastActivityAt ?? x.CreatedAt).ThenBy(x => x.Id)) + .Skip((Math.Max(1, page) - 1) * Math.Clamp(size, 1, 250)) + .Take(Math.Clamp(size, 1, 250)) .ToListAsync(cancellationToken); var directTitles = await LoadDirectTitlesAsync(projections, cancellationToken); @@ -429,6 +436,14 @@ public sealed class AdminService( return null; } + if (!Enum.IsDefined(request.Role)) throw new InvalidOperationException("Неизвестная роль участника."); + if (member.IsOwner && request.Role != ChatMemberRole.Owner && chat.Members.Count(x => x.IsOwner) <= 1) + throw new InvalidOperationException("Нельзя убрать последнего владельца. Сначала назначьте нового."); + if (request.Role == ChatMemberRole.Owner && !member.IsOwner) + { + foreach (var previous in chat.Members.Where(x => x.IsOwner)) { previous.IsOwner = false; previous.Role = ChatMemberRole.Admin; } + chat.CreatedById = memberUserId; + } member.Role = request.Role; member.IsOwner = request.Role == ChatMemberRole.Owner; member.CanPinMessages = request.CanPinMessages || member.IsOwner; @@ -544,7 +559,7 @@ public sealed class AdminService( return await GetChatDetailsAsync(chatId, cancellationToken); } - public async Task> GetAttachmentsAsync(string? query, CancellationToken cancellationToken) + public async Task> GetAttachmentsAsync(string? query, CancellationToken cancellationToken, int page = 1, int size = 250) { var attachmentsQuery = dbContext.MessageAttachments .AsNoTracking() @@ -564,7 +579,8 @@ public sealed class AdminService( (x.Message.Chat.Title != null && EF.Functions.Like(x.Message.Chat.Title, like))); } - var attachments = await attachmentsQuery.ToListAsync(cancellationToken); + var attachments = await attachmentsQuery.OrderByDescending(x => x.UploadedAt).ThenBy(x => x.Id) + .Skip((Math.Max(1, page) - 1) * Math.Clamp(size, 1, 250)).Take(Math.Clamp(size, 1, 250)).ToListAsync(cancellationToken); return attachments .OrderByDescending(x => x.UploadedAt) @@ -667,6 +683,7 @@ public sealed class AdminService( user.IsBlocked = isBlocked; user.BlockedAt = isBlocked ? DateTimeOffset.UtcNow : null; + var revokedSessionIds = isBlocked ? user.Sessions.Select(x => x.Id).ToArray() : []; if (isBlocked && user.Sessions.Count > 0) { @@ -674,6 +691,7 @@ public sealed class AdminService( } await dbContext.SaveChangesAsync(cancellationToken); + foreach (var id in revokedSessionIds) connections?.AbortSession(id); return true; } @@ -875,8 +893,10 @@ public sealed class AdminService( dbContext.ChatTopics.RemoveRange(topics); var avatarPath = user.AvatarStoragePath; + var revokedSessions = user.Sessions.Select(x => x.Id).ToList(); dbContext.Users.Remove(user); await dbContext.SaveChangesAsync(cancellationToken); + foreach (var session in revokedSessions) connections?.AbortSession(session); DeleteFiles(attachmentPathsToDelete); attachmentStorageService.DeleteIfExists(avatarPath); diff --git a/ikar_server/src/Ikar.Server/Infrastructure/Admin/SessionConnectionRegistry.cs b/ikar_server/src/Ikar.Server/Infrastructure/Admin/SessionConnectionRegistry.cs new file mode 100644 index 0000000..3f4a34f --- /dev/null +++ b/ikar_server/src/Ikar.Server/Infrastructure/Admin/SessionConnectionRegistry.cs @@ -0,0 +1,70 @@ +using Ikar.Server.Data; +using Ikar.Server.Infrastructure.Auth; +using Microsoft.AspNetCore.SignalR; +using Microsoft.EntityFrameworkCore; + +namespace Ikar.Server.Infrastructure.Admin; + +public sealed class SessionConnectionRegistry +{ + private readonly object gate = new(); + private readonly Dictionary> sessions = new(); + public void Add(Guid sessionId, string connectionId, Action abort) + { + lock (gate) { + if (!sessions.TryGetValue(sessionId, out var values)) sessions[sessionId] = values = new(); + values[connectionId] = abort; + } + } + public void Remove(Guid sessionId, string connectionId) + { + lock (gate) { + if (!sessions.TryGetValue(sessionId, out var values)) return; + values.Remove(connectionId); + if (values.Count == 0) sessions.Remove(sessionId); + } + } + public void AbortSession(Guid id) + { + Action[] callbacks; + lock (gate) { + if (!sessions.Remove(id, out var values)) return; + callbacks = values.Values.ToArray(); + } + foreach (var abort in callbacks) abort(); + } + public string[] ConnectionIds(Guid id) { lock (gate) return sessions.TryGetValue(id, out var values) ? values.Keys.ToArray() : []; } +} + +public sealed class SessionValidationHubFilter(SessionConnectionRegistry registry) : IHubFilter +{ + public async Task OnConnectedAsync(HubLifetimeContext context, Func next) + { + registry.Add(context.Context.User!.GetRequiredSessionId(), context.Context.ConnectionId, context.Context.Abort); + try { + var db = context.ServiceProvider.GetRequiredService(); + var id = context.Context.User!.GetRequiredSessionId(); + if (!await db.UserSessions.AnyAsync(x => x.Id == id && !x.User.IsBlocked, context.Context.ConnectionAborted)) { + context.Context.Abort(); throw new HubException("Сессия завершена."); + } + await next(context); + } catch { + registry.Remove(context.Context.User!.GetRequiredSessionId(), context.Context.ConnectionId); + throw; + } + } + public async Task OnDisconnectedAsync(HubLifetimeContext context, Exception? exception, Func next) + { + registry.Remove(context.Context.User!.GetRequiredSessionId(), context.Context.ConnectionId); + await next(context, exception); + } + public async ValueTask InvokeMethodAsync(HubInvocationContext context, Func> next) + { + var db = context.ServiceProvider.GetRequiredService(); + var id = context.Context.User!.GetRequiredSessionId(); + if (!await db.UserSessions.AnyAsync(x => x.Id == id && !x.User.IsBlocked, context.Context.ConnectionAborted)) { + context.Context.Abort(); throw new HubException("Сессия завершена."); + } + return await next(context); + } +} diff --git a/ikar_server/src/Ikar.Server/Infrastructure/Storage/DatabaseSchemaBootstrapper.cs b/ikar_server/src/Ikar.Server/Infrastructure/Storage/DatabaseSchemaBootstrapper.cs index 915998b..7843855 100644 --- a/ikar_server/src/Ikar.Server/Infrastructure/Storage/DatabaseSchemaBootstrapper.cs +++ b/ikar_server/src/Ikar.Server/Infrastructure/Storage/DatabaseSchemaBootstrapper.cs @@ -32,6 +32,30 @@ public sealed class DatabaseSchemaBootstrapper(IkarDbContext dbContext) await EnsureBotCommandsTableAsync(cancellationToken); await EnsureBotUpdatesTableAsync(cancellationToken); await EnsureModerationLogEntriesTableAsync(cancellationToken); + await dbContext.Database.ExecuteSqlRawAsync(""" + CREATE TABLE IF NOT EXISTS "AdminAuditEntries" ( + "Id" INTEGER PRIMARY KEY AUTOINCREMENT, "Actor" TEXT NOT NULL, "Action" TEXT NOT NULL, + "Target" TEXT NOT NULL, "Reason" TEXT NULL, "RemoteIp" TEXT NULL, "StatusCode" INTEGER NOT NULL, + "CreatedUnixMs" INTEGER NOT NULL); + CREATE INDEX IF NOT EXISTS "IX_AdminAuditEntries_CreatedUnixMs" ON "AdminAuditEntries" ("CreatedUnixMs"); + CREATE TABLE IF NOT EXISTS "AdminLoginSessions" ( + "Id" TEXT PRIMARY KEY, "Username" TEXT NOT NULL, "CredentialVersion" TEXT NOT NULL, + "RemoteIp" TEXT NULL, "CreatedUnixMs" INTEGER NOT NULL, "ExpiresUnixMs" INTEGER NOT NULL); + CREATE INDEX IF NOT EXISTS "IX_AdminLoginSessions_ExpiresUnixMs" ON "AdminLoginSessions" ("ExpiresUnixMs"); + CREATE TABLE IF NOT EXISTS "ModerationCases" ( + "Id" INTEGER PRIMARY KEY AUTOINCREMENT, "SourceLogId" INTEGER NOT NULL, "ReporterId" TEXT NULL, + "ReporterName" TEXT NOT NULL, "PayloadJson" TEXT NOT NULL, "TargetKind" TEXT NOT NULL, + "TargetId" TEXT NULL, "ChatId" TEXT NULL, "Status" TEXT NOT NULL DEFAULT 'open', + "Resolution" TEXT NULL, "ResolvedBy" TEXT NULL, "CreatedUnixMs" INTEGER NOT NULL, + "ResolvedUnixMs" INTEGER NULL); + CREATE UNIQUE INDEX IF NOT EXISTS "IX_ModerationCases_SourceLogId" ON "ModerationCases" ("SourceLogId"); + CREATE INDEX IF NOT EXISTS "IX_ModerationCases_Status_Id" ON "ModerationCases" ("Status", "Id"); + INSERT OR IGNORE INTO "ModerationCases" ("SourceLogId", "ReporterId", "ReporterName", "PayloadJson", "TargetKind", "Status", "CreatedUnixMs") + SELECT m."Id", m."ActorUserId", COALESCE(u."DisplayName", 'Удалённый пользователь'), + COALESCE(m."Reason", '{{}}'), 'unknown', 'open', CAST(strftime('%s', m."CreatedAt") AS INTEGER) * 1000 + FROM "ModerationLogEntries" m LEFT JOIN "Users" u ON u."Id" = m."ActorUserId" + WHERE m."Action" = 'complaint.submitted'; + """, cancellationToken); await EnsureStoriesTablesAsync(cancellationToken); await EnsureMessageReactionsTableAsync(cancellationToken); await EnsureMessageViewsTableAsync(cancellationToken); diff --git a/ikar_server/src/Ikar.Server/Program.cs b/ikar_server/src/Ikar.Server/Program.cs index 79bd7cd..21f806b 100644 --- a/ikar_server/src/Ikar.Server/Program.cs +++ b/ikar_server/src/Ikar.Server/Program.cs @@ -2,6 +2,9 @@ using System.Net; using System.Security.Claims; using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.AspNetCore.Authentication.Cookies; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.DataProtection; +using Microsoft.AspNetCore.SignalR; using Microsoft.AspNetCore.HttpOverrides; using Microsoft.Data.Sqlite; using Microsoft.EntityFrameworkCore; @@ -32,7 +35,11 @@ builder.WebHost.UseUrls(configuredUrls); builder.Services.AddControllers(); builder.Services.AddScoped(); builder.Services.AddOpenApi(); -builder.Services.AddSignalR(); +builder.Services.AddSignalR(options => options.AddFilter()); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +var adminKeys = builder.Configuration["DataProtection:KeysDirectory"] ?? Path.Combine(builder.Environment.ContentRootPath, "Data", "AdminKeys"); +builder.Services.AddDataProtection().SetApplicationName("IKAR.Admin").PersistKeysToFileSystem(new DirectoryInfo(adminKeys)); builder.Services.AddEndpointsApiExplorer(); builder.Services.AddHttpClient(); @@ -52,6 +59,8 @@ builder.Services.Configure(options => options.ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto | ForwardedHeaders.XForwardedHost; options.KnownProxies.Add(IPAddress.Loopback); options.KnownProxies.Add(IPAddress.IPv6Loopback); + foreach (var proxy in (builder.Configuration["ReverseProxy:KnownProxies"] ?? "").Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries)) + if (IPAddress.TryParse(proxy, out var address)) options.KnownProxies.Add(address); }); builder.Services.Configure(builder.Configuration.GetSection(JwtOptions.SectionName)); @@ -157,9 +166,22 @@ builder.Services ? CookieSecurePolicy.SameAsRequest : CookieSecurePolicy.Always; options.ExpireTimeSpan = TimeSpan.FromHours(12); - options.SlidingExpiration = true; + options.SlidingExpiration = false; options.Events = new CookieAuthenticationEvents { + OnValidatePrincipal = async context => + { + var settings = context.HttpContext.RequestServices.GetRequiredService>().Value; + var db = context.HttpContext.RequestServices.GetRequiredService(); + var id = Guid.TryParse(context.Principal?.FindFirstValue("admin_sid"), out var parsed) ? parsed : Guid.Empty; + var session = await db.AdminLoginSessions.AsNoTracking().SingleOrDefaultAsync(x => x.Id == id, context.HttpContext.RequestAborted); + if (!settings.IsConfigured || session is null || session.ExpiresUnixMs <= DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() || + session.Username != settings.Username || session.CredentialVersion != AdminSecurity.CredentialVersion(settings)) + { + context.RejectPrincipal(); + await context.HttpContext.SignOutAsync(AdminAuthConstants.Scheme); + } + }, OnRedirectToLogin = context => { context.Response.StatusCode = StatusCodes.Status401Unauthorized; @@ -207,6 +229,14 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddAntiforgery(options => { + options.HeaderName = "X-Ikar-CSRF"; + options.Cookie.Name = "Ikar.Admin.Csrf"; + options.Cookie.SameSite = SameSiteMode.Strict; + options.Cookie.SecurePolicy = builder.Environment.IsDevelopment() ? CookieSecurePolicy.SameAsRequest : CookieSecurePolicy.Always; +}); builder.Services.AddSingleton(); builder.Services.AddHostedService(); builder.Services.AddSingleton(); @@ -255,6 +285,7 @@ app.Use(async (context, next) => await next(); }); app.UseForwardedHeaders(); +app.UseMiddleware(); app.UseStaticFiles(); app.Use(async (context, next) => { @@ -387,3 +418,5 @@ static bool TryGetClientVersionCode(HttpRequest request, out int clientVersionCo { return ClientVersionRequestReader.TryGetClientVersionCode(request, out clientVersionCode); } + +public partial class Program { } diff --git a/ikar_server/src/Ikar.Server/wwwroot/admin/admin.css b/ikar_server/src/Ikar.Server/wwwroot/admin/admin.css index 184bc1f..58f6c30 100644 --- a/ikar_server/src/Ikar.Server/wwwroot/admin/admin.css +++ b/ikar_server/src/Ikar.Server/wwwroot/admin/admin.css @@ -1,15 +1,15 @@ :root { - --paper: #f4f1ea; + --paper: #f3f6fb; --paper-2: #ebe6dc; --ink: #14171b; --ink-2: #2a3038; --muted: #68707b; --line: #c9c2b5; --line-strong: #9e9688; - --panel: #fffdf8; + --panel: #ffffff; --rail: #171b20; --rail-2: #20262e; - --accent: #0f5f9f; + --accent: #245bdf; --accent-2: #0b7c86; --warning: #a56300; --danger: #b3261e; @@ -702,3 +702,32 @@ tr.selected { grid-template-columns: 1fr; } } + +body { background: linear-gradient(145deg, #f7faff, #edf2f9); } +.brand-mark { width: 48px; height: 48px; object-fit: contain; border-radius: 14px; } +.console-toolbar { display: flex; gap: 20px; align-items: end; justify-content: space-between; margin: 0 0 22px; } +.console-toolbar label:first-child { flex: 1; max-width: 620px; } +.console-toolbar label, .inline-filter { display: flex; gap: 8px; align-items: center; font-size: 13px; color: var(--muted); } +.console-toolbar input[type=checkbox], .check-label input { width: auto; } +.console-toolbar input:not([type=checkbox]) { width: 100%; min-width: 0; padding: 10px 12px; border: 1px solid #cbd6e8; border-radius: 8px; background: white; } +.console-toolbar label:first-child { display: grid; } +.pager { display: flex; align-items: center; justify-content: space-between; padding: 18px 0 0; gap: 12px; font-size: 13px; color: var(--muted); } +.pager .action-row { align-items: center; } +.button:disabled { opacity: .4; cursor: default; } +.inline-filter select { min-width: 140px; } +.wrap { overflow-wrap: anywhere; white-space: pre-wrap; } +.message-text { white-space: pre-wrap; max-width: 580px; } +dialog { width: min(1040px, calc(100vw - 32px)); max-height: 90vh; padding: 28px; border: 1px solid #dce3ef; border-radius: 18px; background: var(--panel); box-shadow: 0 30px 100px #1627464d; color: var(--ink); } +dialog::backdrop { background: #10203a99; backdrop-filter: blur(3px); } +.dialog-heading { display: flex; align-items: center; justify-content: space-between; gap: 16px; margin-bottom: 22px; } +.dialog-heading h2 { margin: 0; } +dialog form { display: grid; gap: 14px; margin: 20px 0; } +dialog form label { display: grid; gap: 6px; font-size: 14px; } +dialog form .check-label { display: flex; align-items: center; } +textarea { font: inherit; width: 100%; min-height: 92px; resize: vertical; border: 1px solid var(--line); border-radius: 9px; padding: 10px 12px; background: white; color: var(--ink); } +.detail-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 28px; } +blockquote { background: #f1f5fd; margin: 16px 0; padding: 18px; border-left: 4px solid var(--accent); border-radius: 8px; } +.inspector-tools { margin-bottom: 18px; } +#reportBadge:not(:empty) { display: inline-flex; border-radius: 8px; padding: 1px 7px; background: #f5a524; color: #182338; margin-left: 8px; } +input:focus-visible, textarea:focus-visible, select:focus-visible, button:focus-visible { outline: 3px solid #5689ff; outline-offset: 2px; } +@media (max-width: 860px) { .console-toolbar, .pager { flex-wrap: wrap; } .detail-grid { grid-template-columns: 1fr; } dialog { padding: 18px; } .nav-list { display: grid; grid-template-columns: 1fr 1fr; } .nav-item { padding: 10px; font-size: 13px; } } diff --git a/ikar_server/src/Ikar.Server/wwwroot/admin/admin.js b/ikar_server/src/Ikar.Server/wwwroot/admin/admin.js index de50b74..ccdeafe 100644 --- a/ikar_server/src/Ikar.Server/wwwroot/admin/admin.js +++ b/ikar_server/src/Ikar.Server/wwwroot/admin/admin.js @@ -20,7 +20,7 @@ const sectionMeta = { media: ["Медиа", "Хранилище вложений"], moderation: ["Модерация", "Журнал действий"], updates: ["Обновления", "Android-релизы и совместимость"], - operations: ["Операции", "Maintenance и контроль данных"], + operations: ["Операции", "Обслуживание и контроль данных"], }; const elements = { @@ -72,7 +72,10 @@ document.addEventListener("DOMContentLoaded", () => { function wireEvents() { elements.loginForm.addEventListener("submit", async (event) => { event.preventDefault(); - await login(); + const button = elements.loginForm.querySelector("button"); + button.disabled = true; + try { await login(); } catch (error) { handleError(error); } + finally { button.disabled = false; } }); elements.refreshButton.addEventListener("click", () => refreshAll().catch(handleError)); @@ -137,23 +140,28 @@ async function logout() { } async function refreshAll() { + const revision = (state.refreshVersion || 0) + 1; + state.refreshVersion = revision; state.query = elements.globalSearch.value.trim(); - const query = state.query ? `?q=${encodeURIComponent(state.query)}` : ""; const [status, compatibility, users, chats, attachments, moderation] = await Promise.all([ fetchJson("/admin/api/status"), fetchJson("/admin/api/app-updates/compatibility-report"), - fetchJson(`/admin/api/users${query}`), - fetchJson(`/admin/api/chats${query}`), - fetchJson(`/admin/api/attachments${query}`), + fetchJson(consolePageUrl("users")), + fetchJson(consolePageUrl("chats")), + fetchJson(consolePageUrl("media")), fetchJson("/admin/api/moderation-log?limit=150"), ]); + if (revision !== state.refreshVersion) return; state.status = status; state.compatibility = compatibility; - state.users = users || []; - state.chats = chats || []; - state.attachments = attachments || []; + state.users = users.items || []; + state.chats = chats.items || []; + state.attachments = attachments.items || []; + state.pages.users = users; + state.pages.chats = chats; + state.pages.media = attachments; state.moderation = moderation || []; if (state.selectedChatId) { @@ -162,6 +170,8 @@ async function refreshAll() { elements.lastUpdated.textContent = `обновлено ${new Date().toLocaleTimeString("ru-RU")}`; renderAll(); + renderConsolePagers(); + await loadConsoleSection(); } async function loadChatDetails(chatId, rerender = true) { @@ -234,9 +244,9 @@ function renderStatus() { metric("Чаты", status.totalChats, `${status.groupChats} групп, ${status.channelChats} каналов`), metric("Сообщения", status.totalMessages, `${status.pinnedMessages} закреплено, ${status.expiringMessages} с TTL`), metric("Медиа", status.totalAttachments, formatBytes(status.attachmentBytes)), - metric("Protected", status.protectedChats, `${status.protectedMessages} защищенных сообщений`), - metric("Stories", status.totalStories, `${status.activeStories} активных, ${status.protectedStories} protected`), - metric("Join requests", status.pendingJoinRequests, `${status.activeInviteLinks} активных invite links`), + metric("Защита содержимого", status.protectedChats, `${status.protectedMessages} защищенных сообщений`), + metric("Истории", status.totalStories, `${status.activeStories} активных, ${status.protectedStories} защищено`), + metric("Заявки на вход", status.pendingJoinRequests, `${status.activeInviteLinks} активных приглашений`), metric("Сессии", status.activeSessions, `${status.registeredPushDevices} push-устройств`), ].join(""); @@ -249,9 +259,9 @@ function renderStatus() { ].join(""); elements.featureMatrix.innerHTML = [ - feature("Пользователи и privacy", `${status.totalUsers} аккаунтов`, status.totalUsers > 0), + feature("Пользователи и приватность", `${status.totalUsers} аккаунтов`, status.totalUsers > 0), feature("Группы и каналы", `${status.groupChats + status.channelChats} публичных контуров`, status.groupChats + status.channelChats > 0), - feature("Protected content", `${status.protectedChats} чатов`, status.protectedChats > 0), + feature("Защита содержимого", `${status.protectedChats} чатов`, status.protectedChats > 0), feature("Топики и роли", `${status.topics} топиков`, status.topics > 0), feature("Истории", `${status.activeStories} активных`, status.totalStories > 0), feature("Боты", `${status.enabledBots}/${status.totalBots} включено`, status.totalBots > 0), @@ -287,6 +297,7 @@ function renderUsers() { + @@ -356,11 +367,11 @@ function renderChats() { ${chat.members}
${chat.messages}
-
${chat.pinnedMessages} pinned · ${chat.protectedMessages} protected
+
${chat.pinnedMessages} закреплено · ${chat.protectedMessages} защищено
- ${chat.hasProtectedContent ? badge("Protected", "ok") : badge("Обычный", "warn")} + ${chat.hasProtectedContent ? badge("Защищён", "ok") : badge("Обычный", "warn")} ${chat.pendingJoinRequests > 0 ? badge(`${chat.pendingJoinRequests} заявок`, "warn") : ""} ${chat.topicsEnabled ? badge(`${chat.topics} топиков`, "ok") : ""}
@@ -382,7 +393,7 @@ function renderChatInspector() {

Инспектор

Выберите чат

-

Здесь появятся настройки protected mode, TTL, public username, участники, права, invite links и заявки на вход.

+

Здесь появятся защита содержимого, срок жизни сообщений, публичное имя, участники, права, приглашения и заявки на вход.

`; return; @@ -400,7 +411,7 @@ function renderChatInspector() {

Настройки чата

@@ -429,7 +440,7 @@ function renderChatInspector() {
-

Invite links

+

Ссылки-приглашения

@@ -495,7 +506,7 @@ function renderChatInspector() {
${escapeHtml(message.sender)} · ${formatDate(message.sentAt)}
${message.deletedAt ? "Сообщение удалено" : escapeHtml(shorten(message.text || "медиа-сообщение", 160))}
-
${message.attachments} вложений · ${message.reactions} реакций ${message.hasProtectedContent ? "· protected" : ""}
+
${message.attachments} вложений · ${message.reactions} реакций ${message.hasProtectedContent ? "· защищено" : ""}
`).join("")}
@@ -523,14 +534,14 @@ function renderMemberPermissions(member) {
@${escapeHtml(member.username)}${member.isBot ? " · бот" : ""}
- ${permissionCheckbox("pin", "Pin", member.canPinMessages)} - ${permissionCheckbox("delete", "Delete", member.canDeleteMessages)} - ${permissionCheckbox("invite", "Invite", member.canInviteUsers)} - ${permissionCheckbox("post", "Post", member.canPostMessages)} + ${permissionCheckbox("pin", "Закрепление", member.canPinMessages)} + ${permissionCheckbox("delete", "Удаление", member.canDeleteMessages)} + ${permissionCheckbox("invite", "Приглашения", member.canInviteUsers)} + ${permissionCheckbox("post", "Публикация", member.canPostMessages)}
@@ -555,7 +566,7 @@ function renderAttachments() {
${badge(attachment.exists ? "файл есть" : "нет файла", attachment.exists ? "ok" : "danger")} - ${attachment.isProtected ? badge("protected", "ok") : ""} + ${attachment.isProtected ? badge("Защищено", "ok") : ""}
@@ -766,7 +777,7 @@ async function handleDocumentClick(event) { createsJoinRequest: document.getElementById("newInviteRequiresApproval").checked, }), }); - showToast("Invite link создан."); + showToast("Ссылка-приглашение создана."); await refreshAll(); return; } @@ -775,7 +786,7 @@ async function handleDocumentClick(event) { state.selectedChatDetails = await fetchJson(`/admin/api/chats/${button.dataset.chatId}/invite-links/${button.dataset.inviteId}`, { method: "DELETE", }); - showToast("Invite link отозван."); + showToast("Ссылка-приглашение отозвана."); await refreshAll(); return; } @@ -806,13 +817,16 @@ async function handleDocumentClick(event) { } async function fetchJson(url, options = {}, useAuth = true) { + const headers = { Accept: "application/json", ...(options.headers || {}) }; + if (options.method && !["GET", "HEAD"].includes(options.method.toUpperCase())) { + headers["X-Ikar-CSRF"] = state.session?.csrfToken || ""; + const reason = document.getElementById("actionReason")?.value.trim(); + if (reason) headers["X-Admin-Reason"] = encodeURIComponent(reason); + } const response = await fetch(url, { - credentials: "same-origin", - headers: { - Accept: "application/json", - ...(options.headers || {}), - }, ...options, + credentials: "same-origin", + headers, }); if (response.status === 401 && useAuth) { @@ -825,7 +839,9 @@ async function fetchJson(url, options = {}, useAuth = true) { if (!response.ok) { let message = `HTTP ${response.status}`; try { - message = await response.text(); + const text = await response.text(); + try { const problem = JSON.parse(text); message = problem.error || problem.detail || problem.title || text; } + catch { message = text; } } catch { // Тело ошибки не всегда доступно. } @@ -995,15 +1011,15 @@ function formatModerationTarget(target) { 0: "Чат", 1: "Сообщение", 2: "Пользователь", - 3: "Invite link", - 4: "Join request", - 5: "Story", + 3: "Приглашение", + 4: "Заявка на вход", + 5: "История", Chat: "Чат", Message: "Сообщение", User: "Пользователь", - InviteLink: "Invite link", - JoinRequest: "Join request", - Story: "Story", + InviteLink: "Приглашение", + JoinRequest: "Заявка на вход", + Story: "История", }; return map[target] || String(target || "—"); } diff --git a/ikar_server/src/Ikar.Server/wwwroot/admin/console.js b/ikar_server/src/Ikar.Server/wwwroot/admin/console.js new file mode 100644 index 0000000..42c54ab --- /dev/null +++ b/ikar_server/src/Ikar.Server/wwwroot/admin/console.js @@ -0,0 +1,233 @@ +state.pages = { users: { page: 1 }, chats: { page: 1 }, media: { page: 1 }, reports: { page: 1 }, bots: { page: 1 }, audit: { page: 1 } }; +state.console = {}; +Object.assign(sectionMeta, { + reports: ["Модерация", "Жалобы и решения"], bots: ["Интеграции", "Боты и мини-приложения"], + audit: ["Контроль", "Журнал администратора"], security: ["Безопасность", "Доступ и сессии"], + operations: ["Обслуживание", "Состояние и очереди"], updates: ["Обновления", "Выпуски и версии клиентов"] +}); +const dialog = document.getElementById("consoleDialog"); +let refreshInterval; +let sectionRequest = 0; +const originalSetSection = setSection; +setSection = function(section) { originalSetSection(section); loadConsoleSection().catch(handleError); }; +const originalClick = handleDocumentClick; +handleDocumentClick = async function(event) { + const button = event.target.closest("button[data-action]"); + if (!button) { await originalClick(event); return; } + if (button.dataset.action.startsWith("console-")) return; + button.disabled = true; + try { await originalClick(event); } finally { button.disabled = false; } +}; + +document.addEventListener("DOMContentLoaded", () => { + elements.usersCount.insertAdjacentHTML("beforebegin", ''); + for (const [section, table] of [["users", elements.usersTable], ["chats", elements.chatsTable], ["media", elements.attachmentsTable]]) { + const pager = document.createElement("div"); pager.id = `${section}Pager`; pager.className = "pager"; + table.closest(".table-wrap").after(pager); + } + for (const [id, section] of [["userStatus", "users"], ["chatTypeFilter", "chats"], ["chatAttentionFilter", "chats"], ["reportStatus", "reports"]]) + document.getElementById(id).addEventListener("change", () => { state.pages[section].page = 1; refreshAll().catch(handleError); }); + elements.globalSearch.addEventListener("input", () => Object.values(state.pages).forEach(value => value.page = 1)); + document.getElementById("autoRefresh").addEventListener("change", event => { + clearInterval(refreshInterval); + if (event.target.checked) refreshInterval = setInterval(() => { + if (state.session?.authenticated && !dialog.open && !document.hidden) refreshAll().catch(handleError); + }, 30000); + }); + document.addEventListener("click", consoleClick); + document.addEventListener("submit", consoleSubmit); +}); + +function consolePageUrl(section) { + const route = { users: "users/list", chats: "chats/list", media: "attachments/list" }[section] || section; + const params = new URLSearchParams({ page: state.pages[section]?.page || 1, size: 25, q: state.query || "" }); + if (section === "users") params.set("status", document.getElementById("userStatus")?.value || "all"); + if (section === "chats") params.set("type", elements.chatTypeFilter.value); + if (section === "chats") params.set("attention", elements.chatAttentionFilter.value); + if (section === "reports") params.set("status", document.getElementById("reportStatus").value); + return `/admin/api/${route}?${params}`; +} + +function renderPager(section) { + const page = state.pages[section]; const root = document.getElementById(`${section}Pager`); + if (!root || !page || page.total == null) return; + const last = Math.max(1, Math.ceil(page.total / page.pageSize)); + root.innerHTML = `${page.total ? (page.page - 1) * page.pageSize + 1 : 0}–${Math.min(page.total, page.page * page.pageSize)} из ${page.total} +
+ Страница ${page.page} / ${last}
`; +} + +function renderConsolePagers() { + ["users", "chats", "media"].forEach(renderPager); + elements.usersCount.textContent = `${state.pages.users.total} пользователей`; + elements.chatsCount.textContent = `${state.pages.chats.total} чатов`; + elements.attachmentsCount.textContent = `${state.pages.media.total} вложений`; +} + +function table(headers, rows, empty = "Записей нет") { + return `
${headers.map(x => ``).join("")}${rows.length ? rows.join("") : emptyRow(empty, headers.length)}
${escapeHtml(x)}
`; +} +function action(name, label, id, danger = false) { + return ``; +} +function statusLabel(status) { return { open: "Новая", resolved: "Подтверждена", dismissed: "Отклонена" }[status] || status; } +function reasonLabel(report) { + try { return { 1: "Спам", 2: "Оскорбления", 3: "Мошенничество", 4: "Другое" }[JSON.parse(report.payloadJson).reasonId] || "Другое"; } + catch { return "Другое"; } +} +function modal(title, content) { + document.getElementById("dialogTitle").textContent = title; + document.getElementById("dialogContent").innerHTML = content; + if (!dialog.open) dialog.showModal(); +} + +async function loadConsoleSection() { + if (!state.session?.authenticated) return; + const request = ++sectionRequest; const section = state.activeSection; + if (["reports", "bots", "audit"].includes(section)) { + const result = await fetchJson(consolePageUrl(section)); + if (request !== sectionRequest) return; + state.pages[section] = result; state.console[section] = result.items; + if (section === "reports") renderReports(result.items); + if (section === "bots") renderBots(result.items); + if (section === "audit") renderAudit(result.items); + renderPager(section); + } else if (section === "security") { + const result = await fetchJson("/admin/api/admin-sessions"); + if (request !== sectionRequest) return; + document.getElementById("securityContent").innerHTML = table(["Администратор", "IP", "Вход", "Истекает", ""], result.map(x => `${escapeHtml(x.username)} ${x.current ? badge("Эта сессия", "ok") : ""}${escapeHtml(x.remoteIp || "—")}${formatDate(x.createdUnixMs)}${formatDate(x.expiresUnixMs)}${action("revoke-admin", "Завершить", x.id, true)}`)); + } else if (section === "updates") { + const result = await fetchJson("/admin/api/releases"); + if (request !== sectionRequest) return; + const release = result.release; + document.getElementById("releasesContent").innerHTML = `

Опубликованный выпуск Android

${release ? `
${metric("Версия", `${release.versionName} (${release.versionCode})`, "")}${metric("Размер", formatBytes(release.packageSizeBytes), "")}

${escapeHtml(release.notes || "")}

SHA-256: ${escapeHtml(release.sha256 || "")}

` : '

Выпуск не опубликован.

'}Открыть ИКАР в Аргусе

Сохранённые APK

${table(["Файл", "Размер", "Дата"], result.packages.map(x => `${escapeHtml(x.name)}${formatBytes(x.bytes)}${formatDate(x.createdAt)}`))}
`; + } else if (section === "operations" || section === "overview") { + const result = await fetchJson("/admin/api/operations"); + if (request !== sectionRequest) return; + document.getElementById("reportBadge").textContent = result.openReports || ""; + if (section === "operations") { + const queue = await fetchJson("/admin/api/push-queue?size=25"); + if (request !== sectionRequest) return; + document.getElementById("operationsDetails").innerHTML = `

Состояние сервера

${metric("Свободно на диске", formatBytes(result.diskFreeBytes), `Всего ${formatBytes(result.diskTotalBytes)}`)}${metric("Память процесса", formatBytes(result.processMemoryBytes), "")}${metric("Отложенные сообщения", result.scheduledMessages, "")}${metric("Истёкшие сообщения", result.expiredMessages, "")}${metric("Push в очереди", result.queuedPush, `С ошибками: ${result.failedPush}`)}${metric("Открытые жалобы", result.openReports, "")}

Резервные копии

${table(["Архив", "Размер", "Создан"], result.backups.map(x => `${escapeHtml(x.name)}${formatBytes(x.bytes)}${formatDate(x.createdAt)}`), "Резервные копии не найдены — проверьте настройку сервера.")}

Очередь push-уведомлений

${table(["ID", "Попытки", "Следующая попытка", "Ошибка", "Действия"], queue.items.map(x => `${x.id}${x.attemptCount}${formatDate(x.nextAttemptAt)}${escapeHtml(x.lastError || "—")}${action("retry-push", "Повторить", x.id)} ${action("remove-push", "Удалить задачу", x.id, true)}`))}

Показаны первые 25 задач. Удаление задачи не удаляет сообщение.

`; + } + } +} + +function renderReports(items) { + document.getElementById("reportsContent").innerHTML = table(["№", "Отправитель", "Причина", "Статус", "Дата", "Решение", ""], items.map(x => `${x.id}${escapeHtml(x.reporterName)}${escapeHtml(reasonLabel(x))}${badge(statusLabel(x.status), x.status === "open" ? "warn" : "ok")}${formatDate(x.createdUnixMs)}${escapeHtml(x.resolution || "—")}${action("report", "Рассмотреть", x.id)}`)); +} +function renderBots(items) { + document.getElementById("botsContent").innerHTML = table(["Бот", "Владелец", "Состояние", "Мини-приложение", "Команды", ""], items.map(x => `${escapeHtml(x.name)}
@${escapeHtml(x.username)}
${escapeHtml(x.owner)}${badge(x.isEnabled ? "Включён" : "Отключён", x.isEnabled ? "ok" : "warn")}${escapeHtml(x.webAppUrl || "—")}${x.commands.length}${action("bot", "Настроить", x.id)}`)); +} +function renderAudit(items) { + document.getElementById("auditContent").innerHTML = table(["Время", "Администратор", "Действие", "Объект", "Результат", "Комментарий", "IP"], items.map(x => `${formatDate(x.createdUnixMs)}${escapeHtml(x.actor)}${escapeHtml(x.action)}${escapeHtml(x.target)}${badge(x.statusCode, x.statusCode < 400 ? "ok" : "warn")}${escapeHtml(x.reason || "—")}${escapeHtml(x.remoteIp || "—")}`)); +} + +async function showUser(id) { + const result = await fetchJson(`/admin/api/users/${id}/details`); const user = result.user; + modal(`${user.displayName} · @${user.username}`, `
${badge(user.isBlocked ? "Заблокирован" : "Активен", user.isBlocked ? "warn" : "ok")} ${badge(result.online ? "Онлайн" : result.connected ? "Подключён, скрыт" : "Не в сети", "neutral")}

Телефон: ${escapeHtml(user.phoneNumber || "—")}

Создан: ${formatDate(user.createdAt)}

Сообщений: ${user.messages} · Чатов: ${user.chats}

${escapeHtml(user.id)}

+
+

Сессии пользователя

${action("revoke-all", "Завершить все", id, true)}
${table(["Платформа", "Версия", "Вход", "Активность", ""], result.sessions.map(x => `${escapeHtml(x.clientPlatform || "Не определена")}${escapeHtml(formatVersion(x.clientVersionCode, x.clientVersionName))}${formatDate(x.createdAt)}${formatDate(x.lastSeenAt)}`))} +

Устройства уведомлений

${table(["Устройство", "Платформа", "Уведомления", ""], result.devices.map(x => `${escapeHtml(x.deviceName || "Без имени")}${escapeHtml(x.clientPlatform || String(x.platform))}${x.notificationsEnabled ? "Включены" : "Отключены"}`))} +

Чаты пользователя

${table(["Название", "Тип", ""], result.chats.map(x => `${escapeHtml(x.title || "Личный чат")}${escapeHtml(formatChatType(x.type))}${action("chat", "Открыть", x.chatId)}`))}`); +} + +async function showReport(id) { + const result = await fetchJson(`/admin/api/reports/${id}`); const report = result.report; + const message = result.message; + modal(`Жалоба №${id} · ${reasonLabel(report)}`, `

Отправитель: ${escapeHtml(report.reporterName)} · ${formatDate(report.createdUnixMs)}

${badge(statusLabel(report.status), "warn")}

+ ${message ? `
${escapeHtml(message.sender)}

${escapeHtml(message.text)}

${formatDate(message.sentAt)}${message.deletedAt ? " · скрыто" : ""}

` : '

Сообщение отсутствует либо жалоба относится к пользователю или чату.

'} +
${report.targetKind === "user" && report.targetId ? action("user", "Карточка пользователя", report.targetId) : ""}${report.chatId ? action("chat", "Открыть чат", report.chatId) : ""}
+
Данные жалобы
${escapeHtml(report.payloadJson)}
+
`); +} + +function showBot(id) { + const bot = state.console.bots.find(x => x.id === id); if (!bot) return; + modal(`Бот @${bot.username}`, `

Владелец: ${escapeHtml(bot.owner)}

Команды

${table(["Команда", "Описание"], bot.commands.map(x => `${escapeHtml(x.command)}${escapeHtml(x.description)}`))}

API-токен

Замена сразу отключает прежний токен. Новый токен показывается один раз; передайте его владельцу.

${action("rotate-bot", "Заменить токен", id, true)}
`); +} + +async function showMessages(id, page = 1, q = "") { + const result = await fetchJson(`/admin/api/chats/${id}/messages?${new URLSearchParams({page, size: 25, q})}`); + modal("Сообщения чата", `
${table(["Отправитель", "Сообщение", "Дата", ""], result.items.map(x => `${escapeHtml(x.sender)}${escapeHtml(x.text)}${x.deletedAt ? '
Скрыто
' : ""}${formatDate(x.sentAt)}`))}
${result.total} сообщений
${page > 1 ? `` : ""}Страница ${page}${page * result.pageSize < result.total ? `` : ""}
`); +} + +async function consoleClick(event) { + const button = event.target.closest("button[data-action^='console-']"); if (!button || button.disabled) return; + const name = button.dataset.action.slice(8), id = button.dataset.id; + button.disabled = true; + try { + if (name === "close") { dialog.close(); return; } + if (name === "page") { state.pages[button.dataset.section].page = Number(button.dataset.page); await refreshAll(); return; } + if (name === "user") { await showUser(id); return; } + if (name === "report") { await showReport(id); return; } + if (name === "bot") { showBot(id); return; } + if (name === "chat") { dialog.close(); setSection("chats"); await loadChatDetails(id); return; } + if (name === "messages" || name === "message-page") { await showMessages(id, Number(button.dataset.page || 1), button.dataset.query || ""); return; } + if (name === "export-audit") { exportAudit(); return; } + if (name === "revoke-all" || name === "revoke-session") { + if (!confirm(name === "revoke-all" ? "Завершить все сессии пользователя? Ему потребуется снова войти." : "Завершить эту сессию?")) return; + await fetchJson(name === "revoke-all" ? `/admin/api/users/${id}/sessions/revoke` : `/admin/api/users/${id}/sessions/${button.dataset.session}`, { method: name === "revoke-all" ? "POST" : "DELETE" }); + await showUser(id); showToast("Сессия завершена."); return; + } + if (name === "remove-device") { + if (!confirm("Убрать регистрацию уведомлений этого устройства?")) return; + await fetchJson(`/admin/api/users/${id}/devices/${button.dataset.device}`, { method: "DELETE" }); await showUser(id); return; + } + if (name === "rotate-bot") { + if (!confirm("Заменить API-токен? Прежний перестанет работать сразу.")) return; + const result = await fetchJson(`/admin/api/bots/${id}/rotate-token`, { method: "POST" }); + document.getElementById("botTokenResult").innerHTML = ``; + return; + } + if (name === "message") { + if (!confirm(button.dataset.mode === "hide" ? "Скрыть сообщение у всех участников? Его можно будет восстановить." : "Восстановить сообщение?")) return; + await fetchJson(`/admin/api/messages/${id}/${button.dataset.mode}`, { method: "POST" }); await showMessages(button.dataset.chat); return; + } + if (name === "revoke-admin") { + if (!confirm("Завершить административную сессию?")) return; + await fetchJson(`/admin/api/admin-sessions/${id}`, { method: "DELETE" }); await loadConsoleSection(); return; + } + if (name === "retry-push" || name === "remove-push") { + if (name === "remove-push" && !confirm("Удалить задачу доставки уведомления? Сообщение останется в чате.")) return; + await fetchJson(`/admin/api/push-queue/${id}${name === "retry-push" ? "/retry" : ""}`, { method: name === "retry-push" ? "POST" : "DELETE" }); await loadConsoleSection(); + } + } catch (error) { handleError(error); } finally { button.disabled = false; } +} + +async function consoleSubmit(event) { + const form = event.target.closest("form[data-console-form]"); if (!form) return; + event.preventDefault(); const values = Object.fromEntries(new FormData(form)); const id = form.dataset.id; + const button = form.querySelector("button[type='submit'], button:not([type])"); + if (button.disabled) return; button.disabled = true; + try { + if (form.dataset.consoleForm === "messages") { await showMessages(id, 1, values.q); return; } + if (form.dataset.consoleForm === "user") { + await fetchJson(`/admin/api/users/${id}/profile`, {method: "PUT", headers: {"Content-Type": "application/json"}, body: JSON.stringify(values)}); + await showUser(id); + } + if (form.dataset.consoleForm === "report") { + if (values.action !== "none" && !confirm("Применить выбранную санкцию и сохранить решение?")) return; + await fetchJson(`/admin/api/reports/${id}/resolve`, {method: "POST", headers: {"Content-Type": "application/json"}, body: JSON.stringify(values)}); + dialog.close(); await loadConsoleSection(); + } + if (form.dataset.consoleForm === "bot") { + await fetchJson(`/admin/api/bots/${id}`, {method: "PUT", headers: {"Content-Type": "application/json"}, body: JSON.stringify({...values, enabled: form.elements.enabled.checked})}); + dialog.close(); await loadConsoleSection(); + } + showToast("Изменения сохранены."); + } catch (error) { handleError(error); } finally { button.disabled = false; } +} + +function exportAudit() { + const cells = value => '"' + String(value ?? "").replace(/^[\s]*[=+\-@]/, match => "'" + match).replaceAll('"', '""') + '"'; + const rows = [["Дата", "Администратор", "Действие", "Объект", "Код", "Комментарий", "IP"], ...(state.console.audit || []).map(x => [new Date(x.createdUnixMs).toISOString(), x.actor, x.action, x.target, x.statusCode, x.reason, x.remoteIp])]; + const url = URL.createObjectURL(new Blob(["\uFEFF", rows.map(row => row.map(cells).join(";")).join("\r\n")], {type: "text/csv;charset=utf-8"})); + const link = document.createElement("a"); link.href = url; link.download = "ikar-admin-audit.csv"; link.click(); URL.revokeObjectURL(url); +} + +const originalInspector = renderChatInspector; +renderChatInspector = function() { + originalInspector(); + if (state.selectedChatId) elements.chatInspector.insertAdjacentHTML("afterbegin", `
${action("messages", "Все сообщения и поиск", state.selectedChatId)}
`); +}; diff --git a/ikar_server/src/Ikar.Server/wwwroot/admin/ikar-mark.png b/ikar_server/src/Ikar.Server/wwwroot/admin/ikar-mark.png new file mode 100644 index 0000000..7d5d84f Binary files /dev/null and b/ikar_server/src/Ikar.Server/wwwroot/admin/ikar-mark.png differ diff --git a/ikar_server/src/Ikar.Server/wwwroot/admin/index.html b/ikar_server/src/Ikar.Server/wwwroot/admin/index.html index b4fc34c..729e234 100644 --- a/ikar_server/src/Ikar.Server/wwwroot/admin/index.html +++ b/ikar_server/src/Ikar.Server/wwwroot/admin/index.html @@ -33,7 +33,7 @@