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
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:
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user