Add full IKAR administration console with secure session controls
Tests / test (push) Canceled after 0s
Native crypto / native-crypto (push) Canceled after 0s
Release (main) / android (oneme) (push) Canceled after 0s
Release (main) / android (qlyra) (push) Canceled after 0s
Release (main) / windows (push) Canceled after 0s
Release (main) / linux (push) Canceled after 0s
Release (main) / macos (push) Canceled after 0s
Release (main) / ios (push) Canceled after 0s
Release (main) / release (push) Canceled after 0s

This commit is contained in:
sevenhill
2026-10-07 23:42:02 +03:00
parent 2fcd23a4cf
commit b4c27da560
29 changed files with 1762 additions and 88 deletions
+66
View File
@@ -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`: реквизиты читаются внутри сервера, проверяется доступ,
защита изменений, резервные копии и целостность базы. В отчёт секреты не входят.
@@ -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<IkarDbContext>(); 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<JsonElement>("/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<JsonElement>()).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<IkarDbContext>();
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<TokenService>().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<SessionConnectionRegistry>().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<IkarDbContext>();
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<IkarDbContext>(); 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<IkarDbContext>();
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<IkarDbContext>(); 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<JsonElement>();
var token = rotated.GetProperty("token").GetString()!;
using var check = host.Services.CreateScope(); var dbCheck = check.ServiceProvider.GetRequiredService<IkarDbContext>();
var botCheck = (await dbCheck.Bots.FindAsync(id))!; Assert.False(botCheck.IsEnabled);
Assert.Equal(check.ServiceProvider.GetRequiredService<BotTokenService>().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<IkarDbContext>(); var user = User("signalr-admin-test");
db.Users.Add(user); await db.SaveChangesAsync(); userId = user.Id;
token = (await scope.ServiceProvider.GetRequiredService<TokenService>().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<string?>(token);
}).Build();
var closed = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
connection.Closed += _ => { closed.TrySetResult(); return Task.CompletedTask; };
await connection.StartAsync();
Assert.NotEmpty(host.Services.GetRequiredService<SessionConnectionRegistry>().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<SessionConnectionRegistry>().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<IkarDbContext>(); 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<IkarDbContext>();
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<IkarDbContext>();
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<DatabaseSchemaBootstrapper>();
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<JsonElement> JsonAsync(HttpClient client, string url) { var response = await client.GetAsync(url); response.EnsureSuccessStatusCode(); return await response.Content.ReadFromJsonAsync<JsonElement>(); }
}
internal sealed class AdminTestHost(bool production = false) : WebApplicationFactory<Program>
{
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<Microsoft.AspNetCore.Hosting.IStartupFilter>(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<JsonElement>("/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<JsonElement>("/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<Microsoft.AspNetCore.Builder.IApplicationBuilder> Configure(Action<Microsoft.AspNetCore.Builder.IApplicationBuilder> next) => app => {
app.Use((context, continuation) => { context.Connection.RemoteIpAddress = address; return continuation(); }); next(app);
};
}
}
@@ -8,6 +8,8 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.9" />
<PackageReference Include="Microsoft.AspNetCore.SignalR.Client" Version="10.0.9" />
<PackageReference Include="Microsoft.Data.Sqlite" Version="10.0.4" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
<PackageReference Include="xunit" Version="2.9.3" />
@@ -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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> UserDetails(Guid id, CancellationToken ct) =>
await console.UserDetailsAsync(id, ct) is { } result ? Ok(result) : NotFound();
[HttpPut("users/{id:guid}/profile")]
public async Task<IActionResult> EditUser(Guid id, EditUserRequest request, CancellationToken ct) =>
await console.EditUserAsync(id, request, ct) ? NoContent() : NotFound();
[HttpPost("users/{id:guid}/sessions/revoke")]
public async Task<IActionResult> RevokeUserSessions(Guid id, CancellationToken ct) => Ok(new { revoked = await console.RevokeSessionsAsync(id, null, ct) });
[HttpDelete("users/{id:guid}/sessions/{sessionId:guid}")]
public async Task<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> ReportDetails(long id, CancellationToken ct) =>
await console.ReportDetailsAsync(id, ct) is { } result ? Ok(result) : NotFound();
[HttpPost("reports/{id:long}/resolve")]
public async Task<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> EditBot(Guid id, EditBotRequest request, CancellationToken ct) => await console.EditBotAsync(id, request, ct) ? NoContent() : NotFound();
[HttpPost("bots/{id:guid}/rotate-token")]
public async Task<IActionResult> RotateBotToken(Guid id, CancellationToken ct) => await console.RotateBotTokenAsync(id, ct) is { } token ? Ok(new { token }) : NotFound();
[HttpGet("operations")]
public async Task<IActionResult> Operations(CancellationToken ct) => Ok(await console.OperationsAsync(ct));
[HttpGet("releases")]
public async Task<IActionResult> Releases(CancellationToken ct) => Ok(await console.ReleasesAsync(ct));
[HttpGet("push-queue")]
public async Task<IActionResult> 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<IActionResult> 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<IActionResult> RemovePush(long id, CancellationToken ct) => await dbContext.PushDispatchOutbox.Where(x => x.Id == id).ExecuteDeleteAsync(ct) > 0 ? NoContent() : NotFound();
[HttpGet("audit")]
public async Task<IActionResult> 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<IActionResult> 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<IActionResult> RevokeAdminSession(Guid id, CancellationToken ct) => await dbContext.AdminLoginSessions.Where(x => x.Id == id).ExecuteDeleteAsync(ct) > 0 ? NoContent() : NotFound();
}
@@ -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> adminOptions,
AdminService adminService,
IkarDbContext dbContext) : ControllerBase
IkarDbContext dbContext,
IAntiforgery antiforgery,
AdminConsoleService console) : ControllerBase
{
[HttpGet("session")]
[AllowAnonymous]
public async Task<ActionResult<AdminSessionStateDto>> 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<Claim>
{
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<IActionResult> 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")]
@@ -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<IActionResult> 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<object> 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<Guid> userIds,
int opcode,
@@ -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; }
}
@@ -47,9 +47,25 @@ public sealed class IkarDbContext(DbContextOptions<IkarDbContext> options) : DbC
public DbSet<QlyraPollVote> QlyraPollVotes => Set<QlyraPollVote>();
public DbSet<UserTwoFactorSettings> UserTwoFactorSettings => Set<UserTwoFactorSettings>();
public DbSet<UserContact> UserContacts => Set<UserContact>();
public DbSet<AdminAuditEntry> AdminAuditEntries => Set<AdminAuditEntry>();
public DbSet<AdminLoginSession> AdminLoginSessions => Set<AdminLoginSession>();
public DbSet<ModerationCase> ModerationCases => Set<ModerationCase>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<AdminAuditEntry>(entity => {
entity.HasKey(x => x.Id);
entity.HasIndex(x => x.CreatedUnixMs);
});
modelBuilder.Entity<AdminLoginSession>(entity => {
entity.HasKey(x => x.Id);
entity.HasIndex(x => x.ExpiresUnixMs);
});
modelBuilder.Entity<ModerationCase>(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);
@@ -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<MessengerHub> hub, SessionConnectionRegistry connections, IWebHostEnvironment environment,
IConfiguration configuration, PresenceTracker presence, AndroidAppUpdateService updates,
Ikar.Server.Controllers.QlyraCompatibilityController compatibility)
{
public async Task<object?> 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<bool> 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<int> 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<AdminPage<ModerationCase>> 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<object?> 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<ModerationCase?> 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<AdminPage<object>> 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<object>().ToList(), total, page, size);
}
public async Task<bool> 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<AdminPage<object>> 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<object>().ToList(), total, page, size);
}
public async Task<bool> 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<string?> 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<object> 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<object> 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<AdminPage<T>> PageAsync<T>(IQueryable<T> 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");
@@ -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,
@@ -0,0 +1,50 @@
using Ikar.Server.Data.Entities;
using Ikar.Shared;
using Microsoft.EntityFrameworkCore;
namespace Ikar.Server.Infrastructure.Admin;
public sealed record AdminPage<T>(IReadOnlyList<T> Items, int Total, int Page, int PageSize);
public sealed partial class AdminService
{
private static IQueryable<User> FilterUsers(IQueryable<User> 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<AdminPage<AdminUserDto>> 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<Chat> FilterChatAttention(IQueryable<Chat> 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<AdminPage<AdminChatDto>> 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<ChatType>(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<AdminPage<AdminAttachmentDto>> 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);
}
}
@@ -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<string, (long Window, int Count)> 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);
}
}
@@ -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<MessengerHub> hubContext)
IHubContext<MessengerHub> hubContext,
SessionConnectionRegistry? connections = null)
{
public async Task<AdminStatusDto> GetStatusAsync(CancellationToken cancellationToken)
{
@@ -134,9 +135,10 @@ public sealed class AdminService(
recentHits);
}
public async Task<IReadOnlyList<AdminUserDto>> GetUsersAsync(string? query, CancellationToken cancellationToken)
public async Task<IReadOnlyList<AdminUserDto>> 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<IReadOnlyList<AdminChatDto>> GetChatsAsync(string? query, CancellationToken cancellationToken)
public async Task<IReadOnlyList<AdminChatDto>> 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<ChatType>(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<IReadOnlyList<AdminAttachmentDto>> GetAttachmentsAsync(string? query, CancellationToken cancellationToken)
public async Task<IReadOnlyList<AdminAttachmentDto>> 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);
@@ -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<Guid, Dictionary<string, Action>> 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<HubLifetimeContext, Task> next)
{
registry.Add(context.Context.User!.GetRequiredSessionId(), context.Context.ConnectionId, context.Context.Abort);
try {
var db = context.ServiceProvider.GetRequiredService<IkarDbContext>();
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<HubLifetimeContext, Exception?, Task> next)
{
registry.Remove(context.Context.User!.GetRequiredSessionId(), context.Context.ConnectionId);
await next(context, exception);
}
public async ValueTask<object?> InvokeMethodAsync(HubInvocationContext context, Func<HubInvocationContext, ValueTask<object?>> next)
{
var db = context.ServiceProvider.GetRequiredService<IkarDbContext>();
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);
}
}
@@ -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);
+35 -2
View File
@@ -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<Ikar.Server.Controllers.QlyraCompatibilityController>();
builder.Services.AddOpenApi();
builder.Services.AddSignalR();
builder.Services.AddSignalR(options => options.AddFilter<SessionValidationHubFilter>());
builder.Services.AddSingleton<SessionConnectionRegistry>();
builder.Services.AddSingleton<SessionValidationHubFilter>();
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<ForwardedHeadersOptions>(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<JwtOptions>(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<Microsoft.Extensions.Options.IOptions<AdminOptions>>().Value;
var db = context.HttpContext.RequestServices.GetRequiredService<IkarDbContext>();
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<BotCreatorService>();
builder.Services.AddScoped<PushNotificationDispatcher>();
builder.Services.AddScoped<AndroidUpdateCompatibilityTrackingService>();
builder.Services.AddScoped<AdminService>();
builder.Services.AddScoped<AdminAuditFilter>();
builder.Services.AddScoped<AdminConsoleService>();
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<AndroidAppUpdateService>();
builder.Services.AddHostedService<AndroidAppUpdateMonitorService>();
builder.Services.AddSingleton<PushDispatchQueue>();
@@ -255,6 +285,7 @@ app.Use(async (context, next) =>
await next();
});
app.UseForwardedHeaders();
app.UseMiddleware<AdminGuardMiddleware>();
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 { }
@@ -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; } }
@@ -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() {
<button class="button small danger" type="button" data-action="delete-user" data-user-id="${escapeAttribute(user.id)}" data-user-name="${escapeAttribute(user.username)}">
Удалить
</button>
<button class="button small secondary" type="button" data-action="console-user" data-id="${escapeAttribute(user.id)}">Карточка</button>
</div>
</td>
</tr>
@@ -356,11 +367,11 @@ function renderChats() {
<td>${chat.members}</td>
<td>
<div>${chat.messages}</div>
<div class="cell-secondary">${chat.pinnedMessages} pinned · ${chat.protectedMessages} protected</div>
<div class="cell-secondary">${chat.pinnedMessages} закреплено · ${chat.protectedMessages} защищено</div>
</td>
<td>
<div class="action-row">
${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") : ""}
</div>
@@ -382,7 +393,7 @@ function renderChatInspector() {
<div class="empty-state">
<p class="eyebrow">Инспектор</p>
<h2>Выберите чат</h2>
<p class="muted">Здесь появятся настройки protected mode, TTL, public username, участники, права, invite links и заявки на вход.</p>
<p class="muted">Здесь появятся защита содержимого, срок жизни сообщений, публичное имя, участники, права, приглашения и заявки на вход.</p>
</div>
`;
return;
@@ -400,7 +411,7 @@ function renderChatInspector() {
<h3>Настройки чата</h3>
<div class="inspector-grid">
<label class="field">
<span>Public username</span>
<span>Публичное имя</span>
<input id="chatPublicUsername" value="${escapeAttribute(chat.publicUsername || "")}" placeholder="channel_name">
</label>
<label class="field">
@@ -413,7 +424,7 @@ function renderChatInspector() {
</label>
<label class="check-line">
<input id="chatProtected" type="checkbox" ${chat.hasProtectedContent ? "checked" : ""}>
<span>Protected content</span>
<span>Защита содержимого</span>
</label>
</div>
<div class="action-row" style="margin-top: 12px">
@@ -429,7 +440,7 @@ function renderChatInspector() {
</div>
<div class="inspector-block">
<h3>Invite links</h3>
<h3>Ссылки-приглашения</h3>
<div class="inspector-grid">
<label class="field">
<span>Название ссылки</span>
@@ -437,7 +448,7 @@ function renderChatInspector() {
</label>
<label class="check-line">
<input id="newInviteRequiresApproval" type="checkbox">
<span>Требовать approval</span>
<span>Одобрять заявки на вход</span>
</label>
</div>
<div class="action-row" style="margin-top: 12px">
@@ -495,7 +506,7 @@ function renderChatInspector() {
<div class="mini-row">
<div class="cell-primary">${escapeHtml(message.sender)} · ${formatDate(message.sentAt)}</div>
<div class="cell-secondary">${message.deletedAt ? "Сообщение удалено" : escapeHtml(shorten(message.text || "медиа-сообщение", 160))}</div>
<div class="cell-secondary">${message.attachments} вложений · ${message.reactions} реакций ${message.hasProtectedContent ? "· protected" : ""}</div>
<div class="cell-secondary">${message.attachments} вложений · ${message.reactions} реакций ${message.hasProtectedContent ? "· защищено" : ""}</div>
</div>
`).join("")}
</div>
@@ -523,14 +534,14 @@ function renderMemberPermissions(member) {
<div class="cell-secondary">@${escapeHtml(member.username)}${member.isBot ? " · бот" : ""}</div>
<div class="permission-controls">
<select data-field="role">
<option value="0" ${role === "Member" ? "selected" : ""}>Member</option>
<option value="1" ${role === "Admin" ? "selected" : ""}>Admin</option>
<option value="2" ${role === "Owner" ? "selected" : ""}>Owner</option>
<option value="0" ${role === "Member" ? "selected" : ""}>Участник</option>
<option value="1" ${role === "Admin" ? "selected" : ""}>Администратор</option>
<option value="2" ${role === "Owner" ? "selected" : ""}>Владелец</option>
</select>
${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)}
<button class="button small" type="button" data-action="save-member-permissions" data-chat-id="${escapeAttribute(state.selectedChatId)}" data-member-id="${escapeAttribute(member.userId)}">Сохранить</button>
</div>
</div>
@@ -555,7 +566,7 @@ function renderAttachments() {
<td>
<div class="action-row">
${badge(attachment.exists ? "файл есть" : "нет файла", attachment.exists ? "ok" : "danger")}
${attachment.isProtected ? badge("protected", "ok") : ""}
${attachment.isProtected ? badge("Защищено", "ok") : ""}
</div>
</td>
<td>
@@ -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 || "—");
}
@@ -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", '<label class="inline-filter">Статус <select id="userStatus"><option value="all">Все</option><option value="active">Активные</option><option value="blocked">Заблокированные</option><option value="people">Люди</option><option value="bots">Боты</option></select></label>');
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 = `<span>${page.total ? (page.page - 1) * page.pageSize + 1 : 0}–${Math.min(page.total, page.page * page.pageSize)} из ${page.total}</span>
<div class="action-row"><button class="button small secondary" data-action="console-page" data-section="${section}" data-page="${page.page - 1}" ${page.page <= 1 ? "disabled" : ""}>Назад</button>
<span>Страница ${page.page} / ${last}</span><button class="button small secondary" data-action="console-page" data-section="${section}" data-page="${page.page + 1}" ${page.page >= last ? "disabled" : ""}>Далее</button></div>`;
}
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 `<div class="table-wrap"><table><thead><tr>${headers.map(x => `<th>${escapeHtml(x)}</th>`).join("")}</tr></thead><tbody>${rows.length ? rows.join("") : emptyRow(empty, headers.length)}</tbody></table></div>`;
}
function action(name, label, id, danger = false) {
return `<button type="button" class="button small ${danger ? "danger" : "secondary"}" data-action="console-${name}" data-id="${escapeAttribute(id)}">${escapeHtml(label)}</button>`;
}
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 => `<tr><td>${escapeHtml(x.username)} ${x.current ? badge("Эта сессия", "ok") : ""}</td><td>${escapeHtml(x.remoteIp || "—")}</td><td>${formatDate(x.createdUnixMs)}</td><td>${formatDate(x.expiresUnixMs)}</td><td>${action("revoke-admin", "Завершить", x.id, true)}</td></tr>`));
} else if (section === "updates") {
const result = await fetchJson("/admin/api/releases");
if (request !== sectionRequest) return;
const release = result.release;
document.getElementById("releasesContent").innerHTML = `<article class="command-panel"><h2>Опубликованный выпуск Android</h2>${release ? `<div class="metric-grid compact">${metric("Версия", `${release.versionName} (${release.versionCode})`, "")}${metric("Размер", formatBytes(release.packageSizeBytes), "")}</div><p>${escapeHtml(release.notes || "")}</p><p class="mono wrap">SHA-256: ${escapeHtml(release.sha256 || "")}</p>` : '<p class="muted">Выпуск не опубликован.</p>'}<a class="button primary" href="https://argus.kusoft.xyz/#app=ikar" target="_blank" rel="noopener noreferrer">Открыть ИКАР в Аргусе</a><h3>Сохранённые APK</h3>${table(["Файл", "Размер", "Дата"], result.packages.map(x => `<tr><td>${escapeHtml(x.name)}</td><td>${formatBytes(x.bytes)}</td><td>${formatDate(x.createdAt)}</td></tr>`))}</article>`;
} 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 = `<article class="command-panel"><h2>Состояние сервера</h2><div class="metric-grid compact">${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, "")}</div><h3>Резервные копии</h3>${table(["Архив", "Размер", "Создан"], result.backups.map(x => `<tr><td>${escapeHtml(x.name)}</td><td>${formatBytes(x.bytes)}</td><td>${formatDate(x.createdAt)}</td></tr>`), "Резервные копии не найдены — проверьте настройку сервера.")}<h3>Очередь push-уведомлений</h3>${table(["ID", "Попытки", "Следующая попытка", "Ошибка", "Действия"], queue.items.map(x => `<tr><td>${x.id}</td><td>${x.attemptCount}</td><td>${formatDate(x.nextAttemptAt)}</td><td>${escapeHtml(x.lastError || "—")}</td><td>${action("retry-push", "Повторить", x.id)} ${action("remove-push", "Удалить задачу", x.id, true)}</td></tr>`))}<p class="muted">Показаны первые 25 задач. Удаление задачи не удаляет сообщение.</p></article>`;
}
}
}
function renderReports(items) {
document.getElementById("reportsContent").innerHTML = table(["№", "Отправитель", "Причина", "Статус", "Дата", "Решение", ""], items.map(x => `<tr><td>${x.id}</td><td>${escapeHtml(x.reporterName)}</td><td>${escapeHtml(reasonLabel(x))}</td><td>${badge(statusLabel(x.status), x.status === "open" ? "warn" : "ok")}</td><td>${formatDate(x.createdUnixMs)}</td><td>${escapeHtml(x.resolution || "—")}</td><td>${action("report", "Рассмотреть", x.id)}</td></tr>`));
}
function renderBots(items) {
document.getElementById("botsContent").innerHTML = table(["Бот", "Владелец", "Состояние", "Мини-приложение", "Команды", ""], items.map(x => `<tr><td><strong>${escapeHtml(x.name)}</strong><div class="cell-secondary">@${escapeHtml(x.username)}</div></td><td>${escapeHtml(x.owner)}</td><td>${badge(x.isEnabled ? "Включён" : "Отключён", x.isEnabled ? "ok" : "warn")}</td><td class="wrap">${escapeHtml(x.webAppUrl || "—")}</td><td>${x.commands.length}</td><td>${action("bot", "Настроить", x.id)}</td></tr>`));
}
function renderAudit(items) {
document.getElementById("auditContent").innerHTML = table(["Время", "Администратор", "Действие", "Объект", "Результат", "Комментарий", "IP"], items.map(x => `<tr><td>${formatDate(x.createdUnixMs)}</td><td>${escapeHtml(x.actor)}</td><td>${escapeHtml(x.action)}</td><td class="mono wrap">${escapeHtml(x.target)}</td><td>${badge(x.statusCode, x.statusCode < 400 ? "ok" : "warn")}</td><td>${escapeHtml(x.reason || "—")}</td><td>${escapeHtml(x.remoteIp || "—")}</td></tr>`));
}
async function showUser(id) {
const result = await fetchJson(`/admin/api/users/${id}/details`); const user = result.user;
modal(`${user.displayName} · @${user.username}`, `<div class="detail-grid"><div>${badge(user.isBlocked ? "Заблокирован" : "Активен", user.isBlocked ? "warn" : "ok")} ${badge(result.online ? "Онлайн" : result.connected ? "Подключён, скрыт" : "Не в сети", "neutral")}<p>Телефон: ${escapeHtml(user.phoneNumber || "—")}</p><p>Создан: ${formatDate(user.createdAt)}</p><p>Сообщений: ${user.messages} · Чатов: ${user.chats}</p><p class="mono wrap">${escapeHtml(user.id)}</p></div>
<form data-console-form="user" data-id="${escapeAttribute(id)}"><label>Имя <input name="displayName" value="${escapeAttribute(user.displayName)}" required maxlength="120"></label><label>Описание <textarea name="about" maxlength="2000">${escapeHtml(user.about || "")}</textarea></label><button class="button primary">Сохранить профиль</button></form></div>
<div class="panel-head"><h3>Сессии пользователя</h3>${action("revoke-all", "Завершить все", id, true)}</div>${table(["Платформа", "Версия", "Вход", "Активность", ""], result.sessions.map(x => `<tr><td>${escapeHtml(x.clientPlatform || "Не определена")}</td><td>${escapeHtml(formatVersion(x.clientVersionCode, x.clientVersionName))}</td><td>${formatDate(x.createdAt)}</td><td>${formatDate(x.lastSeenAt)}</td><td><button class="button small danger" data-action="console-revoke-session" data-id="${escapeAttribute(id)}" data-session="${escapeAttribute(x.id)}">Завершить</button></td></tr>`))}
<h3>Устройства уведомлений</h3>${table(["Устройство", "Платформа", "Уведомления", ""], result.devices.map(x => `<tr><td>${escapeHtml(x.deviceName || "Без имени")}</td><td>${escapeHtml(x.clientPlatform || String(x.platform))}</td><td>${x.notificationsEnabled ? "Включены" : "Отключены"}</td><td><button class="button small danger" data-action="console-remove-device" data-id="${escapeAttribute(id)}" data-device="${escapeAttribute(x.id)}">Убрать</button></td></tr>`))}
<h3>Чаты пользователя</h3>${table(["Название", "Тип", ""], result.chats.map(x => `<tr><td>${escapeHtml(x.title || "Личный чат")}</td><td>${escapeHtml(formatChatType(x.type))}</td><td>${action("chat", "Открыть", x.chatId)}</td></tr>`))}`);
}
async function showReport(id) {
const result = await fetchJson(`/admin/api/reports/${id}`); const report = result.report;
const message = result.message;
modal(`Жалоба №${id} · ${reasonLabel(report)}`, `<p>Отправитель: <strong>${escapeHtml(report.reporterName)}</strong> · ${formatDate(report.createdUnixMs)}</p><p>${badge(statusLabel(report.status), "warn")}</p>
${message ? `<blockquote><strong>${escapeHtml(message.sender)}</strong><p class="message-text">${escapeHtml(message.text)}</p><p class="muted">${formatDate(message.sentAt)}${message.deletedAt ? " · скрыто" : ""}</p></blockquote>` : '<p class="muted">Сообщение отсутствует либо жалоба относится к пользователю или чату.</p>'}
<div class="action-row">${report.targetKind === "user" && report.targetId ? action("user", "Карточка пользователя", report.targetId) : ""}${report.chatId ? action("chat", "Открыть чат", report.chatId) : ""}</div>
<details><summary>Данные жалобы</summary><pre class="wrap">${escapeHtml(report.payloadJson)}</pre></details>
<form data-console-form="report" data-id="${id}"><label>Решение <textarea name="resolution" required minlength="3" maxlength="2000">${escapeHtml(report.resolution || "")}</textarea></label><label>Статус <select name="status">${report.status === "open" ? '<option value="resolved">Подтвердить</option><option value="dismissed">Отклонить</option>' : '<option value="open">Открыть повторно</option>'}</select></label><label>Действие <select name="action"><option value="none">Без санкции</option>${report.status === "open" && report.targetId && ["user", "message"].includes(report.targetKind) ? '<option value="block-user">Заблокировать пользователя</option>' : ""}${report.status === "open" && message ? '<option value="delete-message">Скрыть сообщение</option>' : ""}</select></label><button class="button primary">Сохранить решение</button></form>`);
}
function showBot(id) {
const bot = state.console.bots.find(x => x.id === id); if (!bot) return;
modal(`Бот @${bot.username}`, `<p>Владелец: ${escapeHtml(bot.owner)}</p><form data-console-form="bot" data-id="${escapeAttribute(id)}"><label>Имя <input name="name" value="${escapeAttribute(bot.name)}" required maxlength="120"></label><label>Описание <textarea name="about" maxlength="2000">${escapeHtml(bot.about || "")}</textarea></label><label>Адрес мини-приложения <input name="webAppUrl" type="url" value="${escapeAttribute(bot.webAppUrl || "")}" placeholder="https://..."></label><label class="check-label"><input name="enabled" type="checkbox" ${bot.isEnabled ? "checked" : ""}> Бот включён</label><button class="button primary">Сохранить</button></form><h3>Команды</h3>${table(["Команда", "Описание"], bot.commands.map(x => `<tr><td>${escapeHtml(x.command)}</td><td>${escapeHtml(x.description)}</td></tr>`))}<h3>API-токен</h3><p class="muted">Замена сразу отключает прежний токен. Новый токен показывается один раз; передайте его владельцу.</p>${action("rotate-bot", "Заменить токен", id, true)}<div id="botTokenResult"></div>`);
}
async function showMessages(id, page = 1, q = "") {
const result = await fetchJson(`/admin/api/chats/${id}/messages?${new URLSearchParams({page, size: 25, q})}`);
modal("Сообщения чата", `<form data-console-form="messages" data-id="${escapeAttribute(id)}"><label>Поиск в чате <input name="q" type="search" value="${escapeAttribute(q)}"></label><button class="button secondary">Найти</button></form>${table(["Отправитель", "Сообщение", "Дата", ""], result.items.map(x => `<tr><td>${escapeHtml(x.sender)}</td><td class="message-text wrap">${escapeHtml(x.text)}${x.deletedAt ? '<div class="muted">Скрыто</div>' : ""}</td><td>${formatDate(x.sentAt)}</td><td><button class="button small ${x.deletedAt ? "secondary" : "danger"}" data-action="console-message" data-id="${escapeAttribute(x.id)}" data-chat="${escapeAttribute(id)}" data-mode="${x.deletedAt ? "restore" : "hide"}">${x.deletedAt ? "Восстановить" : "Скрыть"}</button></td></tr>`))}<div class="pager"><span>${result.total} сообщений</span><div class="action-row">${page > 1 ? `<button class="button small" data-action="console-message-page" data-id="${id}" data-page="${page-1}" data-query="${escapeAttribute(q)}">Назад</button>` : ""}<span>Страница ${page}</span>${page * result.pageSize < result.total ? `<button class="button small" data-action="console-message-page" data-id="${id}" data-page="${page+1}" data-query="${escapeAttribute(q)}">Далее</button>` : ""}</div></div>`);
}
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 = `<label>Новый токен <input readonly value="${escapeAttribute(result.token)}" aria-label="Новый API-токен"></label>`;
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", `<div class="action-row inspector-tools">${action("messages", "Все сообщения и поиск", state.selectedChatId)}</div>`);
};
Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

@@ -33,7 +33,7 @@
<div id="appView" class="app-shell hidden">
<aside class="rail">
<div class="brand">
<div class="brand-mark">ИК</div>
<img class="brand-mark" src="/admin/ikar-mark.png" alt="Икар">
<div>
<div class="brand-title">Икар</div>
<div class="brand-subtitle">управление мессенджером</div>
@@ -55,7 +55,11 @@
<button class="nav-item" type="button" data-section="users">Пользователи</button>
<button class="nav-item" type="button" data-section="chats">Чаты и каналы</button>
<button class="nav-item" type="button" data-section="media">Медиа</button>
<button class="nav-item" type="button" data-section="reports">Жалобы <span id="reportBadge"></span></button>
<button class="nav-item" type="button" data-section="bots">Боты</button>
<button class="nav-item" type="button" data-section="moderation">Модерация</button>
<button class="nav-item" type="button" data-section="audit">Журнал администратора</button>
<button class="nav-item" type="button" data-section="security">Доступ и сессии</button>
<button class="nav-item" type="button" data-section="updates">Обновления</button>
<button class="nav-item" type="button" data-section="operations">Операции</button>
</nav>
@@ -79,6 +83,24 @@
</header>
<div id="noticeBar" class="notice hidden"></div>
<div class="console-toolbar"><label>Комментарий к действиям <input id="actionReason" placeholder="Причина для журнала изменений" maxlength="500"></label><label><input id="autoRefresh" type="checkbox"> Автообновление</label></div>
<section class="section" data-panel="reports"><article class="command-panel">
<div class="panel-head"><h2>Очередь жалоб</h2><label>Статус <select id="reportStatus"><option value="open">Новые</option><option value="resolved">Подтверждённые</option><option value="dismissed">Отклонённые</option><option value="all">Все</option></select></label></div>
<div id="reportsContent"></div><div id="reportsPager" class="pager"></div>
</article></section>
<section class="section" data-panel="bots"><article class="command-panel">
<div class="panel-head"><h2>Боты и мини-приложения</h2></div><p class="muted">Боты создаются владельцами через @botfatherbot. Здесь доступны настройки, отключение и замена API-токена.</p>
<div id="botsContent"></div><div id="botsPager" class="pager"></div>
</article></section>
<section class="section" data-panel="audit"><article class="command-panel">
<div class="panel-head"><h2>Журнал действий администратора</h2><button type="button" class="button secondary" data-action="console-export-audit">Экспорт страницы CSV</button></div>
<div id="auditContent"></div><div id="auditPager" class="pager"></div>
</article></section>
<section class="section" data-panel="security"><article class="command-panel">
<h2>Сессии администратора</h2><p class="muted">Сессии действуют 12 часов. Завершённая сессия сразу теряет доступ. Смена учётных данных на сервере отзывает прежние сессии.</p>
<div id="securityContent"></div>
</article></section>
<section class="section active" data-panel="overview">
<div class="hero-grid">
@@ -187,7 +209,7 @@
<select id="chatAttentionFilter">
<option value="all">Все</option>
<option value="pending">Есть заявки</option>
<option value="protected">Protected</option>
<option value="protected">Защищённые</option>
<option value="topics">С топиками</option>
</select>
</label>
@@ -366,11 +388,11 @@
<article class="command-panel">
<div class="panel-head">
<div>
<p class="eyebrow">Maintenance</p>
<p class="eyebrow">Обслуживание</p>
<h2>Очистка данных</h2>
</div>
</div>
<p class="muted">Операции ниже меняют состояние сервера. Запускайте их осознанно: панель не маскирует destructive actions.</p>
<p class="muted">За один запуск очистка удаляет до 500 истёкших сообщений и их вложения. Действие записывается в журнал администратора.</p>
<div class="action-stack">
<button class="button danger" type="button" data-action="cleanup-expired">Очистить истекшие TTL-сообщения</button>
<button class="button secondary" type="button" data-action="refresh">Синхронизировать консоль</button>
@@ -386,21 +408,27 @@
</div>
</div>
<ul class="coverage-list">
<li>Пользователи: блокировка, разблокировка, удаление, privacy-сигналы.</li>
<li>Чаты: protected mode, TTL, public username, topics, удаление.</li>
<li>Участники: роли Owner/Admin/Member и granular permissions.</li>
<li>Invite links и join requests: создание, отзыв, approve/decline.</li>
<li>Пользователи: профили, сессии, устройства, блокировка и удаление.</li>
<li>Чаты: защита содержимого, срок жизни сообщений, публичное имя и темы.</li>
<li>Участники: владельцы, администраторы и отдельные разрешения.</li>
<li>Приглашения и заявки: создание, отзыв, одобрение и отклонение.</li>
<li>Жалобы: рассмотрение, санкции, решения и повторное открытие.</li>
<li>Боты: состояние, мини-приложения и замена API-токенов.</li>
<li>Медиа: проверка наличия файла, скачивание, удаление.</li>
<li>Модерация и обновления: аудит действий и совместимость Android-клиентов.</li>
</ul>
</article>
</div>
</section>
<section class="section" data-panel="operations"><div id="operationsDetails"></div></section>
<section class="section" data-panel="updates"><div id="releasesContent"></div></section>
</main>
</div>
<div id="toast" class="toast hidden"></div>
<dialog id="consoleDialog"><div class="dialog-heading"><h2 id="dialogTitle"></h2><button class="button ghost" data-action="console-close" type="button" aria-label="Закрыть">×</button></div><div id="dialogContent"></div></dialog>
<script src="/admin/admin.js?v=20260505-client-versions"></script>
<script src="/admin/admin.js?v=20261007-console"></script>
<script src="/admin/console.js?v=20261007-console"></script>
</body>
</html>