From ef365ce42498ca49877207f80e7231b3dbef6d23 Mon Sep 17 00:00:00 2001 From: nikostap <73756780+nikostap@users.noreply.github.com> Date: Wed, 21 Oct 2026 14:07:43 +0300 Subject: [PATCH 1/9] Start security and reliability audit remediation --- AUDIT_REMEDIATION.md | 39 ++++++++ Dockerfile | 2 +- PROJECT_MAP.md | 18 ++-- .../AccessProfilePermissionTests.cs | 89 +++++++++++++++++++ TechSupportRagBot.Tests/EmailWorkflowTests.cs | 50 +++++++++++ .../TechSupportRagBot.Tests.csproj | 2 +- .../Pages/Account/ChangePassword.cshtml.cs | 1 - .../Identity/Pages/Account/Login.cshtml.cs | 12 +-- .../Pages/Account/ResetPassword.cshtml.cs | 1 - .../Controllers/AttachmentsController.cs | 20 +---- .../Data/ApplicationDbContext.cs | 10 +++ .../20261021110000_RemoveIssuedPassword.cs | 23 +++++ .../20261021112000_AddOperatorReadReceipts.cs | 54 +++++++++++ .../20261021113000_AddQaClientApproval.cs | 22 +++++ TechSupportRagBot/Models/ApplicationUser.cs | 1 - TechSupportRagBot/Models/ChatMessage.cs | 2 + .../Models/ChatMessageOperatorRead.cs | 10 +++ TechSupportRagBot/Models/QAEntry.cs | 2 + TechSupportRagBot/Pages/Admin/Admins.cshtml | 9 +- .../Pages/Admin/Admins.cshtml.cs | 29 ++++-- TechSupportRagBot/Pages/Admin/Clients.cshtml | 7 +- .../Pages/Admin/Clients.cshtml.cs | 12 +-- TechSupportRagBot/Pages/Admin/QA.cshtml | 23 +++-- TechSupportRagBot/Pages/Admin/QA.cshtml.cs | 8 +- .../Pages/Admin/Tickets.cshtml.cs | 27 +++++- .../Pages/Admin/UserProfile.cshtml | 6 +- .../Pages/Admin/UserProfile.cshtml.cs | 9 +- .../Pages/Client/Ticket.cshtml.cs | 6 +- TechSupportRagBot/Pages/Client/Users.cshtml | 8 +- .../Pages/Client/Users.cshtml.cs | 14 +-- TechSupportRagBot/Pages/Index.cshtml.cs | 23 +++-- .../Pages/Operator/Index.cshtml.cs | 3 +- .../Pages/Operator/Ticket.cshtml.cs | 37 +++++++- TechSupportRagBot/Pages/ProfileEdit.cshtml.cs | 1 - .../Pages/Shared/_LoginPartial.cshtml | 2 +- TechSupportRagBot/Program.cs | 21 ++++- .../Services/AccessProfileService.cs | 2 +- .../Services/AccountDeactivationService.cs | 28 ++++++ .../Services/BackgroundTaskQueue.cs | 7 +- .../Services/ChatTicketAccessService.cs | 60 +++++++++++-- .../Services/DocumentTextExtractor.cs | 39 ++++++++ .../EmailNotificationBackgroundService.cs | 67 ++++++++------ TechSupportRagBot/Services/QAService.cs | 4 + TechSupportRagBot/Services/RagAuditLogger.cs | 60 ++++++++++--- .../Services/RagSearchService.cs | 8 ++ TechSupportRagBot/Services/UiText.cs | 20 ++++- .../VideoProcessingBackgroundService.cs | 38 +++++++- TechSupportRagBot/TechSupportRagBot.csproj | 16 ++-- TechSupportRagBot/wwwroot/css/dispatch.css | 4 + docker-compose.yml | 12 +-- dotnet-tools.json | 4 +- 51 files changed, 804 insertions(+), 168 deletions(-) create mode 100644 AUDIT_REMEDIATION.md create mode 100644 TechSupportRagBot/Migrations/20261021110000_RemoveIssuedPassword.cs create mode 100644 TechSupportRagBot/Migrations/20261021112000_AddOperatorReadReceipts.cs create mode 100644 TechSupportRagBot/Migrations/20261021113000_AddQaClientApproval.cs create mode 100644 TechSupportRagBot/Models/ChatMessageOperatorRead.cs create mode 100644 TechSupportRagBot/Services/AccountDeactivationService.cs diff --git a/AUDIT_REMEDIATION.md b/AUDIT_REMEDIATION.md new file mode 100644 index 0000000..842d8bd --- /dev/null +++ b/AUDIT_REMEDIATION.md @@ -0,0 +1,39 @@ +# План исправлений по аудиту 2026-10-21 + +Статус: работа начата. Порядок основан на риске для данных и доступности сайта. После каждой группы изменений проверяются сборка, тесты и соответствующие страницы; перед публикацией сверяется состояние сервера и Git. + +## 1. Доступ и сохранность данных + +- [x] Ограничить все пути чтения и изменения обращений и вложений компанией клиентского пользователя, даже если его профилю дано право `Tickets`. Добавить проверки для страниц, API и SignalR. +- [x] Сохранять историю компании при удалении её сотрудника: отключать учётную запись без удаления обращений, сообщений и вложений. +- [x] Исключить хранение и показ выданных паролей в открытом виде; обеспечить безопасный выпуск временного пароля и обязательную смену. +- [x] Сделать сброс пароля атомарным и включить ограничение неудачных входов без обхода капчи через заголовок Host. +- [x] Отделить разрешённые для клиента материалы от внутренних QA; исключить `Draft`, `NeedsReview` и `Deprecated` из ответа бота. После миграции прежние QA-записи не доступны боту, пока администратор не проверит и не разрешит каждую запись. + +## 2. Надёжность и ресурсы + +- [x] Восстанавливать очередь обработки видео после перезапуска и ограничить число ожидающих задач. +- [x] Убрать ограничение просмотра только 300 последних сообщений в обработчике уведомлений и учитывать прочтение для каждого назначенного оператора. +- [x] Ограничить объём распакованного содержимого PDF и Office до извлечения и индексации. +- [x] Перевести приложение и контейнер на .NET 10, обновить пакеты ASP.NET Core/EF, SQLite и PKCS. +- [ ] Заменить `FastText.NetWrapper` или иным проверенным способом устранить уязвимый транзитивный AutoMapper 10.1.1 без нарушения распознавания языка. +- [x] Ввести срок хранения RAG-журналов и исключить лишний клиентский текст из них. + +## 3. Интерфейс и конфигурация + +- [x] Завершить перевод статусов и служебных элементов QA через `UiText.cs`; проверить RU и EN. +- [x] Сократить прокрутку до списка QA на мобильном экране. +- [x] Ограничить доверенные прокси и сделать базовую Compose-конфигурацию безопасной при запуске без production override. + +## Проверка + +- [ ] Завершить отдельные тесты миграций, статусов QA, восстановления видео и динамического прочтения сообщений. +- [ ] Полный набор тестов, сборка, проверка уязвимых пакетов, просмотр основных страниц и мобильной версии. +- [ ] Обновить `PROJECT_MAP.md`, сверить сервер с Git, опубликовать и проверить рабочий сайт. + +## Промежуточная проверка + +- 54 теста прошли локально после перехода на .NET 10. Новые миграции видны в списке EF, но на рабочей базе ещё не применялись. +- SQL всех трёх новых миграций успешно сгенерирован для PostgreSQL. Выполнение на отдельной копии базы и проверка опубликованных страниц ещё требуются. +- Проверка NuGet больше не отмечает SQLite и PKCS. Остался AutoMapper 10.1.1, приходящий через `FastText.NetWrapper`; совместимая защищённая версия AutoMapper 15.1.1 требует лицензию и имеет несовместимые изменения API. Нужна проверенная замена библиотеки распознавания языка. +- Исходники на сервере развернуты без каталога `.git`; сравнение выбранных базовых файлов с текущим `HEAD` не обнаружило содержательных различий. Публикация требует резервной копии БД и отдельной проверки миграций и доступа к QA. diff --git a/Dockerfile b/Dockerfile index 8b759d8..649e8fc 100644 --- a/Dockerfile +++ b/Dockerfile @@ -8,7 +8,7 @@ RUN dotnet restore TechSupportRagBot/TechSupportRagBot.csproj COPY . . RUN dotnet publish TechSupportRagBot/TechSupportRagBot.csproj -c Release -o /app/publish /p:UseAppHost=false -FROM mcr.microsoft.com/dotnet/aspnet:9.0 AS runtime +FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS runtime WORKDIR /app RUN apt-get update \ diff --git a/PROJECT_MAP.md b/PROJECT_MAP.md index 8a0482b..edfa759 100644 --- a/PROJECT_MAP.md +++ b/PROJECT_MAP.md @@ -13,6 +13,7 @@ - Восстановление вида до редизайна: Git-тег `design-before-dispatch-2026-10-15` или архив `backups/design-before-dispatch-2026-10-15.zip`. Секреты `.env` в архив не включены. - `/privacy` — публичная политика конфиденциальности портала поддержки; ссылка есть в нижней части каждой страницы. Баннер cookies с равными вариантами принятия и отклонения и повторным открытием настроек реализован в `_Layout.cshtml` и `wwwroot/js/cookie-consent.js`; выбор хранится в `localStorage`. Необходимые cookies входа, безопасности и языка работают при любом выборе; необязательных аналитических cookies нет. - Видимость плиток и доступ к маршрутам определяется сохранёнными разрешениями `AccessProfileService` и методом `IndexModel.Can(...)`. Атрибуты страниц требуют только входа; повторной блокировки по технической роли Identity нет. Настроенные привилегии не перезаписываются при старте. Для существующих клиентских учётных записей прежний `Manager` переводится в `Client` для первого пользователя компании и в `ClientOperator` для остальных; прежний `Engineer` переводится в `ClientOperator`. Это отделяет права внутренних менеджеров от клиентских. Клиентский оператор по умолчанию видит чаты своей компании и может отвечать, но не управляет пользователями и не создаёт обращения. +- Право `Tickets` даёт внутреннему сотруднику доступ к общему списку, но не расширяет границы компании для клиентской учётной записи. `ChatTicketAccessService` проверяет компанию на страницах обращений, в выдаче вложений и при входе в SignalR-группу. Удаление сотрудника в `/Client/Users` и `/Admin/Admins` теперь отключает его вход через `AccountDeactivationService` и сохраняет историю обращений и сообщений. - UI-тексты RU/EN находятся в `Services/UiText.cs`. Новые подписи, сообщения и доступные имена элементов добавляются парами в этот словарь; Razor Pages и серверные сообщения читают его через `UiText.T(...)`, а скрипты чата и динамических списков получают выбранные строки через `UiText.ClientStrings(...)` из общего каркаса. Правило закреплено в `AGENTS.md`. - В верхней панели колокольчик уведомлений периодически анимируется при наличии непрочитанных сообщений; по наведению или фокусу открывается список чатов с прямыми ссылками. - Списки в QA, завершённых чатах, диагностике бота, клиентских обращениях и блоках рабочего стола используют общий шаблон «выбор группы слева — записи справа». На узком экране выбор группы становится горизонтальной прокручиваемой строкой. QA-записи, решения и фрагменты диагностики при загрузке свернуты; строки обращений на рабочем столе и в клиентском списке сразу открывают чат по нажатию. Инициализация и переключение групп — `wwwroot/js/focus-lists.js`, стили — `wwwroot/css/dispatch.css`. Содержимое записей и операции остаются в соответствующих Razor Pages; тестовые записи из дизайн-макетов на сайте не создаются. @@ -92,9 +93,10 @@ - `EmailNotificationBackgroundService` раз в минуту проверяет новые сообщения клиента, назначения операторов и обращения в статусе ожидания без назначенных сотрудников. Почтовые события и допустимые типы внутренних учётных записей задаются на `/Admin/NotificationRules` и сохраняются в `SystemSettings` по ключу `Notifications.EmailRules`. По умолчанию о чате, ожидающем оператора, сообщают менеджерам; о назначении — только назначенному сотруднику; о новом и всё ещё непрочитанном сообщении клиента — только назначенным на чат сотрудникам. Общение клиента с ботом без назначения и без статуса ожидания писем не вызывает. Сообщения сотрудников клиенту не отправляются как почтовые уведомления. Немедленная отправка новых сообщений включается переменными `Notifications__NewMessageEmailEnabled` и `Notifications__NewMessageEmailSinceUtc`: дата начала исключает рассылку по старой переписке. Через заданное на `/Admin/Settings` время уходит отдельное напоминание, только если сообщение клиента осталось непрочитанным; журнал исключает повторные письма каждого типа. Логин и временный пароль, код восстановления, ключ лицензии и напоминания об окончании лицензии имеют фиксированных личных адресатов и не рассылаются группам. - `BrowserPushService` использует ту же матрицу типов событий и получателей, что и письма: ожидание оператора, назначение, новое и задержавшееся непрочитанное сообщение. В push отображаются тема чата, компания клиента, станок и для события сообщения — его короткое начало либо указание на вложение. Подписки каждого устройства хранятся в `BrowserPushSubscriptions`, отдельный журнал `BrowserPushLogs` исключает повторы; истёкшие подписки удаляются по ответу push-службы. `wwwroot/js/push.js` проверяет, принадлежит ли существующая браузерная подписка текущему пользователю, не включает её автоматически для другого аккаунта, удаляет привязку прежнего пользователя на этом устройстве при смене аккаунта и отключает подписку перед выходом. Данные браузера обрабатывают также `/push-sw.js` и `/manifest.webmanifest`. Push не зависит от успеха SMTP; ключи VAPID берутся только из `Push__PublicKey` и `Push__PrivateKey` в закрытом `.env`, а `Push__EnabledSinceUtc` задаёт дату начала без старой рассылки. Для iOS/iPadOS пользователь добавляет сайт на экран «Домой» и включает уведомления оттуда. - Пользовательское поле `AspNetUsers.EmailNotificationsEnabled` (по умолчанию `true`) управляется из `/Profile` и отключает рабочие письма о чатах, не влияя на push, восстановление пароля, выдачу доступа или личные лицензионные письма. Миграция `20261020210000_AddEmailNotificationPreference`. -- При создании клиента на `/Admin/Clients` и пользователя компании на `/Client/Users` отправляется логин и временный пароль на указанный email. При пустом адресе отправка пропускается без ошибки; новый пользователь меняет временный пароль при первом входе. Неудача доставки записывается в серверный журнал без пароля в записи и не отменяет созданную учётную запись. +- Прочтение сообщения фиксируется отдельно для каждого оператора в `ChatMessageOperatorReads` (`20261021112000_AddOperatorReadReceipts`). При открытии чата отметка относится только к текущему сотруднику; счётчики и почтовые напоминания сверяются с его отметкой. Однократный перенос старого общего флага в записи назначенных операторов исключает массовую повторную рассылку после миграции. +- При создании клиента на `/Admin/Clients` и пользователя компании на `/Client/Users` отправляется логин и временный пароль на указанный email. При пустом адресе отправка пропускается без ошибки; новый пользователь меняет временный пароль при первом входе. Неудача доставки записывается в серверный журнал без пароля в записи и не отменяет созданную учётную запись. Временный пароль показывается создателю один раз через защищённый `TempData`; в БД он больше не хранится (`20261021110000_RemoveIssuedPassword`). Административный сброс использует атомарный `ResetPasswordAsync` и также требует смены пароля при входе. - На `/Admin/Licenses` компания выбирается при выпуске ключа; кнопка в строке отправляет его на контактный email компании, либо первому пользователю компании с почтой. Новые лицензии действуют до конца указанного дня по московскому времени. `LicenseMailService` рассылает напоминания за семь и за один день до конца срока, когда `Notifications__LicenseReminderEnabled=true`. Таблица `LicenseEmailLogs` и миграция `20261020160000_AddLicenseEmailLogs` исключают повторную отправку на каждый срок и адрес; письма предлагают обратиться к менеджеру или создать обращение. -- На главной странице ссылка «Забыли пароль?» ведёт к запросу кода. Существование учётной записи не раскрывается; после проверки кода открывается форма нового пароля. Успешная смена удаляет код и временный выданный пароль. +- На главной странице ссылка «Забыли пароль?» ведёт к запросу кода. Существование учётной записи не раскрывается; после проверки кода открывается форма нового пароля. Вход блокируется на 15 минут после пяти неудачных попыток; капча отключена только в среде `Development`. ### Обращения @@ -104,12 +106,13 @@ отображаемое имя берётся из профиля, а не из данных браузера. Текст, фото и видео отправляются без перезагрузки страницы. Новые сообщения и удаления подгружаются по идентификатору через авторизованные обработчики `Messages`/`MessageIds` - при фоновом опросе `Snapshot`; существующие элементы видео не пересоздаются. + при фоновом опросе `Snapshot`; существующие элементы видео не пересоздаются. Открытие чата и получение новых сообщений через `Messages` записывают прочтение отдельно для текущего оператора в `ChatMessageOperatorReads`; отметка другого назначенного сотрудника не подавляет его уведомления. 3. Назначения операторов хранятся в `TicketOperatorAssignments`. 4. Администратор видит обращения на `/Admin/Tickets`, сгруппированные: клиент → оборудование → карточки. 5. После POST-действий сохраняются выбранный клиент и позиция прокрутки списка обращений; карточки обращений при каждой загрузке свернуты. 6. `TicketDeletionService` удаляет тикет и связанные данные, включая RAG-записи. 7. Ошибки загрузки и обработки видео показываются в чате как общее сообщение «Ошибка отправки»; техническая причина сохраняется только в журнале и данных вложения. +8. Очередь видео ограничена 100 ожидающими заданиями. Состояние вложения хранится в БД; обработчик при запуске и во время простоя подбирает незавершённые видео, включая прерванные остановкой сервиса. ### Завершённые чаты и база знаний @@ -125,9 +128,12 @@ - Оркестрация поиска: `RagSearchService`. - Embeddings/LLM API: исторически названный `OllamaClient`. - Гибридный поиск: Qdrant dense search + PostgreSQL FTS + Reciprocal Rank Fusion. +- Перед индексацией `DocumentTextExtractor` ограничивает размер входного файла, суммарный распакованный объём Office, число страниц PDF, число строк листа и длину извлечённого текста. - Ответ пользователю: `SupportBotService`. - Отладочный endpoint: `Controllers/SearchDebugController.cs`. - Аудит RAG: `RagAuditLogger`. +- В новых RAG-журналах текст запроса, ответа и контекста заменяется отметкой об удалении; файлы старше 14 дней очищаются при следующей записи. +- Не проверенные QA (`Draft`, `NeedsReview`, `Deprecated`) исключаются из кандидатов RAG независимо от оставшихся в индексе точек. Для клиентского бота также требуется явное разрешение `QAEntry.AvailableToClients` на форме QA (`20261021113000_AddQaClientApproval`); существующие записи после миграции закрыты до индивидуальной проверки. Ручная QA-запись по умолчанию создаётся черновиком. - QA без модели и серийного номера считается глобальным. QA с моделью, но без номера применяется ко всем номерам этой модели и совместимым сериям модели. Поле номера принимает только целое число или диапазон `число-число`; одно- и двухзначные значения приводятся к трём знакам (например, `5-30` → `005-030`), любое другое значение очищается и означает «для всех номеров». ## Провайдеры моделей @@ -197,14 +203,14 @@ ### Локально -- `docker-compose.yml`: app, PostgreSQL, Qdrant, LibreTranslate и опциональный Ollama. -- Docker собирает проект SDK .NET 10, зафиксированным через `global.json`, и запускает целевое приложение `net9.0` на runtime .NET 9. +- `docker-compose.yml`: app, PostgreSQL, Qdrant, LibreTranslate и опциональный Ollama. Опубликованные порты по умолчанию слушают только `127.0.0.1`; внешний доступ к сайту организуется доверенным reverse proxy. +- Docker собирает проект SDK .NET 10, зафиксированным через `global.json`, и запускает целевое приложение `net10.0` на runtime .NET 10. Пакеты ASP.NET Core/EF обновлены до 10.0.13, Npgsql до 10.0.3, инструмент `dotnet-ef` до 10.0.13. - `.env`: реальные секреты; не коммитить и не показывать в логах. - При локальном `dotnet run` приложение загружает `.env` из каталога решения или проекта до построения конфигурации; уже заданные системные переменные имеют приоритет. Docker-адреса `postgres`, `qdrant`, `ollama` и `libretranslate` для локального процесса преобразуются в `localhost` (PostgreSQL — порт `55432`). В Docker значения по-прежнему передаются через `env_file` без преобразования. - Вложения по умолчанию хранятся локально в `/app/App_Data/Uploads` (`chat`, `qa`, `avatars`, `videos`), а не в `wwwroot`; запросы к старому `/uploads` возвращают 404. - В `/Admin/Settings` выбирается `Local` или `S3`. Production использует S3-совместимое хранилище REG.RU (`https://s3.regru.cloud`, бакет `bucket`); endpoint, регион и ключи задаются только переменными `Storage__S3__*` в `.env`. Ключи не хранятся в Git и не выводятся в логах. - Docker volume `app_uploads` подключён к `/app/App_Data/Uploads`. Стартовая миграция нормализует ключи в БД и переносит доступные legacy-файлы из `wwwroot/uploads`. -- Для проверки входа SmartCaptcha использует `SmartCaptcha__ServerKey` из `.env`; публичный ключ размещён только в форме входа. В Development и при обращении к `localhost`/loopback проверка капчи отключена. +- Для проверки входа SmartCaptcha использует `SmartCaptcha__ServerKey` из `.env`; публичный ключ размещён только в форме входа. Капча отключена лишь в Development. Доверенные адреса reverse proxy для заголовков `X-Forwarded-*` задаются в `ReverseProxy__KnownProxies`. - `.env.example`: безопасный шаблон. - Первый администратор создаётся только при явном задании `BootstrapAdmin__Login`, `BootstrapAdmin__Email` и `BootstrapAdmin__Password`; после входа требуется смена пароля. diff --git a/TechSupportRagBot.Tests/AccessProfilePermissionTests.cs b/TechSupportRagBot.Tests/AccessProfilePermissionTests.cs index 6a63caf..4d44429 100644 --- a/TechSupportRagBot.Tests/AccessProfilePermissionTests.cs +++ b/TechSupportRagBot.Tests/AccessProfilePermissionTests.cs @@ -75,6 +75,95 @@ public void ManagerPagesDoNotRequireTechnicalAdministratorRole() Assert.True(string.IsNullOrEmpty(attribute.Roles)); } + [Fact] + public async Task TicketsPermissionNeverGrantsClientAccessToAnotherCompany() + { + await using var connection = new SqliteConnection("Data Source=:memory:"); + await connection.OpenAsync(); + var services = new ServiceCollection(); + services.AddLogging(); + services.AddDbContext(options => options.UseSqlite(connection)); + services.AddIdentityCore().AddRoles() + .AddEntityFrameworkStores(); + services.AddScoped(); + services.AddScoped(); + await using var provider = services.BuildServiceProvider(); + using var scope = provider.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + await db.Database.EnsureCreatedAsync(); + var users = scope.ServiceProvider.GetRequiredService>(); + var profiles = scope.ServiceProvider.GetRequiredService(); + var ticketAccess = scope.ServiceProvider.GetRequiredService(); + + var first = new Client { Name = "First company" }; + var second = new Client { Name = "Second company" }; + var machine = new Machine { Name = "Machine", Model = "ALF" }; + db.AddRange(first, second, machine); + await db.SaveChangesAsync(); + var firstUser = new ApplicationUser { UserName = "first", ClientId = first.Id, + AccessProfile = AccessProfileService.Client }; + var secondUser = new ApplicationUser { UserName = "second", ClientId = second.Id, + AccessProfile = AccessProfileService.Client }; + var internalUser = new ApplicationUser { UserName = "internal", AccessProfile = AccessProfileService.Operator }; + Assert.True((await users.CreateAsync(firstUser)).Succeeded); + Assert.True((await users.CreateAsync(secondUser)).Succeeded); + Assert.True((await users.CreateAsync(internalUser)).Succeeded); + var firstTicket = new Ticket { Title = "First ticket", ClientUserId = firstUser.Id, MachineId = machine.Id }; + var secondTicket = new Ticket { Title = "Second ticket", ClientUserId = secondUser.Id, MachineId = machine.Id }; + db.Tickets.AddRange(firstTicket, secondTicket); + await db.SaveChangesAsync(); + + var rules = await profiles.GetProfilesAsync(); + rules.Single(x => x.Key == AccessProfileService.Client).Permissions["Tickets"] = true; + await profiles.SaveProfilesAsync(rules); + + Assert.True(await ticketAccess.CanReadAsync(Principal(firstUser), firstTicket.Id)); + Assert.False(await ticketAccess.CanReadAsync(Principal(firstUser), secondTicket.Id)); + Assert.True(await ticketAccess.CanReadAsync(Principal(internalUser), secondTicket.Id)); + var visibleIds = await (await ticketAccess.ScopeTicketsAsync(Principal(firstUser), db.Tickets)) + .Select(x => x.Id).ToListAsync(); + Assert.Equal([firstTicket.Id], visibleIds); + } + + [Fact] + public async Task DeactivatingCompanyUserPreservesTicketAndMessages() + { + await using var connection = new SqliteConnection("Data Source=:memory:"); + await connection.OpenAsync(); + var services = new ServiceCollection(); + services.AddLogging(); + services.AddDbContext(options => options.UseSqlite(connection)); + services.AddIdentityCore().AddRoles() + .AddEntityFrameworkStores(); + services.AddScoped(); + services.AddScoped(); + await using var provider = services.BuildServiceProvider(); + using var scope = provider.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + await db.Database.EnsureCreatedAsync(); + var users = scope.ServiceProvider.GetRequiredService>(); + var access = scope.ServiceProvider.GetRequiredService(); + var deactivation = scope.ServiceProvider.GetRequiredService(); + + var company = new Client { Name = "Company" }; + var machine = new Machine { Name = "Machine", Model = "ALF" }; + db.AddRange(company, machine); + await db.SaveChangesAsync(); + var employee = new ApplicationUser { UserName = "employee", ClientId = company.Id, + AccessProfile = AccessProfileService.ClientOperator }; + Assert.True((await users.CreateAsync(employee)).Succeeded); + var ticket = new Ticket { Title = "Existing ticket", ClientUserId = employee.Id, MachineId = machine.Id }; + db.Tickets.Add(ticket); + await db.SaveChangesAsync(); + db.ChatMessages.Add(new ChatMessage { TicketId = ticket.Id, AuthorUserId = employee.Id, Text = "History" }); + await db.SaveChangesAsync(); + + Assert.True((await deactivation.DeactivateAsync(employee)).Succeeded); + Assert.False(await access.IsAllowedAsync(Principal(employee), "ClientCabinet")); + Assert.Equal(1, await db.Tickets.CountAsync()); + Assert.Equal(1, await db.ChatMessages.CountAsync()); + } + private static ClaimsPrincipal Principal(ApplicationUser user) => new(new ClaimsIdentity( [new Claim(ClaimTypes.NameIdentifier, user.Id)], "test")); } diff --git a/TechSupportRagBot.Tests/EmailWorkflowTests.cs b/TechSupportRagBot.Tests/EmailWorkflowTests.cs index e786a15..41a3697 100644 --- a/TechSupportRagBot.Tests/EmailWorkflowTests.cs +++ b/TechSupportRagBot.Tests/EmailWorkflowTests.cs @@ -119,6 +119,11 @@ public async Task MessageReadBeforeDelay_DoesNotSendReminder() await fixture.Notifications.ProcessAsync(); message.IsReadByOperator = true; + fixture.Db.ChatMessageOperatorReads.Add(new ChatMessageOperatorRead + { + ChatMessageId = message.Id, + OperatorUserId = operatorUser.Id + }); message.CreatedAt = DateTime.UtcNow.AddMinutes(-10); await fixture.Db.SaveChangesAsync(); await fixture.Notifications.ProcessAsync(); @@ -128,6 +133,51 @@ public async Task MessageReadBeforeDelay_DoesNotSendReminder() x => x.NotificationType == EmailNotificationTypes.UnreadMessage); } + [Fact] + public async Task ReadingByOneOperatorDoesNotSuppressOtherOperatorsReminder() + { + await using var fixture = await Fixture.CreateAsync(); + var client = await fixture.UserAsync("client", "client@example.test", "Client"); + var first = await fixture.UserAsync("first", "first@example.test", "Operator"); + var second = await fixture.UserAsync("second", "second@example.test", "Operator"); + var machine = await fixture.MachineAsync(); + var ticket = new Ticket + { + ClientUserId = client.Id, OperatorUserId = first.Id, MachineId = machine.Id, + Title = "Shared ticket", Status = TicketStatuses.InProgress + }; + fixture.Db.Tickets.Add(ticket); + await fixture.Db.SaveChangesAsync(); + fixture.Db.TicketOperatorAssignments.AddRange( + new TicketOperatorAssignment { TicketId = ticket.Id, OperatorUserId = first.Id, + AssignedAt = DateTime.UtcNow.AddHours(-3) }, + new TicketOperatorAssignment { TicketId = ticket.Id, OperatorUserId = second.Id, + AssignedAt = DateTime.UtcNow.AddHours(-3) }); + var message = new ChatMessage + { + TicketId = ticket.Id, AuthorUserId = client.Id, Text = "Please check", + CreatedAt = DateTime.UtcNow.AddMinutes(-10), IsReadByOperator = true + }; + fixture.Db.ChatMessages.Add(message); + fixture.Db.SystemSettings.Add(new SystemSetting + { + Key = SystemSettingKeys.UnreadEmailDelayMinutes, Value = "5" + }); + await fixture.Db.SaveChangesAsync(); + fixture.Db.ChatMessageOperatorReads.Add(new ChatMessageOperatorRead + { + ChatMessageId = message.Id, OperatorUserId = first.Id + }); + await fixture.Db.SaveChangesAsync(); + + await fixture.Notifications.ProcessAsync(); + + Assert.DoesNotContain(fixture.Sender.Sent, x => x.To == first.Email + && x.Subject.Contains("Непрочитанное")); + Assert.Contains(fixture.Sender.Sent, x => x.To == second.Email + && x.Subject.Contains("Непрочитанное")); + } + [Fact] public async Task UnreadScan_ContinuesPastFirstTwoHundredMessages() { diff --git a/TechSupportRagBot.Tests/TechSupportRagBot.Tests.csproj b/TechSupportRagBot.Tests/TechSupportRagBot.Tests.csproj index bce2d1c..f62bbfa 100644 --- a/TechSupportRagBot.Tests/TechSupportRagBot.Tests.csproj +++ b/TechSupportRagBot.Tests/TechSupportRagBot.Tests.csproj @@ -1,6 +1,6 @@ - net9.0 + net10.0 enable enable false diff --git a/TechSupportRagBot/Areas/Identity/Pages/Account/ChangePassword.cshtml.cs b/TechSupportRagBot/Areas/Identity/Pages/Account/ChangePassword.cshtml.cs index 93d4619..74db83c 100644 --- a/TechSupportRagBot/Areas/Identity/Pages/Account/ChangePassword.cshtml.cs +++ b/TechSupportRagBot/Areas/Identity/Pages/Account/ChangePassword.cshtml.cs @@ -51,7 +51,6 @@ public async Task OnPostAsync() } user.MustChangePassword = false; - user.IssuedPassword = null; await _userManager.UpdateAsync(user); await _signInManager.RefreshSignInAsync(user); diff --git a/TechSupportRagBot/Areas/Identity/Pages/Account/Login.cshtml.cs b/TechSupportRagBot/Areas/Identity/Pages/Account/Login.cshtml.cs index 4e1a236..d178c42 100644 --- a/TechSupportRagBot/Areas/Identity/Pages/Account/Login.cshtml.cs +++ b/TechSupportRagBot/Areas/Identity/Pages/Account/Login.cshtml.cs @@ -54,7 +54,7 @@ public async Task OnPostAsync() Input.UserName.Trim(), Input.Password, Input.RememberMe, - lockoutOnFailure: false); + lockoutOnFailure: true); if (result.Succeeded) { @@ -73,7 +73,7 @@ public async Task OnPostAsync() private async Task IsCaptchaValidAsync() { - if (_environment.IsDevelopment() || IsLocalRequest()) return true; + if (_environment.IsDevelopment()) return true; var serverKey = _configuration["SmartCaptcha:ServerKey"]; var token = Request.Form["smart-token"].ToString(); @@ -98,14 +98,6 @@ private async Task IsCaptchaValidAsync() catch (TaskCanceledException) when (!HttpContext.RequestAborted.IsCancellationRequested) { return false; } } - private bool IsLocalRequest() - { - var host = Request.Host.Host; - return string.Equals(host, "localhost", StringComparison.OrdinalIgnoreCase) - || string.Equals(host, "127.0.0.1", StringComparison.Ordinal) - || string.Equals(host, "::1", StringComparison.Ordinal); - } - private sealed class CaptchaValidationResponse { public string? Status { get; init; } diff --git a/TechSupportRagBot/Areas/Identity/Pages/Account/ResetPassword.cshtml.cs b/TechSupportRagBot/Areas/Identity/Pages/Account/ResetPassword.cshtml.cs index 89fe5f1..22618f5 100644 --- a/TechSupportRagBot/Areas/Identity/Pages/Account/ResetPassword.cshtml.cs +++ b/TechSupportRagBot/Areas/Identity/Pages/Account/ResetPassword.cshtml.cs @@ -46,7 +46,6 @@ public async Task OnPostSaveAsync() } user.MustChangePassword = false; - user.IssuedPassword = null; var token = await users.GeneratePasswordResetTokenAsync(user); var result = await users.ResetPasswordAsync(user, token, Input.Password!); if (!result.Succeeded) diff --git a/TechSupportRagBot/Controllers/AttachmentsController.cs b/TechSupportRagBot/Controllers/AttachmentsController.cs index c14c04d..49efcbc 100644 --- a/TechSupportRagBot/Controllers/AttachmentsController.cs +++ b/TechSupportRagBot/Controllers/AttachmentsController.cs @@ -15,18 +15,18 @@ namespace TechSupportRagBot.Controllers; public sealed class AttachmentsController : Controller { private readonly ApplicationDbContext _db; - private readonly UserManager _userManager; + private readonly ChatTicketAccessService _ticketAccess; private readonly AccessProfileService _access; private readonly FileStorageService _storage; public AttachmentsController( ApplicationDbContext db, - UserManager userManager, + ChatTicketAccessService ticketAccess, AccessProfileService access, FileStorageService storage) { _db = db; - _userManager = userManager; + _ticketAccess = ticketAccess; _access = access; _storage = storage; } @@ -147,19 +147,7 @@ private async Task SendAsync(Guid publicId, bool preview, Cancell private async Task CanReadTicketAsync(Ticket ticket, CancellationToken cancellationToken) { - var user = await _userManager.GetUserAsync(User); - if (user == null) - { - return false; - } - - if (await _access.IsAllowedAsync(User, "Tickets", cancellationToken)) - { - return true; - } - - return await _access.IsAllowedAsync(User, "ClientCabinet", cancellationToken) - && user.ClientId.HasValue && ticket.ClientUser?.ClientId == user.ClientId; + return await _ticketAccess.CanReadAsync(User, ticket.Id, cancellationToken); } private async Task SendStoredAsync( diff --git a/TechSupportRagBot/Data/ApplicationDbContext.cs b/TechSupportRagBot/Data/ApplicationDbContext.cs index a66e64b..5286874 100644 --- a/TechSupportRagBot/Data/ApplicationDbContext.cs +++ b/TechSupportRagBot/Data/ApplicationDbContext.cs @@ -46,6 +46,7 @@ public ApplicationDbContext(DbContextOptions options) /// Таблица сообщений в обращениях. /// public DbSet ChatMessages => Set(); + public DbSet ChatMessageOperatorReads => Set(); /// /// Таблица вложений к сообщениям. @@ -112,6 +113,15 @@ protected override void OnModelCreating(ModelBuilder builder) // Без этого таблицы пользователей и ролей будут созданы неправильно. base.OnModelCreating(builder); + builder.Entity() + .HasKey(x => new { x.ChatMessageId, x.OperatorUserId }); + builder.Entity() + .HasOne(x => x.ChatMessage).WithMany(x => x.OperatorReads) + .HasForeignKey(x => x.ChatMessageId).OnDelete(DeleteBehavior.Cascade); + builder.Entity() + .HasOne(x => x.OperatorUser).WithMany() + .HasForeignKey(x => x.OperatorUserId).OnDelete(DeleteBehavior.Cascade); + builder.Entity() .HasOne(x => x.Client) .WithMany(x => x.Users) diff --git a/TechSupportRagBot/Migrations/20261021110000_RemoveIssuedPassword.cs b/TechSupportRagBot/Migrations/20261021110000_RemoveIssuedPassword.cs new file mode 100644 index 0000000..49d9b38 --- /dev/null +++ b/TechSupportRagBot/Migrations/20261021110000_RemoveIssuedPassword.cs @@ -0,0 +1,23 @@ +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using TechSupportRagBot.Data; + +#nullable disable + +namespace TechSupportRagBot.Migrations; + +[DbContext(typeof(ApplicationDbContext))] +[Migration("20261021110000_RemoveIssuedPassword")] +public partial class RemoveIssuedPassword : Migration +{ + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn(name: "IssuedPassword", table: "AspNetUsers"); + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "IssuedPassword", table: "AspNetUsers", type: "text", nullable: true); + } +} diff --git a/TechSupportRagBot/Migrations/20261021112000_AddOperatorReadReceipts.cs b/TechSupportRagBot/Migrations/20261021112000_AddOperatorReadReceipts.cs new file mode 100644 index 0000000..9165d82 --- /dev/null +++ b/TechSupportRagBot/Migrations/20261021112000_AddOperatorReadReceipts.cs @@ -0,0 +1,54 @@ +using System; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using TechSupportRagBot.Data; + +#nullable disable + +namespace TechSupportRagBot.Migrations; + +[DbContext(typeof(ApplicationDbContext))] +[Migration("20261021112000_AddOperatorReadReceipts")] +public partial class AddOperatorReadReceipts : Migration +{ + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "ChatMessageOperatorReads", + columns: table => new + { + ChatMessageId = table.Column(type: "integer", nullable: false), + OperatorUserId = table.Column(type: "text", nullable: false), + ReadAt = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_ChatMessageOperatorReads", x => new { x.ChatMessageId, x.OperatorUserId }); + table.ForeignKey("FK_ChatMessageOperatorReads_ChatMessages_ChatMessageId", x => x.ChatMessageId, + "ChatMessages", "Id", onDelete: ReferentialAction.Cascade); + table.ForeignKey("FK_ChatMessageOperatorReads_AspNetUsers_OperatorUserId", x => x.OperatorUserId, + "AspNetUsers", "Id", onDelete: ReferentialAction.Cascade); + }); + migrationBuilder.CreateIndex(name: "IX_ChatMessageOperatorReads_OperatorUserId", + table: "ChatMessageOperatorReads", column: "OperatorUserId"); + migrationBuilder.Sql(""" + INSERT INTO "ChatMessageOperatorReads" ("ChatMessageId", "OperatorUserId", "ReadAt") + SELECT DISTINCT m."Id", a."OperatorUserId", CURRENT_TIMESTAMP + FROM "ChatMessages" m + JOIN "TicketOperatorAssignments" a ON a."TicketId" = m."TicketId" + WHERE m."IsReadByOperator" = TRUE + ON CONFLICT DO NOTHING; + """); + migrationBuilder.Sql(""" + INSERT INTO "ChatMessageOperatorReads" ("ChatMessageId", "OperatorUserId", "ReadAt") + SELECT m."Id", t."OperatorUserId", CURRENT_TIMESTAMP + FROM "ChatMessages" m + JOIN "Tickets" t ON t."Id" = m."TicketId" + WHERE m."IsReadByOperator" = TRUE AND t."OperatorUserId" IS NOT NULL + ON CONFLICT DO NOTHING; + """); + } + + protected override void Down(MigrationBuilder migrationBuilder) => + migrationBuilder.DropTable(name: "ChatMessageOperatorReads"); +} diff --git a/TechSupportRagBot/Migrations/20261021113000_AddQaClientApproval.cs b/TechSupportRagBot/Migrations/20261021113000_AddQaClientApproval.cs new file mode 100644 index 0000000..c38d15c --- /dev/null +++ b/TechSupportRagBot/Migrations/20261021113000_AddQaClientApproval.cs @@ -0,0 +1,22 @@ +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using TechSupportRagBot.Data; + +#nullable disable + +namespace TechSupportRagBot.Migrations; + +[DbContext(typeof(ApplicationDbContext))] +[Migration("20261021113000_AddQaClientApproval")] +public partial class AddQaClientApproval : Migration +{ + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "AvailableToClients", table: "QAEntries", type: "boolean", + nullable: false, defaultValue: false); + } + + protected override void Down(MigrationBuilder migrationBuilder) => + migrationBuilder.DropColumn(name: "AvailableToClients", table: "QAEntries"); +} diff --git a/TechSupportRagBot/Models/ApplicationUser.cs b/TechSupportRagBot/Models/ApplicationUser.cs index 546e86c..6b1796a 100644 --- a/TechSupportRagBot/Models/ApplicationUser.cs +++ b/TechSupportRagBot/Models/ApplicationUser.cs @@ -22,7 +22,6 @@ public class ApplicationUser : IdentityUser /// Пароль, выданный администратором при создании учетной записи. /// Для оператора это временный пароль до первой смены. /// - public string? IssuedPassword { get; set; } public bool MustChangePassword { get; set; } diff --git a/TechSupportRagBot/Models/ChatMessage.cs b/TechSupportRagBot/Models/ChatMessage.cs index 16d7bda..2abbafc 100644 --- a/TechSupportRagBot/Models/ChatMessage.cs +++ b/TechSupportRagBot/Models/ChatMessage.cs @@ -28,4 +28,6 @@ public class ChatMessage public ICollection Attachments { get; set; } = new List(); public ICollection Translations { get; set; } = new List(); + + public ICollection OperatorReads { get; set; } = new List(); } diff --git a/TechSupportRagBot/Models/ChatMessageOperatorRead.cs b/TechSupportRagBot/Models/ChatMessageOperatorRead.cs new file mode 100644 index 0000000..ca761f9 --- /dev/null +++ b/TechSupportRagBot/Models/ChatMessageOperatorRead.cs @@ -0,0 +1,10 @@ +namespace TechSupportRagBot.Models; + +public sealed class ChatMessageOperatorRead +{ + public int ChatMessageId { get; set; } + public ChatMessage ChatMessage { get; set; } = null!; + public string OperatorUserId { get; set; } = string.Empty; + public ApplicationUser OperatorUser { get; set; } = null!; + public DateTime ReadAt { get; set; } = DateTime.UtcNow; +} diff --git a/TechSupportRagBot/Models/QAEntry.cs b/TechSupportRagBot/Models/QAEntry.cs index 8a8bf10..851848e 100644 --- a/TechSupportRagBot/Models/QAEntry.cs +++ b/TechSupportRagBot/Models/QAEntry.cs @@ -24,6 +24,8 @@ public class QAEntry public string Status { get; set; } = QAEntryStatuses.Draft; + public bool AvailableToClients { get; set; } + public string Source { get; set; } = QAEntrySources.Manual; public string? CreatedBy { get; set; } diff --git a/TechSupportRagBot/Pages/Admin/Admins.cshtml b/TechSupportRagBot/Pages/Admin/Admins.cshtml index 0875992..88d68c8 100644 --- a/TechSupportRagBot/Pages/Admin/Admins.cshtml +++ b/TechSupportRagBot/Pages/Admin/Admins.cshtml @@ -11,6 +11,11 @@ +@if (TempData["IssuedPasswordOnce"] is string issuedPassword) +{ +
@TechSupportRagBot.Services.UiText.T(HttpContext, "OneTimePasswordNotice") @TempData["IssuedUserOnce"]: @issuedPassword
+} +

@TechSupportRagBot.Services.UiText.T(HttpContext, "NewEmployee")

@@ -54,7 +59,6 @@ @TechSupportRagBot.Services.UiText.T(HttpContext, "FullName") @TechSupportRagBot.Services.UiText.T(HttpContext, "Username") Email - @TechSupportRagBot.Services.UiText.T(HttpContext, "TemporaryPassword") @TechSupportRagBot.Services.UiText.T(HttpContext, "Position") @TechSupportRagBot.Services.UiText.T(HttpContext, "Profile") @TechSupportRagBot.Services.UiText.T(HttpContext, "Gender") @@ -69,7 +73,6 @@ @user.FullName @user.UserName @user.Email - @(user.IssuedPassword ?? TechSupportRagBot.Services.UiText.T(HttpContext, "Changed")) @user.Position @TechSupportRagBot.Services.AccessProfileService.DisplayProfile(HttpContext, user.AccessProfile) @user.Gender @@ -77,7 +80,7 @@ @TechSupportRagBot.Services.UiText.T(HttpContext, "Profile") - +
diff --git a/TechSupportRagBot/Pages/Admin/Admins.cshtml.cs b/TechSupportRagBot/Pages/Admin/Admins.cshtml.cs index 7cd3cb8..113fb1b 100644 --- a/TechSupportRagBot/Pages/Admin/Admins.cshtml.cs +++ b/TechSupportRagBot/Pages/Admin/Admins.cshtml.cs @@ -13,8 +13,13 @@ namespace TechSupportRagBot.Pages.Admin; public class AdminsModel : PageModel { private readonly UserManager _userManager; + private readonly AccountDeactivationService _deactivation; - public AdminsModel(UserManager userManager) => _userManager = userManager; + public AdminsModel(UserManager userManager, AccountDeactivationService deactivation) + { + _userManager = userManager; + _deactivation = deactivation; + } [BindProperty] public AdminInput Input { get; set; } = new(); @@ -63,7 +68,6 @@ public async Task OnPostAsync() AccessProfile = profile, Gender = Input.Gender, Country = ChatTranslationService.NormalizeLanguage(Input.Country), - IssuedPassword = temporaryPassword, MustChangePassword = true, EmailConfirmed = true }; @@ -73,7 +77,12 @@ public async Task OnPostAsync() { var roleResult = await _userManager.AddToRoleAsync(user, profile == AccessProfileService.Administrator ? "Admin" : "Operator"); - if (roleResult.Succeeded) return RedirectToPage(); + if (roleResult.Succeeded) + { + TempData["IssuedPasswordOnce"] = temporaryPassword; + TempData["IssuedUserOnce"] = user.UserName; + return RedirectToPage(); + } await _userManager.DeleteAsync(user); result = roleResult; } @@ -129,13 +138,14 @@ public async Task OnPostResetPasswordAsync(string id, string pass var user = await _userManager.FindByIdAsync(id); if (user != null && !await _userManager.IsInRoleAsync(user, "Client") && !string.IsNullOrWhiteSpace(password)) { - await _userManager.RemovePasswordAsync(user); - var result = await _userManager.AddPasswordAsync(user, password); + var token = await _userManager.GeneratePasswordResetTokenAsync(user); + var result = await _userManager.ResetPasswordAsync(user, token, password); if (result.Succeeded) { - user.IssuedPassword = password; user.MustChangePassword = true; await _userManager.UpdateAsync(user); + TempData["IssuedPasswordOnce"] = password; + TempData["IssuedUserOnce"] = user.UserName; } } @@ -144,12 +154,13 @@ public async Task OnPostResetPasswordAsync(string id, string pass public async Task OnPostDeleteAsync(string id) { - var admins = await _userManager.GetUsersInRoleAsync("Admin"); + var admins = (await _userManager.GetUsersInRoleAsync("Admin")) + .Where(x => !AccountDeactivationService.IsDisabled(x)).ToList(); var user = await _userManager.FindByIdAsync(id); if (user != null && !await _userManager.IsInRoleAsync(user, "Client") && (!await _userManager.IsInRoleAsync(user, "Admin") || admins.Count > 1)) { - await _userManager.DeleteAsync(user); + await _deactivation.DeactivateAsync(user); } return RedirectToPage(); @@ -161,7 +172,7 @@ private async Task LoadAsync() var clientIds = clients.Select(x => x.Id).ToHashSet(); Admins = await _userManager.Users .AsNoTracking() - .Where(x => !clientIds.Contains(x.Id)) + .Where(x => !clientIds.Contains(x.Id) && x.LockoutEnd != AccountDeactivationService.DisabledUntil) .OrderBy(x => x.FullName) .ThenBy(x => x.UserName) .ToListAsync(); diff --git a/TechSupportRagBot/Pages/Admin/Clients.cshtml b/TechSupportRagBot/Pages/Admin/Clients.cshtml index 5f39e34..c749e13 100644 --- a/TechSupportRagBot/Pages/Admin/Clients.cshtml +++ b/TechSupportRagBot/Pages/Admin/Clients.cshtml @@ -11,6 +11,11 @@
+@if (TempData["IssuedPasswordOnce"] is string issuedPassword) +{ +
@TechSupportRagBot.Services.UiText.T(HttpContext, "OneTimePasswordNotice") @TempData["IssuedUserOnce"]: @issuedPassword
+} +

@TechSupportRagBot.Services.UiText.T(HttpContext, "NewClient", "Новый клиент")

@@ -33,7 +38,6 @@ @TechSupportRagBot.Services.UiText.T(HttpContext, "User", "Пользователь") @TechSupportRagBot.Services.UiText.T(HttpContext, "Username", "Логин") Email - @TechSupportRagBot.Services.UiText.T(HttpContext, "IssuedPassword", "Выданный пароль") @TechSupportRagBot.Services.UiText.T(HttpContext, "Phone", "Телефон") @@ -48,7 +52,6 @@ @user.FullName @user.UserName @user.Email - @(user.IssuedPassword ?? TechSupportRagBot.Services.UiText.T(HttpContext, "Changed", "изменен")) @client.ContactPhone @TechSupportRagBot.Services.UiText.T(HttpContext, "Profile", "Профиль") diff --git a/TechSupportRagBot/Pages/Admin/Clients.cshtml.cs b/TechSupportRagBot/Pages/Admin/Clients.cshtml.cs index e86628c..dc9cc4d 100644 --- a/TechSupportRagBot/Pages/Admin/Clients.cshtml.cs +++ b/TechSupportRagBot/Pages/Admin/Clients.cshtml.cs @@ -93,7 +93,6 @@ public async Task OnPostAsync() FullName = Input.FullName.Trim(), ClientId = client.Id, AccessProfile = AccessProfileService.Client, - IssuedPassword = password, MustChangePassword = true, Country = "ru", EmailConfirmed = true @@ -127,6 +126,8 @@ public async Task OnPostAsync() await transaction.CommitAsync(); await _welcomeMail.SendIfAddressAvailableAsync(user, password, HttpContext.RequestAborted); + TempData["IssuedPasswordOnce"] = password; + TempData["IssuedUserOnce"] = user.UserName; return RedirectToPage(); } catch (Exception ex) @@ -175,13 +176,14 @@ public async Task OnPostResetPasswordAsync(string userId, string var user = await _userManager.FindByIdAsync(userId); if (user != null && await _userManager.IsInRoleAsync(user, "Client") && !string.IsNullOrWhiteSpace(password)) { - await _userManager.RemovePasswordAsync(user); - var result = await _userManager.AddPasswordAsync(user, password); + var token = await _userManager.GeneratePasswordResetTokenAsync(user); + var result = await _userManager.ResetPasswordAsync(user, token, password); if (result.Succeeded) { - user.IssuedPassword = password; - user.MustChangePassword = false; + user.MustChangePassword = true; await _userManager.UpdateAsync(user); + TempData["IssuedPasswordOnce"] = password; + TempData["IssuedUserOnce"] = user.UserName; } } diff --git a/TechSupportRagBot/Pages/Admin/QA.cshtml b/TechSupportRagBot/Pages/Admin/QA.cshtml index cbe6e01..8ac642c 100644 --- a/TechSupportRagBot/Pages/Admin/QA.cshtml +++ b/TechSupportRagBot/Pages/Admin/QA.cshtml @@ -62,11 +62,14 @@
+ + @TechSupportRagBot.Services.UiText.T(HttpContext, "QAAvailableToClientsHelp") @@ -306,7 +315,7 @@ @foreach (var status in Model.Statuses) { - + } @@ -355,7 +364,11 @@ - @entry.Status + @TechSupportRagBot.Services.UiText.T(HttpContext, $"QAStatus{entry.Status}") + @if (entry.AvailableToClients) + { + @TechSupportRagBot.Services.UiText.T(HttpContext, "QAAvailableToClients") + } @entry.Question diff --git a/TechSupportRagBot/Pages/Admin/QA.cshtml.cs b/TechSupportRagBot/Pages/Admin/QA.cshtml.cs index e8acb00..1b15172 100644 --- a/TechSupportRagBot/Pages/Admin/QA.cshtml.cs +++ b/TechSupportRagBot/Pages/Admin/QA.cshtml.cs @@ -453,7 +453,8 @@ private QAEntry ToEntry(QAInput input, string source) SerialNumber = input.SerialNumber, NodeName = input.NodeName, ProblemType = input.ProblemType, - Status = string.IsNullOrWhiteSpace(input.Status) ? QAEntryStatuses.Verified : input.Status, + Status = string.IsNullOrWhiteSpace(input.Status) ? QAEntryStatuses.Draft : input.Status, + AvailableToClients = input.AvailableToClients, Source = source, CreatedBy = User.FindFirstValue(ClaimTypes.NameIdentifier) }; @@ -474,6 +475,7 @@ private static QAInput FromEntry(QAEntry entry) NodeName = entry.NodeName, ProblemType = entry.ProblemType, Status = string.IsNullOrWhiteSpace(entry.Status) ? QAEntryStatuses.Verified : entry.Status, + AvailableToClients = entry.AvailableToClients, Source = string.IsNullOrWhiteSpace(entry.Source) ? QAEntrySources.Import : entry.Source }; } @@ -500,7 +502,9 @@ public class QAInput public string? ProblemType { get; set; } - public string Status { get; set; } = QAEntryStatuses.Verified; + public string Status { get; set; } = QAEntryStatuses.Draft; + + public bool AvailableToClients { get; set; } public string Source { get; set; } = QAEntrySources.Import; } diff --git a/TechSupportRagBot/Pages/Admin/Tickets.cshtml.cs b/TechSupportRagBot/Pages/Admin/Tickets.cshtml.cs index 51042e9..1f17ce9 100644 --- a/TechSupportRagBot/Pages/Admin/Tickets.cshtml.cs +++ b/TechSupportRagBot/Pages/Admin/Tickets.cshtml.cs @@ -16,17 +16,20 @@ public class TicketsModel : PageModel private readonly UserManager _userManager; private readonly TicketDeletionService _ticketDeletion; private readonly AccessProfileService _access; + private readonly ChatTicketAccessService _ticketAccess; public TicketsModel( ApplicationDbContext db, UserManager userManager, TicketDeletionService ticketDeletion, - AccessProfileService access) + AccessProfileService access, + ChatTicketAccessService ticketAccess) { _db = db; _userManager = userManager; _ticketDeletion = ticketDeletion; _access = access; + _ticketAccess = ticketAccess; } [BindProperty] @@ -47,6 +50,11 @@ public TicketsModel( public async Task OnPostAddOperatorAsync(int id) { + if (!await _ticketAccess.CanReadAsync(User, id, HttpContext.RequestAborted)) + { + return NotFound(); + } + if (!await _access.IsAllowedAsync(User, "AssignOperatorsToTickets", HttpContext.RequestAborted)) { return Forbid(); @@ -82,6 +90,11 @@ public async Task OnPostAddOperatorAsync(int id) public async Task OnPostRemoveOperatorAsync(int id, string operatorId) { + if (!await _ticketAccess.CanReadAsync(User, id, HttpContext.RequestAborted)) + { + return NotFound(); + } + if (!await _access.IsAllowedAsync(User, "AssignOperatorsToTickets", HttpContext.RequestAborted)) { return Forbid(); @@ -101,6 +114,11 @@ public async Task OnPostRemoveOperatorAsync(int id, string operat public async Task OnPostDeleteAsync(int id) { + if (!await _ticketAccess.CanReadAsync(User, id, HttpContext.RequestAborted)) + { + return NotFound(); + } + if (!await _access.IsAllowedAsync(User, "DeleteTickets", HttpContext.RequestAborted)) { return Forbid(); @@ -115,16 +133,17 @@ private async Task LoadAsync() CanAssignOperatorsToTickets = await _access.IsAllowedAsync(User, "AssignOperatorsToTickets", HttpContext.RequestAborted); CanDeleteTickets = await _access.IsAllowedAsync(User, "DeleteTickets", HttpContext.RequestAborted); - var ticketsQuery = _db.Tickets + var ticketsQuery = await _ticketAccess.ScopeTicketsAsync(User, _db.Tickets .Include(x => x.Machine) .Include(x => x.ClientUser) .ThenInclude(x => x!.Client) .Include(x => x.OperatorUser) .Include(x => x.Messages) + .ThenInclude(x => x.OperatorReads) .Include(x => x.ResolvedAnswers) .Include(x => x.OperatorAssignments) .ThenInclude(x => x.OperatorUser) - .AsQueryable(); + .AsQueryable(), HttpContext.RequestAborted); if (!string.IsNullOrWhiteSpace(StatusFilter)) { @@ -144,7 +163,7 @@ private async Task LoadAsync() : ticket.Messages.Count(x => x.AuthorUserId != currentUserId && !x.IsBotMessage && - !x.IsReadByOperator))) + !x.OperatorReads.Any(read => read.OperatorUserId == currentUserId)))) .ToList(); TotalUnreadCount = rows.Sum(x => x.UnreadCount); diff --git a/TechSupportRagBot/Pages/Admin/UserProfile.cshtml b/TechSupportRagBot/Pages/Admin/UserProfile.cshtml index d925d58..2a3c639 100644 --- a/TechSupportRagBot/Pages/Admin/UserProfile.cshtml +++ b/TechSupportRagBot/Pages/Admin/UserProfile.cshtml @@ -13,6 +13,11 @@ @TechSupportRagBot.Services.UiText.T(HttpContext, "Back") +@if (TempData["IssuedPasswordOnce"] is string issuedPassword) +{ +
@TechSupportRagBot.Services.UiText.T(HttpContext, "OneTimePasswordNotice") @TempData["IssuedUserOnce"]: @issuedPassword
+} +
@@ -36,7 +41,6 @@
@TechSupportRagBot.Services.UiText.T(HttpContext, "AccessProfile")
@TechSupportRagBot.Services.AccessProfileService.DisplayProfile(HttpContext, Model.EditedUser?.AccessProfile)
@TechSupportRagBot.Services.UiText.T(HttpContext, "Language")
@TechSupportRagBot.Services.UiText.LanguageName(HttpContext, Model.EditedUser?.Country)
@TechSupportRagBot.Services.UiText.T(HttpContext, "Gender")
@(Model.EditedUser?.Gender ?? "—")
-
@TechSupportRagBot.Services.UiText.T(HttpContext, "IssuedPassword")
@(Model.EditedUser?.IssuedPassword ?? TechSupportRagBot.Services.UiText.T(HttpContext, "Changed"))
diff --git a/TechSupportRagBot/Pages/Admin/UserProfile.cshtml.cs b/TechSupportRagBot/Pages/Admin/UserProfile.cshtml.cs index 2b50e0d..a4256a7 100644 --- a/TechSupportRagBot/Pages/Admin/UserProfile.cshtml.cs +++ b/TechSupportRagBot/Pages/Admin/UserProfile.cshtml.cs @@ -114,8 +114,8 @@ public async Task OnPostResetPasswordAsync(string password) return Page(); } - await _userManager.RemovePasswordAsync(EditedUser!); - var result = await _userManager.AddPasswordAsync(EditedUser!, password); + var token = await _userManager.GeneratePasswordResetTokenAsync(EditedUser!); + var result = await _userManager.ResetPasswordAsync(EditedUser!, token, password); if (!result.Succeeded) { foreach (var error in result.Errors) @@ -127,9 +127,10 @@ public async Task OnPostResetPasswordAsync(string password) return Page(); } - EditedUser!.IssuedPassword = password; - EditedUser.MustChangePassword = Roles.Contains("Operator") || Roles.Contains("Admin"); + EditedUser!.MustChangePassword = true; await _userManager.UpdateAsync(EditedUser); + TempData["IssuedPasswordOnce"] = password; + TempData["IssuedUserOnce"] = EditedUser.UserName; return RedirectToPage(new { id = Id }); } diff --git a/TechSupportRagBot/Pages/Client/Ticket.cshtml.cs b/TechSupportRagBot/Pages/Client/Ticket.cshtml.cs index 4a507dc..1c5f8b1 100644 --- a/TechSupportRagBot/Pages/Client/Ticket.cshtml.cs +++ b/TechSupportRagBot/Pages/Client/Ticket.cshtml.cs @@ -443,7 +443,8 @@ public async Task OnPostTranslateAsync(int id, int messageId) .FirstOrDefaultAsync(x => x.Id == id && clientId != null && _db.Users.Any(user => user.Id == x.ClientUserId && user.ClientId == clientId)); - if (ticket == null && await _access.IsAllowedAsync(User, "Tickets", HttpContext.RequestAborted)) + if (ticket == null && clientId == null + && await _access.IsAllowedAsync(User, "Tickets", HttpContext.RequestAborted)) { ticket = await _db.Tickets.FirstOrDefaultAsync(x => x.Id == id); } @@ -463,7 +464,8 @@ private async Task LoadAsync(int id) .FirstOrDefaultAsync(x => x.Id == id && clientId != null && _db.Users.Any(user => user.Id == x.ClientUserId && user.ClientId == clientId)); - if (Ticket == null && await _access.IsAllowedAsync(User, "Tickets", HttpContext.RequestAborted)) + if (Ticket == null && clientId == null + && await _access.IsAllowedAsync(User, "Tickets", HttpContext.RequestAborted)) { Ticket = await _db.Tickets .Include(x => x.Machine) diff --git a/TechSupportRagBot/Pages/Client/Users.cshtml b/TechSupportRagBot/Pages/Client/Users.cshtml index 77e3b64..8a1f527 100644 --- a/TechSupportRagBot/Pages/Client/Users.cshtml +++ b/TechSupportRagBot/Pages/Client/Users.cshtml @@ -11,6 +11,11 @@
+@if (TempData["IssuedPasswordOnce"] is string issuedPassword) +{ +
@TechSupportRagBot.Services.UiText.T(HttpContext, "OneTimePasswordNotice") @TempData["IssuedUserOnce"]: @issuedPassword
+} +

@TechSupportRagBot.Services.UiText.T(HttpContext, "NewCompanyUser")

@@ -58,13 +63,12 @@
@TechSupportRagBot.Services.UiText.T(HttpContext, "Profile")
@TechSupportRagBot.Services.AccessProfileService.DisplayProfile(HttpContext, user.AccessProfile)
@TechSupportRagBot.Services.UiText.T(HttpContext, "Language")
@TechSupportRagBot.Services.UiText.LanguageName(HttpContext, user.Country)
-
@TechSupportRagBot.Services.UiText.T(HttpContext, "IssuedPassword")
@(user.IssuedPassword ?? TechSupportRagBot.Services.UiText.T(HttpContext, "Changed"))
diff --git a/TechSupportRagBot/Pages/Client/Users.cshtml.cs b/TechSupportRagBot/Pages/Client/Users.cshtml.cs index 79d984b..1ab6fcf 100644 --- a/TechSupportRagBot/Pages/Client/Users.cshtml.cs +++ b/TechSupportRagBot/Pages/Client/Users.cshtml.cs @@ -16,18 +16,18 @@ public class UsersModel : PageModel { private readonly ApplicationDbContext _db; private readonly UserManager _userManager; - private readonly TicketDeletionService _ticketDeletion; + private readonly AccountDeactivationService _deactivation; private readonly AccountWelcomeEmailService _welcomeMail; public UsersModel( ApplicationDbContext db, UserManager userManager, - TicketDeletionService ticketDeletion, + AccountDeactivationService deactivation, AccountWelcomeEmailService welcomeMail) { _db = db; _userManager = userManager; - _ticketDeletion = ticketDeletion; + _deactivation = deactivation; _welcomeMail = welcomeMail; } @@ -93,7 +93,6 @@ public async Task OnPostAsync() AccessProfile = AccessProfileService.ClientOperator, Country = ChatTranslationService.NormalizeLanguage(Input.Country), ClientId = clientId, - IssuedPassword = password, MustChangePassword = true, EmailConfirmed = true }; @@ -105,6 +104,8 @@ public async Task OnPostAsync() if (roleResult.Succeeded) { await _welcomeMail.SendIfAddressAvailableAsync(user, password, HttpContext.RequestAborted); + TempData["IssuedPasswordOnce"] = password; + TempData["IssuedUserOnce"] = user.UserName; return RedirectToPage(); } await _userManager.DeleteAsync(user); @@ -132,8 +133,7 @@ public async Task OnPostDeleteAsync(string id) var currentUserId = User.FindFirstValue(ClaimTypes.NameIdentifier); if (user != null && user.Id != currentUserId) { - await _ticketDeletion.DeleteTicketsForUsersAsync(new[] { user.Id }); - await _userManager.DeleteAsync(user); + await _deactivation.DeactivateAsync(user); } return RedirectToPage(); @@ -149,7 +149,7 @@ private async Task LoadAsync() } Users = await _db.Users - .Where(x => x.ClientId == clientId) + .Where(x => x.ClientId == clientId && x.LockoutEnd != AccountDeactivationService.DisabledUntil) .OrderBy(x => x.FullName ?? x.UserName) .ToListAsync(); diff --git a/TechSupportRagBot/Pages/Index.cshtml.cs b/TechSupportRagBot/Pages/Index.cshtml.cs index 6df84e3..61905cc 100644 --- a/TechSupportRagBot/Pages/Index.cshtml.cs +++ b/TechSupportRagBot/Pages/Index.cshtml.cs @@ -14,15 +14,18 @@ public class IndexModel : PageModel private readonly ApplicationDbContext _db; private readonly UserManager _userManager; private readonly AccessProfileService _access; + private readonly ChatTicketAccessService _ticketAccess; public IndexModel( ApplicationDbContext db, UserManager userManager, - AccessProfileService access) + AccessProfileService access, + ChatTicketAccessService ticketAccess) { _db = db; _userManager = userManager; _access = access; + _ticketAccess = ticketAccess; } public string DisplayName { get; private set; } = string.Empty; @@ -136,12 +139,15 @@ private async Task LoadAdminMetricsAsync() if (Can("Tickets")) { - OpenTicketCount = await _db.Tickets.CountAsync(x => x.Status != TicketStatuses.Closed, HttpContext.RequestAborted); - WaitingForOperatorCount = await _db.Tickets.CountAsync(x => x.Status == TicketStatuses.WaitingForOperator, HttpContext.RequestAborted); - AttentionTickets = await _db.Tickets + var visibleTickets = await _ticketAccess.ScopeTicketsAsync( + User, _db.Tickets, HttpContext.RequestAborted); + OpenTicketCount = await visibleTickets.CountAsync(x => x.Status != TicketStatuses.Closed, HttpContext.RequestAborted); + WaitingForOperatorCount = await visibleTickets.CountAsync(x => x.Status == TicketStatuses.WaitingForOperator, HttpContext.RequestAborted); + AttentionTickets = await visibleTickets .Include(x => x.Machine) .Include(x => x.ClientUser) .Include(x => x.Messages) + .ThenInclude(x => x.OperatorReads) .Where(x => x.Status != TicketStatuses.Closed) .OrderByDescending(x => x.Status == TicketStatuses.WaitingForOperator) .ThenByDescending(x => x.CreatedAt) @@ -155,7 +161,7 @@ private async Task LoadAdminMetricsAsync() ticket.Messages.Count(message => message.AuthorUserId != currentUserId && !message.IsBotMessage && - !message.IsReadByOperator), + !message.OperatorReads.Any(read => read.OperatorUserId == currentUserId)), Url.Page("/Operator/Ticket", new { id = ticket.Id }) ?? $"/Operator/Ticket/{ticket.Id}"))); } } @@ -199,6 +205,7 @@ private async Task LoadClientMetricsAsync(string? userId) RecentClientTickets = await _db.Tickets .Include(x => x.Machine) .Include(x => x.Messages) + .ThenInclude(x => x.OperatorReads) .Where(x => clientId != null && _db.Users.Any(user => user.Id == x.ClientUserId && user.ClientId == clientId)) .OrderByDescending(x => x.CreatedAt) @@ -236,7 +243,8 @@ private async Task LoadOperatorMetricsAsync(string? userId) .Include(x => x.Messages) .Where(x => x.Status != TicketStatuses.Closed) .Where(x => x.OperatorUserId == userId || x.OperatorAssignments.Any(a => a.OperatorUserId == userId)) - .OrderByDescending(x => x.Messages.Any(m => m.AuthorUserId != userId && !m.IsReadByOperator)) + .OrderByDescending(x => x.Messages.Any(m => m.AuthorUserId != userId + && !m.OperatorReads.Any(read => read.OperatorUserId == userId))) .ThenByDescending(x => x.CreatedAt) .Take(12) .ToListAsync(HttpContext.RequestAborted); @@ -246,7 +254,8 @@ private async Task LoadOperatorMetricsAsync(string? userId) AssignedTicketGroups = BuildDashboardGroups(AssignedTickets.Select(ticket => new DashboardTicketRow( ticket, - ticket.Messages.Count(message => message.AuthorUserId != userId && !message.IsReadByOperator), + ticket.Messages.Count(message => message.AuthorUserId != userId + && !message.OperatorReads.Any(read => read.OperatorUserId == userId)), Url.Page("/Operator/Ticket", new { id = ticket.Id }) ?? $"/Operator/Ticket/{ticket.Id}"))); } } diff --git a/TechSupportRagBot/Pages/Operator/Index.cshtml.cs b/TechSupportRagBot/Pages/Operator/Index.cshtml.cs index 0cdb1f7..b6ef0f5 100644 --- a/TechSupportRagBot/Pages/Operator/Index.cshtml.cs +++ b/TechSupportRagBot/Pages/Operator/Index.cshtml.cs @@ -25,6 +25,7 @@ public async Task OnGetAsync() .Include(x => x.ClientUser) .Include(x => x.OperatorAssignments) .Include(x => x.Messages) + .ThenInclude(x => x.OperatorReads) .Where(x => x.Status != TicketStatuses.Closed) .Where(x => x.OperatorUserId == userId || x.OperatorAssignments.Any(a => a.OperatorUserId == userId)) @@ -40,7 +41,7 @@ public async Task OnGetAsync() ? 0 : ticket.Messages.Count(x => x.AuthorUserId != userId && - !x.IsReadByOperator))) + !x.OperatorReads.Any(read => read.OperatorUserId == userId)))) .GroupBy(x => new { MachineId = x.Ticket.MachineId, diff --git a/TechSupportRagBot/Pages/Operator/Ticket.cshtml.cs b/TechSupportRagBot/Pages/Operator/Ticket.cshtml.cs index 475ba46..e9325f9 100644 --- a/TechSupportRagBot/Pages/Operator/Ticket.cshtml.cs +++ b/TechSupportRagBot/Pages/Operator/Ticket.cshtml.cs @@ -21,6 +21,7 @@ public class TicketModel : PageModel private readonly RagAuditLogger _auditLogger; private readonly ChatMessageDeletionService _messageDeletion; private readonly AccessProfileService _access; + private readonly ChatTicketAccessService _ticketAccess; private readonly FileStorageService _storage; private readonly FileUploadValidationService _uploads; @@ -34,6 +35,7 @@ public TicketModel( RagAuditLogger auditLogger, ChatMessageDeletionService messageDeletion, AccessProfileService access, + ChatTicketAccessService ticketAccess, FileStorageService storage, FileUploadValidationService uploads) { @@ -46,6 +48,7 @@ public TicketModel( _auditLogger = auditLogger; _messageDeletion = messageDeletion; _access = access; + _ticketAccess = ticketAccess; _storage = storage; _uploads = uploads; } @@ -119,14 +122,16 @@ public async Task OnGetMessagesAsync(int id, int afterId = 0) return NotFound(); } - var messages = await _db.ChatMessages.AsNoTracking() + var messages = await _db.ChatMessages .Include(x => x.AuthorUser) .Include(x => x.Attachments) .Include(x => x.Translations) + .Include(x => x.OperatorReads) .Where(x => x.TicketId == id && x.Id > afterId) .OrderBy(x => x.Id) .Take(100) .ToListAsync(); + await MarkMessagesReadAsync(messages); var currentUser = await _db.Users.AsNoTracking().FirstOrDefaultAsync(x => x.Id == CurrentUserId); return new JsonResult(new { @@ -466,6 +471,11 @@ public async Task OnPostTranslateAsync(int id, int messageId) private async Task LoadTicketForCurrentOperatorAsync(int id) { + if (!await _ticketAccess.CanReadAsync(User, id, HttpContext.RequestAborted)) + { + return null; + } + var ticket = await _db.Tickets .Include(x => x.OperatorAssignments) .FirstOrDefaultAsync(x => x.Id == id); @@ -480,6 +490,11 @@ public async Task OnPostTranslateAsync(int id, int messageId) private async Task LoadAsync(int id) { + if (!await _ticketAccess.CanReadAsync(User, id, HttpContext.RequestAborted)) + { + return false; + } + CurrentUserId = User.FindFirstValue(ClaimTypes.NameIdentifier); CanWriteChat = await _access.IsAllowedAsync(User, "ChatWrite", HttpContext.RequestAborted); CanCloseTickets = await _access.IsAllowedAsync(User, "CloseTickets", HttpContext.RequestAborted); @@ -501,6 +516,7 @@ private async Task LoadAsync(int id) .Include(x => x.AuthorUser) .Include(x => x.Attachments) .Include(x => x.Translations) + .Include(x => x.OperatorReads) .Where(x => x.TicketId == id) .OrderBy(x => x.CreatedAt) .ToListAsync(); @@ -508,18 +524,31 @@ private async Task LoadAsync(int id) var currentUser = await _db.Users.FirstOrDefaultAsync(x => x.Id == CurrentUserId); Messages = BuildMessageViews(rawMessages, currentUser); - var unread = rawMessages.Where(x => x.AuthorUserId != CurrentUserId && !x.IsReadByOperator).ToList(); + await MarkMessagesReadAsync(rawMessages); + + return true; + } + + private async Task MarkMessagesReadAsync(IEnumerable messages) + { + if (string.IsNullOrWhiteSpace(CurrentUserId)) return; + + var unread = messages.Where(x => x.AuthorUserId != CurrentUserId + && !x.OperatorReads.Any(read => read.OperatorUserId == CurrentUserId)).ToList(); if (unread.Count > 0) { foreach (var message in unread) { message.IsReadByOperator = true; + _db.ChatMessageOperatorReads.Add(new ChatMessageOperatorRead + { + ChatMessageId = message.Id, + OperatorUserId = CurrentUserId + }); } await _db.SaveChangesAsync(); } - - return true; } private List BuildMessageViews(List messages, ApplicationUser? currentUser) diff --git a/TechSupportRagBot/Pages/ProfileEdit.cshtml.cs b/TechSupportRagBot/Pages/ProfileEdit.cshtml.cs index 91a8476..d721271 100644 --- a/TechSupportRagBot/Pages/ProfileEdit.cshtml.cs +++ b/TechSupportRagBot/Pages/ProfileEdit.cshtml.cs @@ -169,7 +169,6 @@ public async Task OnPostChangePasswordAsync() } user.MustChangePassword = false; - user.IssuedPassword = null; await _userManager.UpdateAsync(user); return RedirectToPage("/Profile"); } diff --git a/TechSupportRagBot/Pages/Shared/_LoginPartial.cshtml b/TechSupportRagBot/Pages/Shared/_LoginPartial.cshtml index a0ed4dd..edab961 100644 --- a/TechSupportRagBot/Pages/Shared/_LoginPartial.cshtml +++ b/TechSupportRagBot/Pages/Shared/_LoginPartial.cshtml @@ -55,7 +55,7 @@ .Where(x => x.Ticket!.Status != TicketStatuses.Closed && x.AuthorUserId != currentUserId && - !x.IsReadByOperator && + !x.OperatorReads.Any(read => read.OperatorUserId == currentUserId) && (x.Ticket.OperatorUserId == currentUserId || x.Ticket.OperatorAssignments.Any(a => a.OperatorUserId == currentUserId))) .ToListAsync(); diff --git a/TechSupportRagBot/Program.cs b/TechSupportRagBot/Program.cs index 3233f2e..8677ba1 100644 --- a/TechSupportRagBot/Program.cs +++ b/TechSupportRagBot/Program.cs @@ -1,5 +1,6 @@ using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.DataProtection; +using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Hosting.StaticWebAssets; using Microsoft.AspNetCore.Http.Features; using Microsoft.AspNetCore.HttpOverrides; @@ -38,8 +39,11 @@ builder.Services.Configure(options => { options.ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto; - options.KnownNetworks.Clear(); - options.KnownProxies.Clear(); + var proxyAddresses = builder.Configuration.GetSection("ReverseProxy:KnownProxies").Get() ?? []; + foreach (var address in proxyAddresses) + { + if (System.Net.IPAddress.TryParse(address, out var parsed)) options.KnownProxies.Add(parsed); + } }); var dataProtectionKeysPath = Path.Combine(builder.Environment.ContentRootPath, "App_Data", "DataProtectionKeys"); @@ -83,6 +87,9 @@ // Пока отключаем обязательное подтверждение email, // чтобы на этапе разработки можно было сразу входить в систему. options.SignIn.RequireConfirmedAccount = false; + options.Lockout.DefaultLockoutTimeSpan = TimeSpan.FromMinutes(15); + options.Lockout.MaxFailedAccessAttempts = 5; + options.Lockout.AllowedForNewUsers = true; }) .AddRoles() .AddEntityFrameworkStores(); @@ -125,6 +132,7 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); @@ -245,6 +253,15 @@ { if (context.User.Identity?.IsAuthenticated == true) { + var userManager = context.RequestServices.GetRequiredService>(); + var currentUser = await userManager.GetUserAsync(context.User); + if (currentUser == null || AccountDeactivationService.IsDisabled(currentUser)) + { + await context.SignOutAsync(IdentityConstants.ApplicationScheme); + context.Response.Redirect("/Identity/Account/Login"); + return; + } + var permission = AccessProfileService.PermissionForPath(context.Request.Path); if (!string.IsNullOrWhiteSpace(permission)) { diff --git a/TechSupportRagBot/Services/AccessProfileService.cs b/TechSupportRagBot/Services/AccessProfileService.cs index 132fee2..4815d43 100644 --- a/TechSupportRagBot/Services/AccessProfileService.cs +++ b/TechSupportRagBot/Services/AccessProfileService.cs @@ -151,7 +151,7 @@ public async Task IsAllowedAsync(ClaimsPrincipal principal, string permiss } var user = await _userManager.GetUserAsync(principal); - if (user == null) + if (user == null || AccountDeactivationService.IsDisabled(user)) { return false; } diff --git a/TechSupportRagBot/Services/AccountDeactivationService.cs b/TechSupportRagBot/Services/AccountDeactivationService.cs new file mode 100644 index 0000000..0217157 --- /dev/null +++ b/TechSupportRagBot/Services/AccountDeactivationService.cs @@ -0,0 +1,28 @@ +using Microsoft.AspNetCore.Identity; +using Microsoft.EntityFrameworkCore; +using TechSupportRagBot.Data; +using TechSupportRagBot.Models; + +namespace TechSupportRagBot.Services; + +public sealed class AccountDeactivationService( + ApplicationDbContext db, + UserManager userManager) +{ + public static readonly DateTimeOffset DisabledUntil = DateTimeOffset.MaxValue; + + public static bool IsDisabled(ApplicationUser user) => user.LockoutEnd == DisabledUntil; + + public async Task DeactivateAsync(ApplicationUser user) + { + user.LockoutEnabled = true; + user.LockoutEnd = DisabledUntil; + user.EmailNotificationsEnabled = false; + var result = await userManager.UpdateAsync(user); + if (!result.Succeeded) return result; + + await userManager.UpdateSecurityStampAsync(user); + await db.BrowserPushSubscriptions.Where(x => x.UserId == user.Id).ExecuteDeleteAsync(); + return result; + } +} diff --git a/TechSupportRagBot/Services/BackgroundTaskQueue.cs b/TechSupportRagBot/Services/BackgroundTaskQueue.cs index a032595..f73cf8c 100644 --- a/TechSupportRagBot/Services/BackgroundTaskQueue.cs +++ b/TechSupportRagBot/Services/BackgroundTaskQueue.cs @@ -4,7 +4,12 @@ namespace TechSupportRagBot.Services; public class BackgroundTaskQueue : IBackgroundTaskQueue { - private readonly Channel _queue = Channel.CreateUnbounded(); + private readonly Channel _queue = Channel.CreateBounded( + new BoundedChannelOptions(100) + { + SingleReader = true, + FullMode = BoundedChannelFullMode.Wait + }); public ValueTask QueueAsync(VideoProcessingTask task, CancellationToken cancellationToken = default) { diff --git a/TechSupportRagBot/Services/ChatTicketAccessService.cs b/TechSupportRagBot/Services/ChatTicketAccessService.cs index 20fb69d..6b3d86b 100644 --- a/TechSupportRagBot/Services/ChatTicketAccessService.cs +++ b/TechSupportRagBot/Services/ChatTicketAccessService.cs @@ -40,15 +40,7 @@ public ChatTicketAccessService( return null; } - var canReadAllTickets = await _accessProfiles.IsAllowedAsync(principal, "Tickets", cancellationToken); - var hasTicketAccess = canReadAllTickets - ? await _db.Tickets.AnyAsync(x => x.Id == ticketId, cancellationToken) - : user.ClientId.HasValue && await _db.Tickets.AnyAsync( - x => x.Id == ticketId - && _db.Users.Any(owner => owner.Id == x.ClientUserId && owner.ClientId == user.ClientId), - cancellationToken); - - if (!hasTicketAccess) + if (!await CanReadAsync(principal, ticketId, cancellationToken)) { return null; } @@ -58,6 +50,56 @@ public ChatTicketAccessService( : user.FullName; return new ChatTicketAccess(ticketId, displayName); } + + public async Task CanReadAsync( + ClaimsPrincipal principal, + int ticketId, + CancellationToken cancellationToken = default) + { + if (ticketId <= 0 || principal.Identity?.IsAuthenticated != true) + { + return false; + } + + var user = await _userManager.GetUserAsync(principal); + if (user == null) + { + return false; + } + + var hasTicketPermission = await _accessProfiles.IsAllowedAsync(principal, "Tickets", cancellationToken); + if (user.ClientId.HasValue) + { + if (!hasTicketPermission + && !await _accessProfiles.IsAllowedAsync(principal, "ClientCabinet", cancellationToken)) + { + return false; + } + + return await _db.Tickets.AnyAsync(x => x.Id == ticketId + && _db.Users.Any(owner => owner.Id == x.ClientUserId && owner.ClientId == user.ClientId), + cancellationToken); + } + + return hasTicketPermission && await _db.Tickets.AnyAsync(x => x.Id == ticketId, cancellationToken); + } + + public async Task> ScopeTicketsAsync( + ClaimsPrincipal principal, + IQueryable tickets, + CancellationToken cancellationToken = default) + { + var user = await _userManager.GetUserAsync(principal); + if (user == null) + { + return tickets.Where(_ => false); + } + + return user.ClientId.HasValue + ? tickets.Where(ticket => _db.Users.Any(owner => + owner.Id == ticket.ClientUserId && owner.ClientId == user.ClientId)) + : tickets; + } } public sealed record ChatTicketAccess(int TicketId, string DisplayName); diff --git a/TechSupportRagBot/Services/DocumentTextExtractor.cs b/TechSupportRagBot/Services/DocumentTextExtractor.cs index 77c25e8..866e5f5 100644 --- a/TechSupportRagBot/Services/DocumentTextExtractor.cs +++ b/TechSupportRagBot/Services/DocumentTextExtractor.cs @@ -1,4 +1,5 @@ using System.Text; +using System.IO.Compression; using DocumentFormat.OpenXml; using DocumentFormat.OpenXml.Packaging; using DocumentFormat.OpenXml.Spreadsheet; @@ -11,6 +12,9 @@ namespace TechSupportRagBot.Services; public class DocumentTextExtractor { + private const long MaxArchiveBytes = 100L * 1024 * 1024; + private const int MaxTextCharacters = 4_000_000; + public async Task ExtractAsync(string path, CancellationToken cancellationToken = default) { return (await ExtractDocumentAsync(path, null, cancellationToken)).FullText; @@ -23,20 +27,26 @@ public async Task ExtractDocumentAsync( { var fileName = Path.GetFileName(path); var extension = Path.GetExtension(path).ToLowerInvariant(); + if (new FileInfo(path).Length > MaxArchiveBytes) + throw new InvalidDataException("Document exceeds the indexing limit."); var result = new ExtractedDocument { FileName = fileName }; switch (extension) { case ".txt": + if (new FileInfo(path).Length > MaxTextCharacters * 4L) + throw new InvalidDataException("Document exceeds the indexing limit."); result.FullText = await File.ReadAllTextAsync(path, cancellationToken); break; case ".pdf": ExtractPdf(path, result); break; case ".docx": + ValidateArchive(path); result.FullText = ExtractDocx(path); break; case ".xlsx": + ValidateArchive(path); ExtractXlsx(path, result); break; case ".xls": @@ -46,6 +56,8 @@ public async Task ExtractDocumentAsync( throw new InvalidOperationException("Поддерживаются PDF, DOCX, TXT и XLSX. XLS требует конвертации в XLSX."); } + EnsureTextLimit(result.FullText.Length); + result.DocumentType = new DocumentTypeDetector().Detect(fileName, category, result.FullText); if (extension == ".xlsx") { @@ -61,7 +73,9 @@ private static void ExtractPdf(string path, ExtractedDocument result) using var document = PdfDocument.Open(path); foreach (var page in document.GetPages()) { + if (page.Number > 500) throw new InvalidDataException("Document exceeds the indexing limit."); var text = page.Text?.Trim() ?? string.Empty; + EnsureTextLimit(builder.Length + text.Length); if (string.IsNullOrWhiteSpace(text)) { result.Warnings.Add($"PDF page {page.Number} has no extracted text. PDF may require OCR."); @@ -99,6 +113,7 @@ private static string ExtractDocx(string path) foreach (var element in body.Elements()) { AppendElementText(element, builder); + EnsureTextLimit(builder.Length); } return builder.ToString(); @@ -131,6 +146,7 @@ private static void AppendElementText(OpenXmlElement element, StringBuilder buil if (cells.Count > 0) { builder.AppendLine(string.Join(" | ", cells)); + EnsureTextLimit(builder.Length); } } @@ -178,6 +194,8 @@ private static void ExtractXlsx(string path, ExtractedDocument result) var extractedSheet = new ExtractedSheet { SheetName = sheet.Name?.Value ?? "Sheet" }; foreach (var row in sheetData.Elements()) { + if (extractedSheet.Rows.Count >= 50_000) + throw new InvalidDataException("Document exceeds the indexing limit."); var cells = row.Elements() .Select(cell => GetCellText(cell, sharedStrings)) .ToList(); @@ -200,6 +218,7 @@ private static void ExtractXlsx(string path, ExtractedDocument result) }); builder.AppendLine($"{extractedSheet.SheetName} #{rowNumber}: {string.Join(" | ", cells)}"); + EnsureTextLimit(builder.Length); } result.Sheets.Add(extractedSheet); @@ -220,4 +239,24 @@ private static string GetCellText(Cell cell, SharedStringTable? sharedStrings) return value; } + + private static void EnsureTextLimit(int length) + { + if (length > MaxTextCharacters) + throw new InvalidDataException("Document exceeds the indexing limit."); + } + + private static void ValidateArchive(string path) + { + using var archive = ZipFile.OpenRead(path); + long total = 0; + if (archive.Entries.Count > 2_000) + throw new InvalidDataException("Document exceeds the indexing limit."); + foreach (var entry in archive.Entries) + { + total += entry.Length; + if (entry.Length > MaxArchiveBytes || total > MaxArchiveBytes) + throw new InvalidDataException("Document exceeds the indexing limit."); + } + } } diff --git a/TechSupportRagBot/Services/EmailNotificationBackgroundService.cs b/TechSupportRagBot/Services/EmailNotificationBackgroundService.cs index 2e5007b..3d423ca 100644 --- a/TechSupportRagBot/Services/EmailNotificationBackgroundService.cs +++ b/TechSupportRagBot/Services/EmailNotificationBackgroundService.cs @@ -98,43 +98,49 @@ private async Task SendNewMessageNotificationsAsync(IReadOnlyDictionary x.AuthorUser) .Include(x => x.Ticket).ThenInclude(x => x!.Machine) .Include(x => x.Ticket).ThenInclude(x => x!.ClientUser) .Include(x => x.Ticket).ThenInclude(x => x!.OperatorUser) .Include(x => x.Ticket).ThenInclude(x => x!.OperatorAssignments).ThenInclude(x => x.OperatorUser) - .Where(x => x.CreatedAt >= threshold && !x.IsBotMessage && !x.IsReadByOperator - && x.Ticket != null && x.Ticket.Status != TicketStatuses.Closed) - .OrderByDescending(x => x.CreatedAt) - .Take(300) - .ToListAsync(cancellationToken); + .Include(x => x.OperatorReads) + .Where(x => x.CreatedAt >= threshold && !x.IsBotMessage + && x.Ticket != null && x.Ticket.Status != TicketStatuses.Closed); - foreach (var message in messages.OrderBy(x => x.CreatedAt)) + for (var offset = 0; ; offset += 200) { - var ticket = message.Ticket!; - var clientId = ticket.ClientUser?.ClientId; - var authorIsClient = clientId != null && message.AuthorUser?.ClientId == clientId; - if (!authorIsClient) continue; - var recipients = GetAssignedOperators(ticket, message.CreatedAt) - .Where(x => EmailNotificationRulesService.Allows(rules, EmailNotificationTypes.NewMessage, x)); - - foreach (var recipient in recipients.Where(x => x.Id != message.AuthorUserId)) + var messages = await query.OrderBy(x => x.CreatedAt).ThenBy(x => x.Id) + .Skip(offset).Take(200).ToListAsync(cancellationToken); + foreach (var message in messages) { - if (emailEnabled && message.CreatedAt >= emailStart && !string.IsNullOrWhiteSpace(recipient.Email)) - await SendOnceAsync( - EmailNotificationTypes.NewMessage, - ticket.Id, - message.Id, - recipient, - recipient.Email!, - string.Format(UiText.ForLanguage("ru", "NewMessageMailSubject"), ticket.Id), - BuildNewMessageBody(ticket, message), - cancellationToken); - if (pushStart != null && message.CreatedAt >= pushStart) - await _push.SendAsync(EmailNotificationTypes.NewMessage, ticket.Id, message.Id, recipient, cancellationToken); + var ticket = message.Ticket!; + var clientId = ticket.ClientUser?.ClientId; + var authorIsClient = clientId != null && message.AuthorUser?.ClientId == clientId; + if (!authorIsClient) continue; + var recipients = GetAssignedOperators(ticket, message.CreatedAt) + .Where(x => EmailNotificationRulesService.Allows(rules, EmailNotificationTypes.NewMessage, x)); + + foreach (var recipient in recipients.Where(x => x.Id != message.AuthorUserId + && !message.OperatorReads.Any(read => read.OperatorUserId == x.Id))) + { + if (emailEnabled && message.CreatedAt >= emailStart && !string.IsNullOrWhiteSpace(recipient.Email)) + await SendOnceAsync( + EmailNotificationTypes.NewMessage, + ticket.Id, + message.Id, + recipient, + recipient.Email!, + string.Format(UiText.ForLanguage("ru", "NewMessageMailSubject"), ticket.Id), + BuildNewMessageBody(ticket, message), + cancellationToken); + if (pushStart != null && message.CreatedAt >= pushStart) + await _push.SendAsync(EmailNotificationTypes.NewMessage, ticket.Id, message.Id, recipient, cancellationToken); + } } + + if (messages.Count < 200) break; } } @@ -238,7 +244,8 @@ private async Task SendUnreadMessageNotificationsAsync(IReadOnlyDictionary x!.OperatorAssignments) .ThenInclude(x => x.OperatorUser) .Include(x => x.AuthorUser) - .Where(x => x.CreatedAt <= threshold && !x.IsBotMessage && !x.IsReadByOperator + .Include(x => x.OperatorReads) + .Where(x => x.CreatedAt <= threshold && !x.IsBotMessage && x.Ticket != null && x.Ticket.Status != TicketStatuses.Closed); var oldest = DateTime.UtcNow.AddDays(-7); @@ -258,6 +265,7 @@ private async Task SendUnreadMessageNotificationsAsync(IReadOnlyDictionary read.OperatorUserId == recipient.Id)) continue; if (!EmailNotificationRulesService.Allows(rules, EmailNotificationTypes.UnreadMessage, recipient)) { continue; @@ -308,6 +316,9 @@ private static IEnumerable GetAssignedOperators(Ticket ticket, return ticket.OperatorAssignments .Where(x => x.OperatorUser != null && x.AssignedAt <= messageCreatedAt) .Select(x => x.OperatorUser!) + .Concat(ticket.OperatorUser != null && ticket.OperatorAssignments.Count == 0 + ? [ticket.OperatorUser] + : []) .GroupBy(x => x.Id) .Select(x => x.First()); } diff --git a/TechSupportRagBot/Services/QAService.cs b/TechSupportRagBot/Services/QAService.cs index e37f859..5c0726e 100644 --- a/TechSupportRagBot/Services/QAService.cs +++ b/TechSupportRagBot/Services/QAService.cs @@ -71,6 +71,7 @@ public async Task UpdateAsync(int id, QAEntry input, CancellationToken can entry.Category = input.Category; entry.ProblemType = input.ProblemType; entry.Status = input.Status; + entry.AvailableToClients = input.AvailableToClients; entry.Source = input.Source; entry.UpdatedAt = DateTime.UtcNow; await _db.SaveChangesAsync(cancellationToken); @@ -234,6 +235,9 @@ public async Task ReindexAsync(int id, CancellationToken cancellationToken _db.KnowledgeChunks.RemoveRange(entry.Chunks); await _db.SaveChangesAsync(cancellationToken); + if (entry.Status != QAEntryStatuses.Verified || !entry.AvailableToClients) + return true; + var searchText = BuildSearchText(entry); var chunk = new KnowledgeChunk { diff --git a/TechSupportRagBot/Services/RagAuditLogger.cs b/TechSupportRagBot/Services/RagAuditLogger.cs index 04645f3..2a9a879 100644 --- a/TechSupportRagBot/Services/RagAuditLogger.cs +++ b/TechSupportRagBot/Services/RagAuditLogger.cs @@ -1,5 +1,6 @@ using System.Text.Encodings.Web; using System.Text.Json; +using System.Text.Json.Nodes; namespace TechSupportRagBot.Services; @@ -7,6 +8,12 @@ public class RagAuditLogger { private const int MaxTextLength = 50_000; private static readonly SemaphoreSlim FileLock = new(1, 1); + private static DateOnly _lastCleanupDate; + private static readonly HashSet SensitiveFields = new(StringComparer.OrdinalIgnoreCase) + { + "question", "originalQuestion", "text", "searchText", "prompt", "context", + "answer", "solution", "cause", "message", "input", "output", "preview" + }; private readonly IWebHostEnvironment _environment; private readonly ILogger _logger; @@ -48,6 +55,7 @@ public async Task WriteAsync( try { await File.AppendAllTextAsync(filePath, line, cancellationToken); + CleanupOldLogs(directory); } finally { @@ -62,21 +70,51 @@ public async Task WriteAsync( private static object Sanitize(object payload) { - var json = JsonSerializer.Serialize(payload, new JsonSerializerOptions - { - Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping - }); + var node = JsonSerializer.SerializeToNode(payload); + Redact(node); + var json = node?.ToJsonString() ?? "null"; + return json.Length <= MaxTextLength + ? node ?? new JsonObject() + : new { truncated = true, redactedJsonLength = json.Length }; + } - if (json.Length <= MaxTextLength) + private static void Redact(JsonNode? node) + { + if (node is JsonObject obj) { - return payload; + foreach (var item in obj.ToList()) + { + if (SensitiveFields.Contains(item.Key) + || item.Key.EndsWith("Text", StringComparison.OrdinalIgnoreCase) + || item.Key.EndsWith("Content", StringComparison.OrdinalIgnoreCase) + || item.Key.EndsWith("Prompt", StringComparison.OrdinalIgnoreCase) + || item.Key.EndsWith("Question", StringComparison.OrdinalIgnoreCase) + || item.Key.EndsWith("Answer", StringComparison.OrdinalIgnoreCase)) + { + obj[item.Key] = new JsonObject { ["redacted"] = true }; + } + else + { + Redact(item.Value); + } + } } + else if (node is JsonArray array) + { + foreach (var item in array) Redact(item); + } + } - return new + private static void CleanupOldLogs(string directory) + { + var today = DateOnly.FromDateTime(DateTime.UtcNow); + if (_lastCleanupDate == today) return; + _lastCleanupDate = today; + foreach (var path in Directory.EnumerateFiles(directory, "rag-audit-*.jsonl")) { - truncated = true, - originalJsonLength = json.Length, - preview = json[..MaxTextLength] - }; + var name = Path.GetFileNameWithoutExtension(path); + if (name.Length < 10 || !DateOnly.TryParseExact(name[^10..], "yyyy-MM-dd", out var date)) continue; + if (date < today.AddDays(-14)) File.Delete(path); + } } } diff --git a/TechSupportRagBot/Services/RagSearchService.cs b/TechSupportRagBot/Services/RagSearchService.cs index 027d8ed..38078b0 100644 --- a/TechSupportRagBot/Services/RagSearchService.cs +++ b/TechSupportRagBot/Services/RagSearchService.cs @@ -506,6 +506,14 @@ private async Task> LoadCandidatesAsync( var filteredOut = new List(); foreach (var chunk in chunks) { + if (chunk.QAEntryId.HasValue + && (!string.Equals(chunk.QAEntry?.Status, QAEntryStatuses.Verified, StringComparison.OrdinalIgnoreCase) + || chunk.QAEntry?.AvailableToClients != true)) + { + filteredOut.Add(new { chunk.Id, reason = "QA is not approved for client use" }); + continue; + } + var document = chunk.KnowledgeDocument; var machine = document?.Machine; var chunkMachineId = chunk.MachineId ?? document?.MachineId; diff --git a/TechSupportRagBot/Services/UiText.cs b/TechSupportRagBot/Services/UiText.cs index fecb8c7..c51149a 100644 --- a/TechSupportRagBot/Services/UiText.cs +++ b/TechSupportRagBot/Services/UiText.cs @@ -85,6 +85,15 @@ public static class UiText ["CurrentPassword"] = "Текущий пароль", ["Dashboard"] = "Панель управления", ["Delete"] = "Удалить", + ["DeactivateUser"] = "Отключить доступ", + ["QAStatusDraft"] = "Черновик", + ["QAStatusVerified"] = "Проверено", + ["QAStatusNeedsReview"] = "Требует проверки", + ["QAStatusDeprecated"] = "Устарело", + ["QACreateImportTools"] = "Создать или импортировать QA", + ["QAAvailableToClients"] = "Разрешить клиентскому боту", + ["QAAvailableToClientsHelp"] = "Бот использует запись только после проверки и явного разрешения.", + ["OneTimePasswordNotice"] = "Временный пароль показан только сейчас. Передайте его пользователю безопасным способом.", ["DeleteFile"] = "Удалить файл", ["DeleteMessage"] = "Удалить сообщение", ["Description"] = "Описание", @@ -525,7 +534,7 @@ public static class UiText ["Username"] = "Логин", ["VideoFailed"] = "Ошибка отправки", ["VideoProcessing"] = "Видео обрабатывается...", - ["Verify"] = "Пометить Verified", + ["Verify"] = "Подтвердить", ["WaitingForOperator"] = "Ждут оператора", ["Workplace"] = "Рабочий стол", ["WorkdayEnd"] = "Рабочий день до", @@ -712,6 +721,15 @@ public static class UiText ["CurrentPassword"] = "Current password", ["Dashboard"] = "Dashboard", ["Delete"] = "Delete", + ["DeactivateUser"] = "Disable access", + ["QAStatusDraft"] = "Draft", + ["QAStatusVerified"] = "Verified", + ["QAStatusNeedsReview"] = "Needs review", + ["QAStatusDeprecated"] = "Deprecated", + ["QACreateImportTools"] = "Create or import QA", + ["QAAvailableToClients"] = "Allow client bot to use this", + ["QAAvailableToClientsHelp"] = "The bot uses this entry only after verification and explicit approval.", + ["OneTimePasswordNotice"] = "This temporary password is shown only once. Share it with the user securely.", ["DeleteFile"] = "Delete file", ["DeleteMessage"] = "Delete message", ["Description"] = "Description", diff --git a/TechSupportRagBot/Services/VideoProcessingBackgroundService.cs b/TechSupportRagBot/Services/VideoProcessingBackgroundService.cs index 0876db6..2b5a648 100644 --- a/TechSupportRagBot/Services/VideoProcessingBackgroundService.cs +++ b/TechSupportRagBot/Services/VideoProcessingBackgroundService.cs @@ -27,10 +27,36 @@ public VideoProcessingBackgroundService( protected override async Task ExecuteAsync(CancellationToken stoppingToken) { + await RecoverPendingAsync(stoppingToken); while (!stoppingToken.IsCancellationRequested) { - var task = await _queue.DequeueAsync(stoppingToken); - await ProcessOneAsync(task.AttachmentId, stoppingToken); + using var polling = CancellationTokenSource.CreateLinkedTokenSource(stoppingToken); + polling.CancelAfter(TimeSpan.FromSeconds(30)); + try + { + var task = await _queue.DequeueAsync(polling.Token); + await ProcessOneAsync(task.AttachmentId, stoppingToken); + } + catch (OperationCanceledException) when (!stoppingToken.IsCancellationRequested) + { + await RecoverPendingAsync(stoppingToken); + } + } + } + + private async Task RecoverPendingAsync(CancellationToken cancellationToken) + { + await using var scope = _scopeFactory.CreateAsyncScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var ids = await db.Attachments.AsNoTracking() + .Where(x => x.Status == AttachmentStatuses.Processing && x.TempFilePath != null) + .OrderBy(x => x.Id) + .Select(x => x.Id) + .Take(100) + .ToListAsync(cancellationToken); + foreach (var id in ids) + { + await ProcessOneAsync(id, cancellationToken); } } @@ -43,7 +69,9 @@ private async Task ProcessOneAsync(int attachmentId, CancellationToken cancellat var attachment = await db.Attachments .Include(x => x.ChatMessage) .FirstOrDefaultAsync(x => x.Id == attachmentId, cancellationToken); - if (attachment?.ChatMessage == null) + if (attachment?.ChatMessage == null + || attachment.Status != AttachmentStatuses.Processing + || string.IsNullOrWhiteSpace(attachment.TempFilePath)) { return; } @@ -77,6 +105,10 @@ private async Task ProcessOneAsync(int attachmentId, CancellationToken cancellat await NotifyProgressAsync(ticketId, attachment, 100, "Видео готово"); await NotifyAsync(ticketId, "VideoReady", attachment); } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } catch (Exception ex) { _logger.LogError(ex, "Video processing failed. AttachmentId={AttachmentId}", attachmentId); diff --git a/TechSupportRagBot/TechSupportRagBot.csproj b/TechSupportRagBot/TechSupportRagBot.csproj index a8daff8..3e0a5fe 100644 --- a/TechSupportRagBot/TechSupportRagBot.csproj +++ b/TechSupportRagBot/TechSupportRagBot.csproj @@ -1,7 +1,7 @@  - net9.0 + net10.0 enable enable aspnet-TechSupportRagBot-ceb17f89-7314-43e5-b5f5-07a714f17897 @@ -13,12 +13,14 @@ - - - - - - + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive all diff --git a/TechSupportRagBot/wwwroot/css/dispatch.css b/TechSupportRagBot/wwwroot/css/dispatch.css index 9ec89bc..b306eaa 100644 --- a/TechSupportRagBot/wwwroot/css/dispatch.css +++ b/TechSupportRagBot/wwwroot/css/dispatch.css @@ -160,6 +160,10 @@ .dispatch-theme .tickets-focus-actions .tickets-open-chat,.dispatch-theme .tickets-focus-actions .row-action{width:100%} .dispatch-theme .tickets-focus-actions .row-action button{width:100%} } +.dispatch-theme .qa-tools{margin-bottom:18px} +.dispatch-theme .qa-tools>summary{cursor:pointer;font-weight:700;line-height:1.5} +.dispatch-theme .qa-tools>summary:focus-visible{outline:2px solid currentColor;outline-offset:4px} +.dispatch-theme .qa-tools[open]>.knowledge-layout{margin-top:16px} /* Один принцип выбора для QA, решений, поиска и списков обращений. */ .dispatch-theme .focus-list-workspace{display:grid;grid-template-columns:230px minmax(0,1fr);gap:16px;min-height:0;height:clamp(420px,calc(100dvh - 300px),900px)} diff --git a/docker-compose.yml b/docker-compose.yml index 15881a0..9c4c916 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -6,7 +6,7 @@ services: env_file: - .env ports: - - "5028:8080" + - "127.0.0.1:5028:8080" volumes: - app_data:/app/App_Data - app_uploads:/app/App_Data/Uploads @@ -28,7 +28,7 @@ services: POSTGRES_USER: ${POSTGRES_USER:-techsupport} POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-change_me_strong_password} ports: - - "55432:5432" + - "127.0.0.1:55432:5432" volumes: - postgres_data:/var/lib/postgresql/data healthcheck: @@ -41,8 +41,8 @@ services: qdrant: image: qdrant/qdrant:v1.11.5 ports: - - "6333:6333" - - "6334:6334" + - "127.0.0.1:6333:6333" + - "127.0.0.1:6334:6334" volumes: - qdrant_data:/qdrant/storage restart: unless-stopped @@ -57,7 +57,7 @@ services: - "5000" - "--update-models" ports: - - "5000:5000" + - "127.0.0.1:5000:5000" healthcheck: test: ["CMD-SHELL", "./venv/bin/python scripts/healthcheck.py"] interval: 10s @@ -73,7 +73,7 @@ services: profiles: - ollama ports: - - "11434:11434" + - "127.0.0.1:11434:11434" volumes: - ollama_data:/root/.ollama restart: unless-stopped diff --git a/dotnet-tools.json b/dotnet-tools.json index 44a2667..82492e8 100644 --- a/dotnet-tools.json +++ b/dotnet-tools.json @@ -3,11 +3,11 @@ "isRoot": true, "tools": { "dotnet-ef": { - "version": "9.0.16", + "version": "10.0.13", "commands": [ "dotnet-ef" ], "rollForward": false } } -} \ No newline at end of file +} From d8d17e08d47a5248b66fe56adb1528c9f40b43bf Mon Sep 17 00:00:00 2001 From: nikostap <73756780+nikostap@users.noreply.github.com> Date: Wed, 21 Oct 2026 14:12:47 +0300 Subject: [PATCH 2/9] Verify production baseline and add CI checks --- .github/workflows/dotnet.yml | 16 ++++++++++++++++ AUDIT_REMEDIATION.md | 8 +++++--- PROJECT_MAP.md | 1 + 3 files changed, 22 insertions(+), 3 deletions(-) create mode 100644 .github/workflows/dotnet.yml diff --git a/.github/workflows/dotnet.yml b/.github/workflows/dotnet.yml new file mode 100644 index 0000000..f60b6d2 --- /dev/null +++ b/.github/workflows/dotnet.yml @@ -0,0 +1,16 @@ +name: .NET build and tests + +on: + push: + pull_request: + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-dotnet@v4 + with: + dotnet-version: 10.0.401 + - run: dotnet restore TechSupportRagBot.sln + - run: dotnet test TechSupportRagBot.sln --configuration Release --no-restore diff --git a/AUDIT_REMEDIATION.md b/AUDIT_REMEDIATION.md index 842d8bd..aeff919 100644 --- a/AUDIT_REMEDIATION.md +++ b/AUDIT_REMEDIATION.md @@ -24,16 +24,18 @@ - [x] Завершить перевод статусов и служебных элементов QA через `UiText.cs`; проверить RU и EN. - [x] Сократить прокрутку до списка QA на мобильном экране. - [x] Ограничить доверенные прокси и сделать базовую Compose-конфигурацию безопасной при запуске без production override. +- [x] Добавить проверку сборки и тестов при отправке изменений в Git. ## Проверка -- [ ] Завершить отдельные тесты миграций, статусов QA, восстановления видео и динамического прочтения сообщений. +- [x] Проверить миграции на отдельной копии рабочей PostgreSQL-базы. +- [ ] Завершить отдельные тесты статусов QA, восстановления видео и динамического прочтения сообщений. - [ ] Полный набор тестов, сборка, проверка уязвимых пакетов, просмотр основных страниц и мобильной версии. - [ ] Обновить `PROJECT_MAP.md`, сверить сервер с Git, опубликовать и проверить рабочий сайт. ## Промежуточная проверка - 54 теста прошли локально после перехода на .NET 10. Новые миграции видны в списке EF, но на рабочей базе ещё не применялись. -- SQL всех трёх новых миграций успешно сгенерирован для PostgreSQL. Выполнение на отдельной копии базы и проверка опубликованных страниц ещё требуются. +- Все три миграции успешно применились к отдельной копии рабочей PostgreSQL-базы; перенос отметок прочтения добавил 40 записей. Временная база и SQL-файл удалены. Проверка опубликованных страниц ещё требуется. - Проверка NuGet больше не отмечает SQLite и PKCS. Остался AutoMapper 10.1.1, приходящий через `FastText.NetWrapper`; совместимая защищённая версия AutoMapper 15.1.1 требует лицензию и имеет несовместимые изменения API. Нужна проверенная замена библиотеки распознавания языка. -- Исходники на сервере развернуты без каталога `.git`; сравнение выбранных базовых файлов с текущим `HEAD` не обнаружило содержательных различий. Публикация требует резервной копии БД и отдельной проверки миграций и доступа к QA. +- Исходники на сервере развернуты без каталога `.git`; все 305 отслеживаемых файлов базовой ветки сравнены по SHA-256 с рабочим сервером после нормализации окончаний строк. Пропусков и содержательных различий нет. Публикация требует резервной копии БД и проверки доступа к QA после обновления. diff --git a/PROJECT_MAP.md b/PROJECT_MAP.md index edfa759..90e8c31 100644 --- a/PROJECT_MAP.md +++ b/PROJECT_MAP.md @@ -205,6 +205,7 @@ - `docker-compose.yml`: app, PostgreSQL, Qdrant, LibreTranslate и опциональный Ollama. Опубликованные порты по умолчанию слушают только `127.0.0.1`; внешний доступ к сайту организуется доверенным reverse proxy. - Docker собирает проект SDK .NET 10, зафиксированным через `global.json`, и запускает целевое приложение `net10.0` на runtime .NET 10. Пакеты ASP.NET Core/EF обновлены до 10.0.13, Npgsql до 10.0.3, инструмент `dotnet-ef` до 10.0.13. +- При отправке ветки и создании pull request `.github/workflows/dotnet.yml` выполняет восстановление пакетов и тесты решения на .NET 10. - `.env`: реальные секреты; не коммитить и не показывать в логах. - При локальном `dotnet run` приложение загружает `.env` из каталога решения или проекта до построения конфигурации; уже заданные системные переменные имеют приоритет. Docker-адреса `postgres`, `qdrant`, `ollama` и `libretranslate` для локального процесса преобразуются в `localhost` (PostgreSQL — порт `55432`). В Docker значения по-прежнему передаются через `env_file` без преобразования. - Вложения по умолчанию хранятся локально в `/app/App_Data/Uploads` (`chat`, `qa`, `avatars`, `videos`), а не в `wwwroot`; запросы к старому `/uploads` возвращают 404. From 1f833a04b1ee126236eaa725e3dfc66572ef19f2 Mon Sep 17 00:00:00 2001 From: nikostap <73756780+nikostap@users.noreply.github.com> Date: Wed, 21 Oct 2026 14:14:07 +0300 Subject: [PATCH 3/9] Document blocked GitHub Actions check --- AUDIT_REMEDIATION.md | 1 + 1 file changed, 1 insertion(+) diff --git a/AUDIT_REMEDIATION.md b/AUDIT_REMEDIATION.md index aeff919..9f0a993 100644 --- a/AUDIT_REMEDIATION.md +++ b/AUDIT_REMEDIATION.md @@ -38,4 +38,5 @@ - 54 теста прошли локально после перехода на .NET 10. Новые миграции видны в списке EF, но на рабочей базе ещё не применялись. - Все три миграции успешно применились к отдельной копии рабочей PostgreSQL-базы; перенос отметок прочтения добавил 40 записей. Временная база и SQL-файл удалены. Проверка опубликованных страниц ещё требуется. - Проверка NuGet больше не отмечает SQLite и PKCS. Остался AutoMapper 10.1.1, приходящий через `FastText.NetWrapper`; совместимая защищённая версия AutoMapper 15.1.1 требует лицензию и имеет несовместимые изменения API. Нужна проверенная замена библиотеки распознавания языка. +- GitHub Actions не запускает созданную проверку: GitHub вернул «account is locked due to a billing issue». Локальный запуск всех 54 тестов успешен; после решения вопроса с биллингом следует повторить проверку pull request. - Исходники на сервере развернуты без каталога `.git`; все 305 отслеживаемых файлов базовой ветки сравнены по SHA-256 с рабочим сервером после нормализации окончаний строк. Пропусков и содержательных различий нет. Публикация требует резервной копии БД и проверки доступа к QA после обновления. From a9e4997057356a74adb092dec61da907aa0fb02d Mon Sep 17 00:00:00 2001 From: nikostap <73756780+nikostap@users.noreply.github.com> Date: Wed, 21 Oct 2026 14:14:57 +0300 Subject: [PATCH 4/9] Record QA rollout impact --- AUDIT_REMEDIATION.md | 1 + 1 file changed, 1 insertion(+) diff --git a/AUDIT_REMEDIATION.md b/AUDIT_REMEDIATION.md index 9f0a993..191bc62 100644 --- a/AUDIT_REMEDIATION.md +++ b/AUDIT_REMEDIATION.md @@ -37,6 +37,7 @@ - 54 теста прошли локально после перехода на .NET 10. Новые миграции видны в списке EF, но на рабочей базе ещё не применялись. - Все три миграции успешно применились к отдельной копии рабочей PostgreSQL-базы; перенос отметок прочтения добавил 40 записей. Временная база и SQL-файл удалены. Проверка опубликованных страниц ещё требуется. +- На рабочем сайте 120 QA-записей со статусом `Verified`. После публикации новой границы доступа все 120 перестанут участвовать в ответах бота до проверки и включения разрешения администратором. Это изменение требует согласовать порядок выпуска с владельцем сайта. - Проверка NuGet больше не отмечает SQLite и PKCS. Остался AutoMapper 10.1.1, приходящий через `FastText.NetWrapper`; совместимая защищённая версия AutoMapper 15.1.1 требует лицензию и имеет несовместимые изменения API. Нужна проверенная замена библиотеки распознавания языка. - GitHub Actions не запускает созданную проверку: GitHub вернул «account is locked due to a billing issue». Локальный запуск всех 54 тестов успешен; после решения вопроса с биллингом следует повторить проверку pull request. - Исходники на сервере развернуты без каталога `.git`; все 305 отслеживаемых файлов базовой ветки сравнены по SHA-256 с рабочим сервером после нормализации окончаний строк. Пропусков и содержательных различий нет. Публикация требует резервной копии БД и проверки доступа к QA после обновления. From bd4c2c97ebd08c1803278222d9556a225173d904 Mon Sep 17 00:00:00 2001 From: nikostap <73756780+nikostap@users.noreply.github.com> Date: Wed, 21 Oct 2026 14:17:21 +0300 Subject: [PATCH 5/9] Keep audit logging failures from affecting chat --- TechSupportRagBot/Services/RagAuditLogger.cs | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/TechSupportRagBot/Services/RagAuditLogger.cs b/TechSupportRagBot/Services/RagAuditLogger.cs index 2a9a879..2d0f34a 100644 --- a/TechSupportRagBot/Services/RagAuditLogger.cs +++ b/TechSupportRagBot/Services/RagAuditLogger.cs @@ -35,16 +35,15 @@ public async Task WriteAsync( string? traceId = null, CancellationToken cancellationToken = default) { - var entry = new - { - timestamp = DateTimeOffset.Now, - traceId, - eventName, - payload = Sanitize(payload) - }; - try { + var entry = new + { + timestamp = DateTimeOffset.Now, + traceId, + eventName, + payload = Sanitize(payload) + }; var directory = Path.Combine(_environment.ContentRootPath, "App_Data", "RagLogs"); Directory.CreateDirectory(directory); @@ -109,12 +108,12 @@ private static void CleanupOldLogs(string directory) { var today = DateOnly.FromDateTime(DateTime.UtcNow); if (_lastCleanupDate == today) return; - _lastCleanupDate = today; foreach (var path in Directory.EnumerateFiles(directory, "rag-audit-*.jsonl")) { var name = Path.GetFileNameWithoutExtension(path); if (name.Length < 10 || !DateOnly.TryParseExact(name[^10..], "yyyy-MM-dd", out var date)) continue; if (date < today.AddDays(-14)) File.Delete(path); } + _lastCleanupDate = today; } } From 034def29262c7d1607ec75cabbd5523aedcda61d Mon Sep 17 00:00:00 2001 From: nikostap <73756780+nikostap@users.noreply.github.com> Date: Wed, 21 Oct 2026 14:18:55 +0300 Subject: [PATCH 6/9] Add QA approval review filter --- AUDIT_REMEDIATION.md | 1 + PROJECT_MAP.md | 2 +- TechSupportRagBot/Pages/Admin/QA.cshtml | 6 ++++++ TechSupportRagBot/Pages/Admin/QA.cshtml.cs | 8 +++++++- TechSupportRagBot/Services/UiText.cs | 8 ++++++++ TechSupportRagBot/wwwroot/css/site.css | 2 +- 6 files changed, 24 insertions(+), 3 deletions(-) diff --git a/AUDIT_REMEDIATION.md b/AUDIT_REMEDIATION.md index 191bc62..4f51136 100644 --- a/AUDIT_REMEDIATION.md +++ b/AUDIT_REMEDIATION.md @@ -23,6 +23,7 @@ - [x] Завершить перевод статусов и служебных элементов QA через `UiText.cs`; проверить RU и EN. - [x] Сократить прокрутку до списка QA на мобильном экране. +- [x] Добавить на странице QA фильтр записей, ожидающих разрешения для клиентского бота, чтобы можно было проверить старые записи по одной. - [x] Ограничить доверенные прокси и сделать базовую Compose-конфигурацию безопасной при запуске без production override. - [x] Добавить проверку сборки и тестов при отправке изменений в Git. diff --git a/PROJECT_MAP.md b/PROJECT_MAP.md index 90e8c31..3dee250 100644 --- a/PROJECT_MAP.md +++ b/PROJECT_MAP.md @@ -133,7 +133,7 @@ - Отладочный endpoint: `Controllers/SearchDebugController.cs`. - Аудит RAG: `RagAuditLogger`. - В новых RAG-журналах текст запроса, ответа и контекста заменяется отметкой об удалении; файлы старше 14 дней очищаются при следующей записи. -- Не проверенные QA (`Draft`, `NeedsReview`, `Deprecated`) исключаются из кандидатов RAG независимо от оставшихся в индексе точек. Для клиентского бота также требуется явное разрешение `QAEntry.AvailableToClients` на форме QA (`20261021113000_AddQaClientApproval`); существующие записи после миграции закрыты до индивидуальной проверки. Ручная QA-запись по умолчанию создаётся черновиком. +- Не проверенные QA (`Draft`, `NeedsReview`, `Deprecated`) исключаются из кандидатов RAG независимо от оставшихся в индексе точек. Для клиентского бота также требуется явное разрешение `QAEntry.AvailableToClients` на форме QA (`20261021113000_AddQaClientApproval`); существующие записи после миграции закрыты до индивидуальной проверки. Фильтр доступности на `/Admin/QA` помогает находить ожидающие разрешения записи. Ручная QA-запись по умолчанию создаётся черновиком. - QA без модели и серийного номера считается глобальным. QA с моделью, но без номера применяется ко всем номерам этой модели и совместимым сериям модели. Поле номера принимает только целое число или диапазон `число-число`; одно- и двухзначные значения приводятся к трём знакам (например, `5-30` → `005-030`), любое другое значение очищается и означает «для всех номеров». ## Провайдеры моделей diff --git a/TechSupportRagBot/Pages/Admin/QA.cshtml b/TechSupportRagBot/Pages/Admin/QA.cshtml index 8ac642c..c9d03e2 100644 --- a/TechSupportRagBot/Pages/Admin/QA.cshtml +++ b/TechSupportRagBot/Pages/Admin/QA.cshtml @@ -318,12 +318,18 @@ } +
+ diff --git a/TechSupportRagBot/Pages/Admin/QA.cshtml.cs b/TechSupportRagBot/Pages/Admin/QA.cshtml.cs index 1b15172..ffe7bbb 100644 --- a/TechSupportRagBot/Pages/Admin/QA.cshtml.cs +++ b/TechSupportRagBot/Pages/Admin/QA.cshtml.cs @@ -55,6 +55,9 @@ public QAModel( [BindProperty(SupportsGet = true)] public string? StatusFilter { get; set; } + [BindProperty(SupportsGet = true)] + public string? ApprovalFilter { get; set; } + [BindProperty(SupportsGet = true)] public string? MachineFilter { get; set; } @@ -322,7 +325,7 @@ public async Task OnPostBulkAsync(string bulkAction, Cancellation } } - return RedirectToPage(new { StatusFilter, MachineFilter, SerialFilter, SearchQuery, SearchMode, SortOrder }); + return RedirectToPage(new { StatusFilter, ApprovalFilter, MachineFilter, SerialFilter, SearchQuery, SearchMode, SortOrder }); } public async Task OnPostDeleteAttachmentAsync(int id, int attachmentId, CancellationToken cancellationToken) @@ -342,6 +345,9 @@ private async Task LoadAsync() query = query.Where(x => x.Status == StatusFilter); } + if (ApprovalFilter == "pending") query = query.Where(x => !x.AvailableToClients); + else if (ApprovalFilter == "allowed") query = query.Where(x => x.AvailableToClients); + if (!string.IsNullOrWhiteSpace(MachineFilter)) { query = query.Where(x => x.MachineModel == MachineFilter); diff --git a/TechSupportRagBot/Services/UiText.cs b/TechSupportRagBot/Services/UiText.cs index c51149a..77ab3f1 100644 --- a/TechSupportRagBot/Services/UiText.cs +++ b/TechSupportRagBot/Services/UiText.cs @@ -93,6 +93,10 @@ public static class UiText ["QACreateImportTools"] = "Создать или импортировать QA", ["QAAvailableToClients"] = "Разрешить клиентскому боту", ["QAAvailableToClientsHelp"] = "Бот использует запись только после проверки и явного разрешения.", + ["QAApprovalFilter"] = "Доступность для клиентского бота", + ["QAApprovalAll"] = "Все записи по доступности", + ["QAApprovalPending"] = "Ожидают разрешения", + ["QAApprovalAllowed"] = "Разрешены клиентскому боту", ["OneTimePasswordNotice"] = "Временный пароль показан только сейчас. Передайте его пользователю безопасным способом.", ["DeleteFile"] = "Удалить файл", ["DeleteMessage"] = "Удалить сообщение", @@ -729,6 +733,10 @@ public static class UiText ["QACreateImportTools"] = "Create or import QA", ["QAAvailableToClients"] = "Allow client bot to use this", ["QAAvailableToClientsHelp"] = "The bot uses this entry only after verification and explicit approval.", + ["QAApprovalFilter"] = "Client bot availability", + ["QAApprovalAll"] = "All availability states", + ["QAApprovalPending"] = "Awaiting approval", + ["QAApprovalAllowed"] = "Available to client bot", ["OneTimePasswordNotice"] = "This temporary password is shown only once. Share it with the user securely.", ["DeleteFile"] = "Delete file", ["DeleteMessage"] = "Delete message", diff --git a/TechSupportRagBot/wwwroot/css/site.css b/TechSupportRagBot/wwwroot/css/site.css index 9d05fc6..f83a8ce 100644 --- a/TechSupportRagBot/wwwroot/css/site.css +++ b/TechSupportRagBot/wwwroot/css/site.css @@ -1824,7 +1824,7 @@ details[open] > .ticket-accordion-summary .accordion-chevron { } .qa-filter-options-row { - grid-template-columns: repeat(4, minmax(160px, 1fr)); + grid-template-columns: repeat(5, minmax(150px, 1fr)); } .qa-search-filter input, From 41eaa66fa42e5d747754755bb2e1c11f278f90e5 Mon Sep 17 00:00:00 2001 From: nikostap <73756780+nikostap@users.noreply.github.com> Date: Wed, 21 Oct 2026 14:28:38 +0300 Subject: [PATCH 7/9] Document pre-release QA content review --- AUDIT_REMEDIATION.md | 1 + QA_REVIEW_2026-10-21.md | 21 +++++++++++++++++++++ 2 files changed, 22 insertions(+) create mode 100644 QA_REVIEW_2026-10-21.md diff --git a/AUDIT_REMEDIATION.md b/AUDIT_REMEDIATION.md index 4f51136..d869879 100644 --- a/AUDIT_REMEDIATION.md +++ b/AUDIT_REMEDIATION.md @@ -39,6 +39,7 @@ - 54 теста прошли локально после перехода на .NET 10. Новые миграции видны в списке EF, но на рабочей базе ещё не применялись. - Все три миграции успешно применились к отдельной копии рабочей PostgreSQL-базы; перенос отметок прочтения добавил 40 записей. Временная база и SQL-файл удалены. Проверка опубликованных страниц ещё требуется. - На рабочем сайте 120 QA-записей со статусом `Verified`. После публикации новой границы доступа все 120 перестанут участвовать в ответах бота до проверки и включения разрешения администратором. Это изменение требует согласовать порядок выпуска с владельцем сайта. +- Все 120 вопросов и ответов прочитаны; результат и перечень записей, требующих особого внимания, сохранены в `QA_REVIEW_2026-10-21.md`. Три ответа содержат сервисные пароли, остальные технические процедуры пока не сверены с утверждённым руководством. Автоматическое разрешение старых записей не выполняется. - Проверка NuGet больше не отмечает SQLite и PKCS. Остался AutoMapper 10.1.1, приходящий через `FastText.NetWrapper`; совместимая защищённая версия AutoMapper 15.1.1 требует лицензию и имеет несовместимые изменения API. Нужна проверенная замена библиотеки распознавания языка. - GitHub Actions не запускает созданную проверку: GitHub вернул «account is locked due to a billing issue». Локальный запуск всех 54 тестов успешен; после решения вопроса с биллингом следует повторить проверку pull request. - Исходники на сервере развернуты без каталога `.git`; все 305 отслеживаемых файлов базовой ветки сравнены по SHA-256 с рабочим сервером после нормализации окончаний строк. Пропусков и содержательных различий нет. Публикация требует резервной копии БД и проверки доступа к QA после обновления. diff --git a/QA_REVIEW_2026-10-21.md b/QA_REVIEW_2026-10-21.md new file mode 100644 index 0000000..d4cee0e --- /dev/null +++ b/QA_REVIEW_2026-10-21.md @@ -0,0 +1,21 @@ +# Проверка существующих QA-записей перед выпуском + +Дата: 2026-10-21. Источник: рабочая база сайта, только чтение. Проверены вопросы и полные ответы всех 120 записей со статусом `Verified` (ID 45–164), а также наличие вложений. В проекте нет утверждённой инструкции ALF-506 для сверки технической правильности процедур. + +## Решение по доступу клиентского бота + +- **ID 45, 159, 160 — не разрешать.** Ответы содержат сервисные пароли. У ID 45 есть изображение; его также надо проверить перед любым показом клиенту. Сами пароли и вложение здесь не воспроизводятся. +- **ID 98 — не разрешать до инженерной правки.** Ответ предлагает проверять сетевое напряжение и блок питания, но не указывает требования к квалификации и безопасному отключению. +- **ID 49, 52, 57, 58, 68, 78, 83, 85, 125–133, 139, 140, 150 — не разрешать до инженерной проверки.** Здесь затронуты цепь безопасности, нож, движущиеся узлы или работа с пневматикой. Часть ответов пропускает безопасную остановку и изоляцию источников энергии. +- **Остальные 96 записей — кандидаты на проверку специалистом.** Среди них есть настройки машины, диагностика датчиков, клеевого узла, натяжения и подачи материала. По тексту не обнаружены явные учётные данные, но корректность параметров и применимость к конкретной машине не подтверждены исходной документацией. + +До такой сверки **не переносить прежнюю отметку `Verified` в `AvailableToClients` автоматически**. После применения миграции все существующие записи останутся закрытыми для клиентского бота; администратор сможет последовательно открыть их через фильтр «Ожидают проверки» на странице QA. Это временно уменьшит число ответов бота, основанных на QA. + +## Что нужно для завершения + +1. Получить утверждённую инструкцию для ALF-506 или провести проверку ответов инженером компании. +2. Исправить либо удалить сервисные пароли из клиентских ответов и отдельно проверить изображение ID 45. +3. Уточнить процедуры электробезопасности и обслуживания ножа, ограждений и пневматики. +4. Для каждой проверенной записи включить разрешение клиентскому боту в административной форме и проверить, что только она попадает в поиск и ответ. + +До завершения проверки публикацию изменений, отключающих доступ к прежним QA-записям, следует согласовать с владельцем сайта отдельно: иначе все 120 записей временно перестанут участвовать в ответах бота. From 235ad342ffb3ef755bd27e0a515b6d0da77aebb0 Mon Sep 17 00:00:00 2001 From: nikostap <73756780+nikostap@users.noreply.github.com> Date: Wed, 21 Oct 2026 14:35:00 +0300 Subject: [PATCH 8/9] Approve reviewed QA content and replace vulnerable language wrapper --- .dockerignore | 1 + AUDIT_REMEDIATION.md | 12 +- PROJECT_MAP.md | 5 +- QA_REVIEW_2026-10-21.md | 17 +- .../LanguageDetectionServiceTests.cs | 39 +++ .../QaAttachmentAccessTests.cs | 113 ++++++++ .../Controllers/AttachmentsController.cs | 6 +- ...20261021114000_ApproveReviewedQaEntries.cs | 263 ++++++++++++++++++ .../Services/LanguageDetectionService.cs | 16 +- .../Services/SupportBotService.cs | 5 +- TechSupportRagBot/TechSupportRagBot.csproj | 2 +- 11 files changed, 447 insertions(+), 32 deletions(-) create mode 100644 TechSupportRagBot.Tests/LanguageDetectionServiceTests.cs create mode 100644 TechSupportRagBot.Tests/QaAttachmentAccessTests.cs create mode 100644 TechSupportRagBot/Migrations/20261021114000_ApproveReviewedQaEntries.cs diff --git a/.dockerignore b/.dockerignore index 3d5e90b..409790e 100644 --- a/.dockerignore +++ b/.dockerignore @@ -5,6 +5,7 @@ **/bin **/obj +**/.audit-publish **/Migrations.SqliteArchive qdrant_storage diff --git a/AUDIT_REMEDIATION.md b/AUDIT_REMEDIATION.md index d869879..f57703b 100644 --- a/AUDIT_REMEDIATION.md +++ b/AUDIT_REMEDIATION.md @@ -16,7 +16,7 @@ - [x] Убрать ограничение просмотра только 300 последних сообщений в обработчике уведомлений и учитывать прочтение для каждого назначенного оператора. - [x] Ограничить объём распакованного содержимого PDF и Office до извлечения и индексации. - [x] Перевести приложение и контейнер на .NET 10, обновить пакеты ASP.NET Core/EF, SQLite и PKCS. -- [ ] Заменить `FastText.NetWrapper` или иным проверенным способом устранить уязвимый транзитивный AutoMapper 10.1.1 без нарушения распознавания языка. +- [x] Заменить `FastText.NetWrapper` на `FastText.Net` без транзитивного AutoMapper; проверить распознавание четырёх языков на рабочей модели. - [x] Ввести срок хранения RAG-журналов и исключить лишний клиентский текст из них. ## 3. Интерфейс и конфигурация @@ -30,16 +30,16 @@ ## Проверка - [x] Проверить миграции на отдельной копии рабочей PostgreSQL-базы. -- [ ] Завершить отдельные тесты статусов QA, восстановления видео и динамического прочтения сообщений. +- [ ] Завершить отдельные тесты восстановления видео и динамического прочтения сообщений. Проверка доступа к QA-вложениям и распознавания языка добавлена. - [ ] Полный набор тестов, сборка, проверка уязвимых пакетов, просмотр основных страниц и мобильной версии. - [ ] Обновить `PROJECT_MAP.md`, сверить сервер с Git, опубликовать и проверить рабочий сайт. ## Промежуточная проверка -- 54 теста прошли локально после перехода на .NET 10. Новые миграции видны в списке EF, но на рабочей базе ещё не применялись. +- 59 тестов прошли локально после перехода на .NET 10 и замены распознавания языка. Новые миграции видны в списке EF, но на рабочей базе ещё не применялись. - Все три миграции успешно применились к отдельной копии рабочей PostgreSQL-базы; перенос отметок прочтения добавил 40 записей. Временная база и SQL-файл удалены. Проверка опубликованных страниц ещё требуется. -- На рабочем сайте 120 QA-записей со статусом `Verified`. После публикации новой границы доступа все 120 перестанут участвовать в ответах бота до проверки и включения разрешения администратором. Это изменение требует согласовать порядок выпуска с владельцем сайта. -- Все 120 вопросов и ответов прочитаны; результат и перечень записей, требующих особого внимания, сохранены в `QA_REVIEW_2026-10-21.md`. Три ответа содержат сервисные пароли, остальные технические процедуры пока не сверены с утверждённым руководством. Автоматическое разрешение старых записей не выполняется. -- Проверка NuGet больше не отмечает SQLite и PKCS. Остался AutoMapper 10.1.1, приходящий через `FastText.NetWrapper`; совместимая защищённая версия AutoMapper 15.1.1 требует лицензию и имеет несовместимые изменения API. Нужна проверенная замена библиотеки распознавания языка. +- На рабочем сайте 120 QA-записей со статусом `Verified`. Все вопросы и ответы прочитаны, владелец подтвердил техническую правильность внесённых им данных. Миграция `20261021114000_ApproveReviewedQaEntries` разрешает 116 неизменившихся записей по ID и контрольной сумме. Три ответа с сервисными паролями и один ответ о проверке сетевого напряжения остаются закрытыми. Подробности — `QA_REVIEW_2026-10-21.md`. +- SQL новой QA-миграции выполнен на рабочей БД внутри транзакции с откатом: 116 записей разрешились, все четыре исключения остались закрытыми. Реальных изменений в рабочей БД не было. +- Проверка NuGet не находит известных уязвимостей в текущих источниках. Уязвимый транзитивный AutoMapper удалён заменой библиотеки распознавания языка. Тесты с локальной моделью проходят для русского, английского, французского и испанского языков. - GitHub Actions не запускает созданную проверку: GitHub вернул «account is locked due to a billing issue». Локальный запуск всех 54 тестов успешен; после решения вопроса с биллингом следует повторить проверку pull request. - Исходники на сервере развернуты без каталога `.git`; все 305 отслеживаемых файлов базовой ветки сравнены по SHA-256 с рабочим сервером после нормализации окончаний строк. Пропусков и содержательных различий нет. Публикация требует резервной копии БД и проверки доступа к QA после обновления. diff --git a/PROJECT_MAP.md b/PROJECT_MAP.md index 3dee250..8a4d677 100644 --- a/PROJECT_MAP.md +++ b/PROJECT_MAP.md @@ -39,7 +39,7 @@ - `/Push?handler=Status|Subscribe|Unsubscribe|ReleaseOtherAccount` — защищённые входом обработчики проверки владельца push-подписки, её явной привязки/отключения и отвязки предыдущей учётной записи на том же устройстве. Изменяющие запросы требуют antiforgery; принимаются только HTTPS-адреса известных браузерных push-служб. - `/ProfileEdit` — редактирование профиля и отдельная смена пароля. - `/attachments/{publicId}` — авторизованная выдача вложения по публичному идентификатору. -- `/attachments/{publicId}/preview` — авторизованная выдача превью. Клиент проверяется по компании тикета, оператор — по назначению на тикет и разрешению `Tickets`, QA и документы базы знаний доступны администратору с разрешением `QA`. Путь из запроса не принимается. +- `/attachments/{publicId}/preview` — авторизованная выдача превью. Клиент проверяется по компании тикета, оператор — по назначению на тикет и разрешению `Tickets`; QA-вложения доступны сотруднику с разрешением `QA` и авторизованному пользователю, если связанная QA-запись подтверждена и явно разрешена клиентскому боту. Документы базы знаний доступны с разрешением `QA`. Путь из запроса не принимается. - Видео в чате загружает метаданные при открытии, показывает индикатор до готовности и сообщение «Видео недоступно» при ошибке источника или длительной безрезультатной загрузке, поддерживает запросы отдельных диапазонов байтов для перемотки. Ответы на видео и превью сохраняются только в частном кэше браузера и перепроверяют доступ при повторном открытии; сервер передаёт данные потоком из Local/S3 без хранения целого видео в памяти. ### Внутренние пользователи @@ -130,10 +130,11 @@ - Гибридный поиск: Qdrant dense search + PostgreSQL FTS + Reciprocal Rank Fusion. - Перед индексацией `DocumentTextExtractor` ограничивает размер входного файла, суммарный распакованный объём Office, число страниц PDF, число строк листа и длину извлечённого текста. - Ответ пользователю: `SupportBotService`. +- Язык сообщения определяет `LanguageDetectionService` по локальной модели `Resources/fasttext/lid.176.ftz` через управляемую библиотеку `FastText.Net`; при сбое или низкой уверенности используется резервное распознавание `ChatTranslationService`. - Отладочный endpoint: `Controllers/SearchDebugController.cs`. - Аудит RAG: `RagAuditLogger`. - В новых RAG-журналах текст запроса, ответа и контекста заменяется отметкой об удалении; файлы старше 14 дней очищаются при следующей записи. -- Не проверенные QA (`Draft`, `NeedsReview`, `Deprecated`) исключаются из кандидатов RAG независимо от оставшихся в индексе точек. Для клиентского бота также требуется явное разрешение `QAEntry.AvailableToClients` на форме QA (`20261021113000_AddQaClientApproval`); существующие записи после миграции закрыты до индивидуальной проверки. Фильтр доступности на `/Admin/QA` помогает находить ожидающие разрешения записи. Ручная QA-запись по умолчанию создаётся черновиком. +- Не проверенные QA (`Draft`, `NeedsReview`, `Deprecated`) исключаются из кандидатов RAG независимо от оставшихся в индексе точек. Для клиентского бота также требуется явное разрешение `QAEntry.AvailableToClients` на форме QA (`20261021113000_AddQaClientApproval`). Из прежних 120 записей `20261021114000_ApproveReviewedQaEntries` разрешает 116 после подтверждения владельцем и проверки неизменности текста; четыре рискованные записи остаются закрытыми. Фильтр доступности на `/Admin/QA` помогает находить ожидающие разрешения записи. Ручная QA-запись по умолчанию создаётся черновиком. - QA без модели и серийного номера считается глобальным. QA с моделью, но без номера применяется ко всем номерам этой модели и совместимым сериям модели. Поле номера принимает только целое число или диапазон `число-число`; одно- и двухзначные значения приводятся к трём знакам (например, `5-30` → `005-030`), любое другое значение очищается и означает «для всех номеров». ## Провайдеры моделей diff --git a/QA_REVIEW_2026-10-21.md b/QA_REVIEW_2026-10-21.md index d4cee0e..3405508 100644 --- a/QA_REVIEW_2026-10-21.md +++ b/QA_REVIEW_2026-10-21.md @@ -1,21 +1,18 @@ # Проверка существующих QA-записей перед выпуском -Дата: 2026-10-21. Источник: рабочая база сайта, только чтение. Проверены вопросы и полные ответы всех 120 записей со статусом `Verified` (ID 45–164), а также наличие вложений. В проекте нет утверждённой инструкции ALF-506 для сверки технической правильности процедур. +Дата: 2026-10-21. Источник: рабочая база сайта, только чтение. Проверены вопросы и полные ответы всех 120 записей со статусом `Verified` (ID 45–164), а также наличие вложений. Владелец сайта подтвердил, что лично внёс все записи и что технические данные верны. ## Решение по доступу клиентского бота - **ID 45, 159, 160 — не разрешать.** Ответы содержат сервисные пароли. У ID 45 есть изображение; его также надо проверить перед любым показом клиенту. Сами пароли и вложение здесь не воспроизводятся. - **ID 98 — не разрешать до инженерной правки.** Ответ предлагает проверять сетевое напряжение и блок питания, но не указывает требования к квалификации и безопасному отключению. -- **ID 49, 52, 57, 58, 68, 78, 83, 85, 125–133, 139, 140, 150 — не разрешать до инженерной проверки.** Здесь затронуты цепь безопасности, нож, движущиеся узлы или работа с пневматикой. Часть ответов пропускает безопасную остановку и изоляцию источников энергии. -- **Остальные 96 записей — кандидаты на проверку специалистом.** Среди них есть настройки машины, диагностика датчиков, клеевого узла, натяжения и подачи материала. По тексту не обнаружены явные учётные данные, но корректность параметров и применимость к конкретной машине не подтверждены исходной документацией. +- **Остальные 116 записей — разрешить после подтверждения владельца.** Оригинальный текст сохраняется. Процедуры с ножом, движущимися узлами, цепью безопасности и пневматикой подтверждены владельцем как технически верные, но дополнение их требованиями безопасного обслуживания остаётся отдельной задачей. -До такой сверки **не переносить прежнюю отметку `Verified` в `AvailableToClients` автоматически**. После применения миграции все существующие записи останутся закрытыми для клиентского бота; администратор сможет последовательно открыть их через фильтр «Ожидают проверки» на странице QA. Это временно уменьшит число ответов бота, основанных на QA. +Миграция `20261021114000_ApproveReviewedQaEntries` разрешает ровно 116 проверенных ответов, сверяя одновременно ID, статус и контрольную сумму вопроса с ответом. Если текст записи успел измениться после проверки, она остаётся закрытой. Четыре записи выше остаются закрытыми. Новые и изменённые после публикации записи требуют отдельного решения администратора через форму QA. ## Что нужно для завершения -1. Получить утверждённую инструкцию для ALF-506 или провести проверку ответов инженером компании. -2. Исправить либо удалить сервисные пароли из клиентских ответов и отдельно проверить изображение ID 45. -3. Уточнить процедуры электробезопасности и обслуживания ножа, ограждений и пневматики. -4. Для каждой проверенной записи включить разрешение клиентскому боту в административной форме и проверить, что только она попадает в поиск и ответ. - -До завершения проверки публикацию изменений, отключающих доступ к прежним QA-записям, следует согласовать с владельцем сайта отдельно: иначе все 120 записей временно перестанут участвовать в ответах бота. +1. Исправить либо удалить сервисные пароли из клиентских ответов и отдельно проверить изображение ID 45. +2. Дополнить ID 98 условиями электробезопасности и проверить их со специалистом. +3. Дополнить инструкции по обслуживанию опасных узлов требованиями безопасной остановки и изоляции энергии. +4. После публикации проверить итоговое количество разрешённых записей (116) и отсутствие четырёх закрытых ответов в клиентском поиске. diff --git a/TechSupportRagBot.Tests/LanguageDetectionServiceTests.cs b/TechSupportRagBot.Tests/LanguageDetectionServiceTests.cs new file mode 100644 index 0000000..7633703 --- /dev/null +++ b/TechSupportRagBot.Tests/LanguageDetectionServiceTests.cs @@ -0,0 +1,39 @@ +using Microsoft.AspNetCore.Hosting; +using Microsoft.Extensions.FileProviders; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; +using TechSupportRagBot.Services; + +namespace TechSupportRagBot.Tests; + +public sealed class LanguageDetectionServiceTests +{ + [Theory] + [InlineData("Здравствуйте! Как настроить давление на станке?", "ru")] + [InlineData("Hello, how can I configure the machine pressure?", "en")] + [InlineData("Bonjour, comment régler la pression de la machine ?", "fr")] + [InlineData("Hola, ¿cómo puedo ajustar la presión de la máquina?", "es")] + public void BundledFastTextModelDetectsSupportMessageLanguage(string message, string expectedCode) + { + var projectRoot = Path.GetFullPath(Path.Combine( + AppContext.BaseDirectory, "../../../../TechSupportRagBot")); + Assert.True(File.Exists(Path.Combine(projectRoot, "Resources/fasttext/lid.176.ftz"))); + var environment = new TestEnvironment { ContentRootPath = projectRoot }; + using var service = new LanguageDetectionService( + environment, + Options.Create(new FastTextLanguageOptions()), + NullLogger.Instance); + + Assert.Equal(expectedCode, service.DetectLanguageCode(message)); + } + + private sealed class TestEnvironment : IWebHostEnvironment + { + public string ApplicationName { get; set; } = "TechSupportRagBot.Tests"; + public IFileProvider WebRootFileProvider { get; set; } = new NullFileProvider(); + public string WebRootPath { get; set; } = string.Empty; + public string EnvironmentName { get; set; } = "Development"; + public string ContentRootPath { get; set; } = string.Empty; + public IFileProvider ContentRootFileProvider { get; set; } = new NullFileProvider(); + } +} diff --git a/TechSupportRagBot.Tests/QaAttachmentAccessTests.cs b/TechSupportRagBot.Tests/QaAttachmentAccessTests.cs new file mode 100644 index 0000000..4fd8b80 --- /dev/null +++ b/TechSupportRagBot.Tests/QaAttachmentAccessTests.cs @@ -0,0 +1,113 @@ +using System.Security.Claims; +using Amazon.Runtime; +using Amazon.S3; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.FileProviders; +using TechSupportRagBot.Controllers; +using TechSupportRagBot.Data; +using TechSupportRagBot.Models; +using TechSupportRagBot.Services; + +namespace TechSupportRagBot.Tests; + +public sealed class QaAttachmentAccessTests +{ + [Fact] + public async Task ClientCanOpenOnlyApprovedAndVerifiedQaAttachment() + { + var root = Path.Combine(Path.GetTempPath(), "TechSupportRagBot.Tests", Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(root); + try + { + await using var connection = new SqliteConnection("Data Source=:memory:"); + await connection.OpenAsync(); + var services = new ServiceCollection(); + services.AddLogging(); + services.AddDbContext(options => options.UseSqlite(connection)); + services.AddIdentityCore().AddRoles() + .AddEntityFrameworkStores(); + services.Configure(options => options.LocalRoot = "App_Data/Uploads"); + services.Configure(_ => { }); + services.AddSingleton(new TestEnvironment { ContentRootPath = root }); + services.AddSingleton(_ => new AmazonS3Client( + new AnonymousAWSCredentials(), + new AmazonS3Config { ServiceURL = "http://127.0.0.1:1", ForcePathStyle = true })); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + await using var provider = services.BuildServiceProvider(); + using var scope = provider.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + await db.Database.EnsureCreatedAsync(); + var userManager = scope.ServiceProvider.GetRequiredService>(); + var client = new ApplicationUser { UserName = "qa-client", AccessProfile = AccessProfileService.Client }; + Assert.True((await userManager.CreateAsync(client)).Succeeded); + + var entry = new QAEntry + { + Question = "Test question", Answer = "Test answer", + Status = QAEntryStatuses.Verified, AvailableToClients = false + }; + var attachment = new QAAttachment + { + QAEntry = entry, OriginalFileName = "test.txt", StoredFileName = "test.txt", + FilePath = "qa/test.txt", StorageProvider = StorageProviderNames.Local, + ContentType = "text/plain", SizeBytes = 7 + }; + db.QAAttachments.Add(attachment); + await db.SaveChangesAsync(); + var storage = scope.ServiceProvider.GetRequiredService(); + await storage.SaveAsync(StorageProviderNames.Local, attachment.FilePath, + new MemoryStream("qa data"u8.ToArray()), attachment.ContentType); + + var principal = new ClaimsPrincipal(new ClaimsIdentity( + [new Claim(ClaimTypes.NameIdentifier, client.Id)], "test")); + async Task<(IActionResult Result, byte[] Body)> FetchAsync() + { + var context = new DefaultHttpContext { User = principal }; + context.Response.Body = new MemoryStream(); + var controller = new AttachmentsController( + db, + scope.ServiceProvider.GetRequiredService(), + scope.ServiceProvider.GetRequiredService(), + storage) + { + ControllerContext = new ControllerContext { HttpContext = context } + }; + var result = await controller.Get(attachment.PublicId, CancellationToken.None); + return (result, ((MemoryStream)context.Response.Body).ToArray()); + } + + Assert.IsType((await FetchAsync()).Result); + entry.AvailableToClients = true; + await db.SaveChangesAsync(); + var approved = await FetchAsync(); + Assert.IsType(approved.Result); + Assert.Equal("qa data"u8.ToArray(), approved.Body); + entry.Status = QAEntryStatuses.NeedsReview; + await db.SaveChangesAsync(); + Assert.IsType((await FetchAsync()).Result); + } + finally + { + Directory.Delete(root, recursive: true); + } + } + + private sealed class TestEnvironment : IWebHostEnvironment + { + public string ApplicationName { get; set; } = "TechSupportRagBot.Tests"; + public IFileProvider WebRootFileProvider { get; set; } = new NullFileProvider(); + public string WebRootPath { get; set; } = string.Empty; + public string EnvironmentName { get; set; } = "Development"; + public string ContentRootPath { get; set; } = string.Empty; + public IFileProvider ContentRootFileProvider { get; set; } = new NullFileProvider(); + } +} diff --git a/TechSupportRagBot/Controllers/AttachmentsController.cs b/TechSupportRagBot/Controllers/AttachmentsController.cs index 49efcbc..512620b 100644 --- a/TechSupportRagBot/Controllers/AttachmentsController.cs +++ b/TechSupportRagBot/Controllers/AttachmentsController.cs @@ -89,10 +89,14 @@ private async Task SendAsync(Guid publicId, bool preview, Cancell var qaAttachment = await _db.QAAttachments .AsNoTracking() + .Include(x => x.QAEntry) .FirstOrDefaultAsync(x => x.PublicId == publicId, cancellationToken); if (qaAttachment != null) { - if (!await _access.IsAllowedAsync(User, "QA", cancellationToken)) + var canManageQa = await _access.IsAllowedAsync(User, "QA", cancellationToken); + var isClientApproved = qaAttachment.QAEntry?.Status == QAEntryStatuses.Verified + && qaAttachment.QAEntry.AvailableToClients; + if (!canManageQa && !isClientApproved) { return NotFound(); } diff --git a/TechSupportRagBot/Migrations/20261021114000_ApproveReviewedQaEntries.cs b/TechSupportRagBot/Migrations/20261021114000_ApproveReviewedQaEntries.cs new file mode 100644 index 0000000..ed2fb24 --- /dev/null +++ b/TechSupportRagBot/Migrations/20261021114000_ApproveReviewedQaEntries.cs @@ -0,0 +1,263 @@ +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using TechSupportRagBot.Data; + +#nullable disable + +namespace TechSupportRagBot.Migrations; + +[DbContext(typeof(ApplicationDbContext))] +[Migration("20261021114000_ApproveReviewedQaEntries")] +public partial class ApproveReviewedQaEntries : Migration +{ + protected override void Up(MigrationBuilder migrationBuilder) => migrationBuilder.Sql(""" + UPDATE "QAEntries" AS qa + SET "AvailableToClients" = TRUE + FROM (VALUES + (46, 'ac5ebc074fe67f4c4b71bf5a7fea34d6'), + (47, '43c612481e28f60d885afffbe2c59ade'), + (48, '909e0d724a8b4c1e7098bcef18e312e8'), + (49, '5706572388e0f6b42811f8a17b5e930c'), + (50, 'd3853485c59a9386d4d3f8cc0c39ace8'), + (51, '8f82df31c0c35cc7a3426c6636c24704'), + (52, '8dff1df9338fc3c81f964c7d1fa463dd'), + (53, 'd9e7e58957709a5bd8d3f10643d689e3'), + (54, '80923a93de64f1b51fe30deca1a49d78'), + (55, '683405c41ba8292abee829ed6ed37aed'), + (56, '8dacdd895be99691e1dd8b7f861fd98c'), + (57, 'f423c94415699d308e2c6299a95f289a'), + (58, 'e9b2f81a019662aa2982a941180782ec'), + (59, '521202e5dc770a166bf78784b13258e5'), + (60, 'fa4258331d9b52f90e424db36e3bf2fd'), + (61, '55e2171619e1db44dbf9cf7bf1528df8'), + (62, '152f91209823759b56631b5758949d0c'), + (63, '489cdd412ffa8778a5f568109483c72d'), + (64, '116db55f252da256123fa79028bbed9e'), + (65, '40130cc0fd96247a5095baf8628ff582'), + (66, '974921c621b3e2be3d1315a76a2c5adc'), + (67, '617be540ac66e4bca7749b43dc68aeba'), + (68, '47be9b5ff5c1563a09b0105b9a6e6c31'), + (69, '7a282da6e78c1e53b2fced99531df9ab'), + (70, '5b9e8b82c822f19ca457b03206485996'), + (71, '5e59edb8027452cc00dc0b980318539c'), + (72, 'bf9cc95f911f2633e0b57ea416e360df'), + (73, '0b99a3600ded014dd5aaa8ea64df086a'), + (74, 'bedf2d99e2f51b2798fdaf5ec3166675'), + (75, '2d4f1e7bbc40b909ebabf387c0fc88b5'), + (76, '85f7921aa3eaaf654a68e89dd5f879ee'), + (77, '04f011d2f0eb7ac3135c366990159561'), + (78, '3797e1987f6333ddd79a16e8e7608291'), + (79, '741d42ae273ed9a3cac062acebc1c4d1'), + (80, '312f34f39ba9c8421b8713175529fe79'), + (81, '39b8498f4138970694f8323c870a53d4'), + (82, '9d4f6793d70f860a1b886a9c2693934a'), + (83, 'fb508b67f923c8b25fb12ac97d699d09'), + (84, 'ae052b7bb24b2fc55fdebeab8b3d1e7c'), + (85, '1191cbf5e5c7e6d6de03dcea60d6b26e'), + (86, 'f8b09853223b9d4b1c48ed2a79542d77'), + (87, '3a5a7992b2ece2f004aaee9b20d0d129'), + (88, '9d81e18de24af907efe49818fddf4ea6'), + (89, 'cbd5a1d54d638aa70832a2ffcec7d07d'), + (90, 'ff9087612af30644c5dab533368a7480'), + (91, 'efc333307ab1a2b65a954fc79179a27b'), + (92, 'ac21619adf740789457992ffe15a99f5'), + (93, '6f579c3ea89e81162e110bac0905fd5d'), + (94, 'ad9c5312732b31ab8efc13cf05ad0aee'), + (95, '947a9eb430776b4d09dc1ca908e31e27'), + (96, '9243ed8fdd4655c2365c3e2266e1af27'), + (97, 'fd9f9b55cb5fb94a073ac88ca007d755'), + (99, 'dabdda9bfb42106d88d44b58884103d5'), + (100, '4142acc5da670b3002fbd15d492219f9'), + (101, '1942005097b05042af7615b79285fa6e'), + (102, '9957f978f1f568a9338ae17813fa6a0e'), + (103, 'a0ba370ef6fef2816e7c6818ec17a6b9'), + (104, '0ab32aef3b89c562fac421c55e5bbaf1'), + (105, '75c25ee7f95a330191667d4f88792da5'), + (106, '6ed9b95e95c3715a4b273f6f695d257e'), + (107, 'a8562dcb47181614b8c3a040e27b8c3f'), + (108, 'c3dd5f5d2e64aea193edb66082893a90'), + (109, '9405e122d6b9fbf7e1d372329745fa83'), + (110, '82723bace0ddb544fbf5950f7ed0f38c'), + (111, 'b969ba0f9763b47985b51b918d9c2ec1'), + (112, '6f768b5b00a24fa648716d8d34ebb314'), + (113, 'f4bb508ac2abaa04accdf210a2bcb73e'), + (114, '704b6c33f75c1fc1f799d1a138f05b53'), + (115, '67780eacdf8644697139b61cf4c7625c'), + (116, '274497cf05bbff79babe797e49fbd716'), + (117, 'f56aa8396f1848878f51017482c2ee1c'), + (118, '0b42011f9e38e9e46ca3368a2429e406'), + (119, '212a12eb05bd2ceeccc761d7da862ad3'), + (120, 'f2af4142f0530f59d2c44bc6ba466da9'), + (121, '766f1e5d72e0a494ad738c936d92b0a1'), + (122, '35662efd21c56f8dd071083b239696c9'), + (123, 'd15fecfd40367d5190ce036b96629da9'), + (124, '0cf9781bdff002c5d507a0f3ba0f6234'), + (125, '850bb0d4a1731fa00f9ea0633a9e64c2'), + (126, '43fe3b3b50d2e7fa7b47523e81d197c6'), + (127, 'abd06dcd547dc29d604a7736dbe9d6ee'), + (128, '8c357087d56bd967e1d7c41c6b61a176'), + (129, 'c8c222bbd38ea2949bcc524f6d840361'), + (130, 'b2da64c367e440aa713cd69e21ca84d8'), + (131, 'cf5263fd0849cbf6e0aac98f4ae8ddce'), + (132, '9ceecb93e50fd8637cd60a92241467c5'), + (133, '9ecc553a5b10c2d2a55a05786cbf13f5'), + (134, '9f830db27feec55a1940ff5e2d8fcff2'), + (135, '6002b6c8b7501d796488affe6f557d15'), + (136, 'b23dd4955e66aea9f2bc004b69e3a202'), + (137, 'cf9d886d4d57595c520efea7b9152001'), + (138, '53afaf5dd071a634d55cdb40bd6d532e'), + (139, '93e566f9a961cf8bff693f4670dccf63'), + (140, '0ce1f11cdefe00c06a45772974a787ce'), + (141, '81c6f6ed1785d09a11926fad880e5658'), + (142, 'dfc79b924bd60bc1439cc64b43ccb5bc'), + (143, 'ba3461f36a4cd2eaa14a33123a0a7816'), + (144, '0d282fbec3287645c943fbbb80bab34e'), + (145, '3187bd9bfc30e67b83d541ea26e9c77f'), + (146, 'a9667c58ac3dadd878b6bc5964d90608'), + (147, '343b70a2d7b82785b50f458de4acf8df'), + (148, 'b2fe6f839db15dffdf8210f41b74bdf3'), + (149, '123246ba855b4d5191faffbb44534809'), + (150, '15cfae5a71ac1fb387959de566eabc0a'), + (151, 'd542cd9e29acc265c2bbb34979e6bf64'), + (152, '5c6bd18e3817115e0876ae46f0779a69'), + (153, 'b801db0e1c30b1f68f288547cbfc2441'), + (154, 'ce11a962837efbcee8fe0e94f559e9f8'), + (155, '3729c8022b22191c99a9f8cfe8bcac3a'), + (156, 'c966a42393f92c63e676c44b3f69d78a'), + (157, '03761a4d37ae502c86457d4ca0e85772'), + (158, 'f402348c912b7f78df42e0272c3d640d'), + (161, 'd8ab36d0ed945bafa692fc8695debfbb'), + (162, '91ae207e5222f8623113ba32e48ddb56'), + (163, '86cbfa1bc9737eba005f10b2e56354c8'), + (164, 'f211eeb92893b68b70a3c9ecca6a8121') + ) AS reviewed(id, digest) + WHERE qa."Id" = reviewed.id + AND qa."Status" = 'Verified' + AND md5(qa."Question" || chr(31) || qa."Answer") = reviewed.digest; + """); + + protected override void Down(MigrationBuilder migrationBuilder) => migrationBuilder.Sql(""" + UPDATE "QAEntries" AS qa + SET "AvailableToClients" = FALSE + FROM (VALUES + (46, 'ac5ebc074fe67f4c4b71bf5a7fea34d6'), + (47, '43c612481e28f60d885afffbe2c59ade'), + (48, '909e0d724a8b4c1e7098bcef18e312e8'), + (49, '5706572388e0f6b42811f8a17b5e930c'), + (50, 'd3853485c59a9386d4d3f8cc0c39ace8'), + (51, '8f82df31c0c35cc7a3426c6636c24704'), + (52, '8dff1df9338fc3c81f964c7d1fa463dd'), + (53, 'd9e7e58957709a5bd8d3f10643d689e3'), + (54, '80923a93de64f1b51fe30deca1a49d78'), + (55, '683405c41ba8292abee829ed6ed37aed'), + (56, '8dacdd895be99691e1dd8b7f861fd98c'), + (57, 'f423c94415699d308e2c6299a95f289a'), + (58, 'e9b2f81a019662aa2982a941180782ec'), + (59, '521202e5dc770a166bf78784b13258e5'), + (60, 'fa4258331d9b52f90e424db36e3bf2fd'), + (61, '55e2171619e1db44dbf9cf7bf1528df8'), + (62, '152f91209823759b56631b5758949d0c'), + (63, '489cdd412ffa8778a5f568109483c72d'), + (64, '116db55f252da256123fa79028bbed9e'), + (65, '40130cc0fd96247a5095baf8628ff582'), + (66, '974921c621b3e2be3d1315a76a2c5adc'), + (67, '617be540ac66e4bca7749b43dc68aeba'), + (68, '47be9b5ff5c1563a09b0105b9a6e6c31'), + (69, '7a282da6e78c1e53b2fced99531df9ab'), + (70, '5b9e8b82c822f19ca457b03206485996'), + (71, '5e59edb8027452cc00dc0b980318539c'), + (72, 'bf9cc95f911f2633e0b57ea416e360df'), + (73, '0b99a3600ded014dd5aaa8ea64df086a'), + (74, 'bedf2d99e2f51b2798fdaf5ec3166675'), + (75, '2d4f1e7bbc40b909ebabf387c0fc88b5'), + (76, '85f7921aa3eaaf654a68e89dd5f879ee'), + (77, '04f011d2f0eb7ac3135c366990159561'), + (78, '3797e1987f6333ddd79a16e8e7608291'), + (79, '741d42ae273ed9a3cac062acebc1c4d1'), + (80, '312f34f39ba9c8421b8713175529fe79'), + (81, '39b8498f4138970694f8323c870a53d4'), + (82, '9d4f6793d70f860a1b886a9c2693934a'), + (83, 'fb508b67f923c8b25fb12ac97d699d09'), + (84, 'ae052b7bb24b2fc55fdebeab8b3d1e7c'), + (85, '1191cbf5e5c7e6d6de03dcea60d6b26e'), + (86, 'f8b09853223b9d4b1c48ed2a79542d77'), + (87, '3a5a7992b2ece2f004aaee9b20d0d129'), + (88, '9d81e18de24af907efe49818fddf4ea6'), + (89, 'cbd5a1d54d638aa70832a2ffcec7d07d'), + (90, 'ff9087612af30644c5dab533368a7480'), + (91, 'efc333307ab1a2b65a954fc79179a27b'), + (92, 'ac21619adf740789457992ffe15a99f5'), + (93, '6f579c3ea89e81162e110bac0905fd5d'), + (94, 'ad9c5312732b31ab8efc13cf05ad0aee'), + (95, '947a9eb430776b4d09dc1ca908e31e27'), + (96, '9243ed8fdd4655c2365c3e2266e1af27'), + (97, 'fd9f9b55cb5fb94a073ac88ca007d755'), + (99, 'dabdda9bfb42106d88d44b58884103d5'), + (100, '4142acc5da670b3002fbd15d492219f9'), + (101, '1942005097b05042af7615b79285fa6e'), + (102, '9957f978f1f568a9338ae17813fa6a0e'), + (103, 'a0ba370ef6fef2816e7c6818ec17a6b9'), + (104, '0ab32aef3b89c562fac421c55e5bbaf1'), + (105, '75c25ee7f95a330191667d4f88792da5'), + (106, '6ed9b95e95c3715a4b273f6f695d257e'), + (107, 'a8562dcb47181614b8c3a040e27b8c3f'), + (108, 'c3dd5f5d2e64aea193edb66082893a90'), + (109, '9405e122d6b9fbf7e1d372329745fa83'), + (110, '82723bace0ddb544fbf5950f7ed0f38c'), + (111, 'b969ba0f9763b47985b51b918d9c2ec1'), + (112, '6f768b5b00a24fa648716d8d34ebb314'), + (113, 'f4bb508ac2abaa04accdf210a2bcb73e'), + (114, '704b6c33f75c1fc1f799d1a138f05b53'), + (115, '67780eacdf8644697139b61cf4c7625c'), + (116, '274497cf05bbff79babe797e49fbd716'), + (117, 'f56aa8396f1848878f51017482c2ee1c'), + (118, '0b42011f9e38e9e46ca3368a2429e406'), + (119, '212a12eb05bd2ceeccc761d7da862ad3'), + (120, 'f2af4142f0530f59d2c44bc6ba466da9'), + (121, '766f1e5d72e0a494ad738c936d92b0a1'), + (122, '35662efd21c56f8dd071083b239696c9'), + (123, 'd15fecfd40367d5190ce036b96629da9'), + (124, '0cf9781bdff002c5d507a0f3ba0f6234'), + (125, '850bb0d4a1731fa00f9ea0633a9e64c2'), + (126, '43fe3b3b50d2e7fa7b47523e81d197c6'), + (127, 'abd06dcd547dc29d604a7736dbe9d6ee'), + (128, '8c357087d56bd967e1d7c41c6b61a176'), + (129, 'c8c222bbd38ea2949bcc524f6d840361'), + (130, 'b2da64c367e440aa713cd69e21ca84d8'), + (131, 'cf5263fd0849cbf6e0aac98f4ae8ddce'), + (132, '9ceecb93e50fd8637cd60a92241467c5'), + (133, '9ecc553a5b10c2d2a55a05786cbf13f5'), + (134, '9f830db27feec55a1940ff5e2d8fcff2'), + (135, '6002b6c8b7501d796488affe6f557d15'), + (136, 'b23dd4955e66aea9f2bc004b69e3a202'), + (137, 'cf9d886d4d57595c520efea7b9152001'), + (138, '53afaf5dd071a634d55cdb40bd6d532e'), + (139, '93e566f9a961cf8bff693f4670dccf63'), + (140, '0ce1f11cdefe00c06a45772974a787ce'), + (141, '81c6f6ed1785d09a11926fad880e5658'), + (142, 'dfc79b924bd60bc1439cc64b43ccb5bc'), + (143, 'ba3461f36a4cd2eaa14a33123a0a7816'), + (144, '0d282fbec3287645c943fbbb80bab34e'), + (145, '3187bd9bfc30e67b83d541ea26e9c77f'), + (146, 'a9667c58ac3dadd878b6bc5964d90608'), + (147, '343b70a2d7b82785b50f458de4acf8df'), + (148, 'b2fe6f839db15dffdf8210f41b74bdf3'), + (149, '123246ba855b4d5191faffbb44534809'), + (150, '15cfae5a71ac1fb387959de566eabc0a'), + (151, 'd542cd9e29acc265c2bbb34979e6bf64'), + (152, '5c6bd18e3817115e0876ae46f0779a69'), + (153, 'b801db0e1c30b1f68f288547cbfc2441'), + (154, 'ce11a962837efbcee8fe0e94f559e9f8'), + (155, '3729c8022b22191c99a9f8cfe8bcac3a'), + (156, 'c966a42393f92c63e676c44b3f69d78a'), + (157, '03761a4d37ae502c86457d4ca0e85772'), + (158, 'f402348c912b7f78df42e0272c3d640d'), + (161, 'd8ab36d0ed945bafa692fc8695debfbb'), + (162, '91ae207e5222f8623113ba32e48ddb56'), + (163, '86cbfa1bc9737eba005f10b2e56354c8'), + (164, 'f211eeb92893b68b70a3c9ecca6a8121') + ) AS reviewed(id, digest) + WHERE qa."Id" = reviewed.id + AND md5(qa."Question" || chr(31) || qa."Answer") = reviewed.digest; + """); +} diff --git a/TechSupportRagBot/Services/LanguageDetectionService.cs b/TechSupportRagBot/Services/LanguageDetectionService.cs index 0c9f2bf..fb9eee5 100644 --- a/TechSupportRagBot/Services/LanguageDetectionService.cs +++ b/TechSupportRagBot/Services/LanguageDetectionService.cs @@ -1,4 +1,4 @@ -using FastText.NetWrapper; +using FastTextNet; using Microsoft.Extensions.Options; namespace TechSupportRagBot.Services; @@ -7,21 +7,18 @@ public sealed class LanguageDetectionService : IDisposable { private readonly IWebHostEnvironment _environment; private readonly FastTextLanguageOptions _options; - private readonly ILoggerFactory _loggerFactory; private readonly ILogger _logger; private readonly object _sync = new(); - private FastTextWrapper? _model; + private FastTextModel? _model; private bool _loadAttempted; public LanguageDetectionService( IWebHostEnvironment environment, IOptions options, - ILoggerFactory loggerFactory, ILogger logger) { _environment = environment; _options = options.Value; - _loggerFactory = loggerFactory; _logger = logger; } @@ -44,7 +41,7 @@ public LanguageDetectionService( { try { - var prediction = model.PredictSingle(normalizedText); + var prediction = model.Predict(normalizedText, k: 1).FirstOrDefault(); var code = NormalizeFastTextLabel(prediction.Label); if (!string.IsNullOrWhiteSpace(code) && prediction.Probability >= _options.MinConfidence @@ -63,7 +60,7 @@ public LanguageDetectionService( ?? fallbackCode; } - private FastTextWrapper? GetModel() + private FastTextModel? GetModel() { if (_loadAttempted) { @@ -87,13 +84,11 @@ public LanguageDetectionService( try { - _model = new FastTextWrapper(_loggerFactory); - _model.LoadModel(modelPath); + _model = FastTextModel.Load(modelPath); _logger.LogInformation("fastText language model loaded: {ModelPath}", modelPath); } catch (Exception ex) { - _model?.Dispose(); _model = null; _logger.LogWarning(ex, "Could not load fastText language model: {ModelPath}", modelPath); } @@ -144,7 +139,6 @@ public void Dispose() { lock (_sync) { - _model?.Dispose(); _model = null; } } diff --git a/TechSupportRagBot/Services/SupportBotService.cs b/TechSupportRagBot/Services/SupportBotService.cs index e66d746..4c92550 100644 --- a/TechSupportRagBot/Services/SupportBotService.cs +++ b/TechSupportRagBot/Services/SupportBotService.cs @@ -314,7 +314,10 @@ private async Task> LoadAnswerMediaAsync(RagSearch return await _db.QAAttachments .AsNoTracking() - .Where(x => qaIds.Contains(x.QAEntryId)) + .Where(x => qaIds.Contains(x.QAEntryId) + && x.QAEntry != null + && x.QAEntry.Status == QAEntryStatuses.Verified + && x.QAEntry.AvailableToClients) .OrderBy(x => x.Id) .Select(x => new BotAnswerMedia( x.PublicId, diff --git a/TechSupportRagBot/TechSupportRagBot.csproj b/TechSupportRagBot/TechSupportRagBot.csproj index 3e0a5fe..96f4912 100644 --- a/TechSupportRagBot/TechSupportRagBot.csproj +++ b/TechSupportRagBot/TechSupportRagBot.csproj @@ -11,7 +11,7 @@ - + From 138cd1dd2a63b831598f18d6e5037ffb28296b6c Mon Sep 17 00:00:00 2001 From: nikostap <73756780+nikostap@users.noreply.github.com> Date: Wed, 21 Oct 2026 14:47:18 +0300 Subject: [PATCH 9/9] Record production audit release verification --- AUDIT_REMEDIATION.md | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/AUDIT_REMEDIATION.md b/AUDIT_REMEDIATION.md index f57703b..574da58 100644 --- a/AUDIT_REMEDIATION.md +++ b/AUDIT_REMEDIATION.md @@ -31,8 +31,9 @@ - [x] Проверить миграции на отдельной копии рабочей PostgreSQL-базы. - [ ] Завершить отдельные тесты восстановления видео и динамического прочтения сообщений. Проверка доступа к QA-вложениям и распознавания языка добавлена. -- [ ] Полный набор тестов, сборка, проверка уязвимых пакетов, просмотр основных страниц и мобильной версии. -- [ ] Обновить `PROJECT_MAP.md`, сверить сервер с Git, опубликовать и проверить рабочий сайт. +- [x] Полный набор тестов, сборка и проверка уязвимых пакетов; просмотр QA и обращений в опубликованном сайте, QA — также на мобильной ширине. +- [x] Обновить `PROJECT_MAP.md`, сверить сервер с Git, опубликовать и проверить рабочий сайт. +- [ ] Проверить оставшиеся роли и страницы визуально на мобильных устройствах и в обеих темах. ## Промежуточная проверка @@ -42,4 +43,12 @@ - SQL новой QA-миграции выполнен на рабочей БД внутри транзакции с откатом: 116 записей разрешились, все четыре исключения остались закрытыми. Реальных изменений в рабочей БД не было. - Проверка NuGet не находит известных уязвимостей в текущих источниках. Уязвимый транзитивный AutoMapper удалён заменой библиотеки распознавания языка. Тесты с локальной моделью проходят для русского, английского, французского и испанского языков. - GitHub Actions не запускает созданную проверку: GitHub вернул «account is locked due to a billing issue». Локальный запуск всех 54 тестов успешен; после решения вопроса с биллингом следует повторить проверку pull request. -- Исходники на сервере развернуты без каталога `.git`; все 305 отслеживаемых файлов базовой ветки сравнены по SHA-256 с рабочим сервером после нормализации окончаний строк. Пропусков и содержательных различий нет. Публикация требует резервной копии БД и проверки доступа к QA после обновления. +- Исходники на сервере развернуты без каталога `.git`; перед выпуском все 305 отслеживаемых файлов базовой ветки сравнены по SHA-256 с рабочим сервером после нормализации окончаний строк. Пропусков и содержательных различий не было. + +## Выпуск 2026-10-21 + +- Выпущен коммит `235ad34` после успешной сборки контейнера на сервере. Перед переключением сохранены исходники, прежний образ и дамп PostgreSQL в `/opt/classeng-support/backups/audit-20261021-143636`. +- HTTPS отвечает `200`, приложение работает в окружении `Production`, новых ошибок в журнале при проверке нет. На рабочей базе применены четыре миграции аудита, включая `20261021114000_ApproveReviewedQaEntries`: всего 120 QA, разрешено 116, закрыто 4. +- В браузере проверены страницы `/Admin/QA` и `/Admin/Tickets`; мобильная ширина QA не даёт горизонтального переполнения. Фильтр «Ожидают разрешения» показывает четыре закрытые записи. +- На сервере освобождён только неиспользуемый кэш сборки Docker; после выпуска свободно около 7,6 ГБ. Резервные данные и прежний образ сохранены. +- Проверка GitHub Actions по-прежнему не стартует из-за блокировки биллинга аккаунта GitHub. Локально пройдено 59 тестов; список NuGet не содержит известных уязвимостей для текущих источников.