Compare commits

95 Commits

Author SHA1 Message Date
grendervill d2443ae460 release: glchat 0.1.2
Версия 0.1.1 выпускалась до удаления OAuth: приводим номер в соответствие с
состоянием кода (в 0.1.2 входа через внешние провайдеры уже нет, схема БД — 27).

- VERSION, tauri.conf.json, Cargo.toml, Cargo.lock → 0.1.2;
- состав изменений с 0.1.1: удалён OAuth целиком (D-084), миграция 00027
  убирает таблицу oauth_accounts, зависимость golang.org/x/oauth2 убрана.
2026-09-27 18:49:15 +03:00
grendervill a48b865bc9 refactor: убрать вход через внешние провайдеры (OAuth) полностью
Решение владельца 27.09.2026 (D-084): сервис ставится на сервер конкретного
человека, и ему всё равно нужен свой OAuth у провайдера — поддержка общего
входа только создаёт трение (регистрация приложений, redirect URI, модерация,
чужие ключи в конфиге). Способы входа остаются: пароль + 2FA и ключи доступа
(WebAuthn).

Удалено:
- сервер: internal/auth/oauth.go, internal/server/oauth.go, internal/store/oauth.go
  и их тесты; поля и методы конфига OAuth*; oauthLimiter и регистрация ручек;
  отображение ошибок oauth.*; features.oauth_enabled/oauth_providers в /meta;
- клиент: web/src/api/oauth.ts, раздел «Вход через внешние сервисы», кнопки
  провайдеров на странице входа, ключи i18n (ru/en), тесты и фикстуры;
- установщик: переменные OAUTH_* из .env, .env.example и шаблона (хелпер
  чтения существующих значений переименован в existing_value — он остался нужен
  для VAPID_SUBJECT);
- зависимость golang.org/x/oauth2 (go mod tidy).

Схема: миграция 00027 удаляет таблицу oauth_accounts (00019 не переписываем —
она применена на стендах). Откат миграции возвращает структуру; тест
TestOAuthRemovalMigration проверяет накат, откат и повторный накат.

AGENT.md (локальный) помечает пункты про OAuth как отменённые.
2026-09-27 18:27:33 +03:00
grendervill c88f774749 feat(auth): VK ID работает и как публичное приложение
Публичному приложению VK ID сервисный ключ не нужен: обмен кода защищает PKCE,
и VK выдаёт токен по одному client_id. Раньше провайдер включался только при
заполненных id и секрете, то есть публичный вариант был недоступен.

- у провайдера появился флаг SecretOptional (у VK ID — true);
- включение провайдера и каталог учитывают флаг;
- тест TestOAuthVKPublicAppWithoutSecret: вход работает без секрета, в запросе
  обмена нет ни client_secret, ни service_token.
2026-09-27 17:59:49 +03:00
grendervill 00d86bb70c feat(auth): вход через VK ID и Яндекс ID
Владельцу нужны VK и Яндекс (Google/Discord/GitHub остаются в каталоге и
включаются своими ключами).

VK ID (id.vk.ru, OAuth 2.1):
- обязательный PKCE: code_challenge в запросе авторизации, code_verifier при
  обмене; верификатор выводится из подписанного state (HMAC), хранить нечего;
- device_id из callback уходит в обмен кода;
- для конфиденциальных приложений секрет передаётся как service_token, а не
  client_secret (и обмен идёт параметрами в теле, не Basic);
- профиль: POST /oauth2/user_info с client_id и access_token; права email и
  vkid.personal_info.

Яндекс ID:
- права login:email и login:info; секрет — в теле запроса обмена;
- профиль: GET login.yandex.ru/info?format=json с заголовком
  "Authorization: OAuth <токен>" (не Bearer).

Общее:
- в профиль добавлено DisplayName: VK отдаёт имя и фамилию, Яндекс — real_name,
  раньше они терялись;
- почта считается подтверждённой, если провайдер её отдал (отдельного флага нет,
  адрес приходит только по соответствующему праву) — D-083;
- новые переменные OAUTH_VK_* и OAUTH_YANDEX_* в установщике и .env.example;
- тесты: полные флоу обоих провайдеров на мок-серверах, каталог и ручки.
2026-09-27 17:58:04 +03:00
grendervill a835903938 fix(install,e2e): установка на VPS владельца — четыре дефекта шаблонов и хелперов
Установка на VPS (Debian 13, 1 vCPU/2 ГБ) с внешним прокси на том же хосте
вскрыла дефекты, которые не проявлялись на стенде:

- compose: с --no-turn порты TURN всё равно публиковались, и стек не стартовал
  («failed to bind host port 0.0.0.0:3478/udp» — порт держит чужой coturn).
  Теперь строки портов TURN подставляет установщик (GLCHAT_TURN_PORTS).
- LiveKit: добавлен отдельный адрес публикации медиапортов
  (--livekit-media-bind-addr / GLCHAT_LIVEKIT_MEDIA_BIND): сигналинг остаётся
  внутренним для прокси, а 7881/tcp и 7882/udp доступны клиентам из интернета.
- external-proxy.conf: адреса апстримов берутся из фактической привязки
  (--app-bind-addr/--livekit-bind-addr), а не жёсткого 127.0.0.1.
- при --no-files-subdomain шаблоны внешнего прокси и встроенного Caddy писали
  два блока с одним адресом сайта (Caddy отвергает такой конфиг и в embedded
  режиме установка ломалась целиком): блок пользовательского контента теперь
  появляется только при отдельном домене (GLCHAT_FILES_BLOCK).
- e2e: postWithRetry/repeat429 читают retry_after_ms из конверта ошибки
  ({"error":{...}}) — без этого хелперы сдавались раньше, чем сервер разрешал
  повтор, и регистрация пробных аккаунтов на свежем инстансе падала.
- e2e: voice.spec.ts ищет комнату «Голос» точным именем: пока сайдбар
  дорисовывает сервер, видны комнаты предыдущего («Голос A»/«Голос B»).
2026-09-27 17:40:58 +03:00
grendervill 1fd9734fff docs(desktop): решение владельца — без подписи и нотаризации, Windows/Linux позже через GitHub Actions
- .dmg распространяется неподписанным; инструкция для конечного пользователя
  (правый клик → «Открыть» или снятие карантина xattr) — в README
- хеш и путь текущего установщика 0.1.1
- сборки Windows/Linux отложены: репозиторий в Gitea, раннеры есть у GitHub
  Actions; локальная часть (desktop-check, desktop-release --target) готова
2026-09-27 15:42:50 +03:00
grendervill 8814fe7cf4 release: glchat 0.1.1
Версия — в одном месте: файл VERSION в корне, из него её берут Makefile,
deploy/install.sh (IMAGE_TAG) и desktop-обёртка (tauri.conf.json, Cargo.toml).

Публикация обновлений (scripts/desktop-release.sh):
- манифесты прошлых релизов удаляются: оставшийся <старая версия>.json
  перекрывал новый релиз — обёртка спрашивает манифест своей версии и видела
  «обновлений нет»;
- mktemp-каталог раскладки получает 755, каталоги на инстансе
  нормализуются: rsync -a переносил режим 0700 на корень каталога обновлений,
  и приложение не могло читать манифесты (404 при живых файлах);
- --remote работает без TTY и без sudo, если каталог обновлений записываем;
  опции ssh — в GLCHAT_SSH_OPTS;
- предупреждение при публикации версии старше уже опубликованной.

Сервер: HEAD /updates/... (chi не выводит его из GET) — размер и тип артефакта
без скачивания; тест TestUpdatesAnswersHead.

Приёмка на живом канале: 0.1.0 → 0.1.1 на стенде (скачивание, проверка
подписи, установка, перезапуск), /Applications/glchat.app = 0.1.1.
2026-09-26 20:14:25 +03:00
grendervill 8cf6bedf58 fix(cli): не отдавать stdin обёртки командам обслуживания
docker compose exec -T подключает stdin и съедал его у скрипта, поданного на
stdin (ssh … bash -s): обёртка обрывала такой скрипт после первой команды.
Найдено живой проверкой build/cli-live-check.sh (create-user выполнялся, дальше
скрипт молча заканчивался). Командам обслуживания stdin не нужен: </dev/null.

Плюс e2e web/e2e/query-retry.spec.ts: живая проверка повтора при 429 — запрос
участников дважды получает 429 с retry_after_ms и проходит с третьей попытки,
раздел «Участники» заполняется без кнопки «Повторить».
2026-09-26 18:40:46 +03:00
grendervill b9d458e133 fix(cli,web): команды аккаунтов в CLI и повтор запросов при 429
- deploy/glchat-cli.sh: reset-password/make-admin/remove-admin больше не
  заглушки «появится в фазе 1» (AGENT.md 10.7), добавлены totp-setup,
  totp-reset и delete-user; подкоманды обслуживания идут через общий app_cli
- cmd/glchat: команда delete-user (мягкое удаление как в админ-панели,
  защита от удаления инстанс-админа и подтверждение --yes), исправлен
  потерянный комментарий cliCleanup
- deploy/install.sh: убраны устаревшие формулировки про фазу 1
- web: политика повторов запросов — 429 повторяется с паузой retry_after_ms
  (до двух раз, не дольше 5 с), 4xx не повторяются; тесты web/tests/queryRetry.test.ts
- scripts/test-install.sh: проверка, что заглушек в CLI не осталось
2026-09-26 18:32:51 +03:00
grendervill e7c9c93443 docs(desktop): приёмка автообновления, .dmg в песочнице и открытые пункты
- `--update-now` в таблице аргументов и в разделе «Автообновление»: что делает,
  как запускать, что подпись проверяется так же, пример записей в журнале;
- «Локальная проверка «поверх старой версии»»: пошаговый рецепт прогона
  0.1.0 → 0.1.1 на локальном сервере инстанса и три грабли — http-эндпоинт
  требует временного `dangerousInsecureTransportProtocol`, путь приложения не
  должен содержать символических ссылок (`/tmp` → `/private/tmp`), в headless
  нужен `--update-now`;
- `.dmg`: точные команды — обычным терминалом (`make desktop-build`, с
  оформлением окна) и без графической сессии (`bundle_dmg.sh --sandbox-safe`,
  проверено: образ монтируется, `hdiutil verify` — VALID);
- «Что не сделано»: автообновление end-to-end проверено локально, открытым
  остаётся публикация реального релиза; добавлено наблюдение, что `open --args`
  из песочницы агента аргументы не доставляет (проверено на macOS 27).
2026-09-26 18:12:42 +03:00
grendervill 2d013df0bb fix(make): не передавать Tauri CLI недопустимый --bundles all
`make desktop-build` не запускался вовсе: Tauri CLI отвергает `--bundles all`
(«possible values: ios, app, dmg»), а значение по умолчанию в Makefile было
именно `all`. Теперь при `all` флаг не передаётся, и список бандлов берётся из
`bundle.targets` в tauri.conf.json — то есть собирается всё, что положено для
текущей ОС. `make desktop-build-app` (`--bundles app`) работает как раньше.

Обнаружено при попытке собрать `.dmg` (make desktop-build).
2026-09-26 18:12:38 +03:00
grendervill b53cf9d6e7 feat(desktop): тихая установка обновления по флагу --update-now
Установка обновления всегда спрашивала согласие диалогом, поэтому сквозная
проверка «поверх старой версии» не автоматизировалась (в headless-прогоне
кликнуть некому), а на управляемых машинах обновление ставит оператор, а не
пользователь. С флагом `--update-now` обёртка проверяет обновление сразу при
старте (не через 30 секунд) и ставит найденное без диалога, после чего
перезапускается; при отсутствии обновления работает как обычно.

По умолчанию флаг выключен — трей, страница настроек и фоновая проверка
по-прежнему спрашивают согласие. Подпись артефакта проверяется в обоих
режимах: флаг убирает вопрос, но не проверку. Ход тихого обновления виден в
журнале, а в строку запуска добавлена версия сборки — иначе после перезапуска
не понять, какая версия работает.

Решение — D-077, приёмка — desktop/README.md («Локальная проверка»).
2026-09-26 18:12:35 +03:00
grendervill 7512857363 test(e2e): матрица 11.6 для иконки беседы и личных шаблонов (Фаза 7)
Две проверки на стенде двумя браузерами, без F5 (trackLoads = 0):

* группа из трёх пробных аккаунтов: владелец ставит иконку и переименовывает
  беседу через меню в шапке, наблюдатель-участник видит иконку в шапке и в
  списке бесед, новое имя — там же, событием DM_CHANNEL_UPDATE; участнику
  меню владельца недоступно;
* личные шаблоны: первое устройство сохраняет структуру сервера шаблоном,
  второе (свой вход формой) открывает окно создания сервера, видит шаблон в
  «Моих шаблонах» и создаёт по нему сервер — комната и роль из шаблона на
  месте. У личных шаблонов нет события шлюза: список читается при открытии
  окна, перезагрузка страницы не нужна.

Имя беседы сверяется ещё и по REST, тестовые данные убираются (группа
распускается вместе с иконкой, серверы и шаблон удаляются, данные прошлых
прогонов подчищаются в beforeAll).
2026-09-26 17:38:28 +03:00
grendervill c0137007ce feat(web): переименование групповой беседы кнопкой (Фаза 7)
Пункт «Переименовать» в меню владельца в шапке групповой беседы и диалог с
текущим именем: `PATCH /channels/{id}` (D-075) уже был, интерфейса не было.
Ответ сервера кладём в стор бесед — имя меняется и в шапке, и в списке; при
отказе окно остаётся открытым с причиной, пустое имя отправить нельзя.
Меню владельца было меню иконки: теперь это общее меню беседы (⋯) с
переименованием, загрузкой и снятием иконки — прежние testid иконки смотрят
на те же пункты, поэтому обновлены только ссылки на само меню.

i18n ru/en, Vitest: `web/tests/dmRename.test.tsx` (переименование, пустое имя,
отмена и черновик, отказ сервера, права участника, DM_CHANNEL_UPDATE без
перезагрузки) и обновлённые ссылки в `web/tests/dmIcon.test.tsx`.
2026-09-26 17:33:06 +03:00
grendervill 12dc41ee7c fix(e2e): точный локатор кнопки входа в общей форме
Кнопка «Войти по ключу доступа» тоже подходит под /Войти/u, поэтому
Playwright в strict mode находил два элемента и падал на входе — от этого
зависели все спеки, которые логинятся через форму (voice, realtime-social и
другие), когда панель passkeys успевала отрисоваться.
2026-09-26 17:20:33 +03:00
grendervill 78e9c3c2d6 test(e2e): звонок в личной беседе двумя браузерами (Фаза 7)
Спека поднимает звонок между двумя реальными клиентами на стенде и
проверяет то, что нельзя проверить юнитами: приглашение приходит событием,
оба браузера обмениваются аудио через LiveKit (входящие байты > 0),
выключенный микрофон виден на сервере, завершение снимает маркер у второго
участника, а в истории остаётся запись.

Сессия берётся из api-контекста (cookie __Host-session): форма входа
упирается в лимит попыток при серии прогонов. Запуск по флагу
GLCHAT_E2E_VOICE=1, как у голосовой спеки.
2026-09-26 17:20:33 +03:00
grendervill 2b9bf55f16 feat(web): интерфейс звонков в беседах (Фаза 7)
В шапке беседы — меню звонка: аудио, видео, «Присоединиться» к идущему и
история. Когда голос на инстансе выключен, меню нет вовсе: обещать звонок,
который не поднимется, нельзя.

Входящий звонок показывается карточкой поверх любого экрана с принять и
отклонить и рингтоном; идущий звонок — маркером в списке бесед и в шапке, а
в области переписки вместо ленты открывается панель с плитками участников и
кнопками микрофона, звука, камеры, шаринга и выхода. История звонков — кто
звонил, когда, сколько длился разговор и кто не ответил.

Тесты (vitest): начало звонка из шапки с подключением к комнате LiveKit,
входящий звонок и его принятие, отклонение, маркер «идёт звонок» по событию
и его снятие после завершения, флаги микрофона, камеры и экрана, история с
пропущенными и длительностью, выключенный голос и маркер после
перезагрузки (active_call в READY).
2026-09-26 17:12:56 +03:00
grendervill 431013c3e3 feat(web): звонки в беседах — API, стор и события (Фаза 7)
Ручной модуль api/calls.ts повторяет контракт сервера: разбор звонка один
и тот же для REST-ручек, READY-снапшота и событий DM_CALL_*, поэтому
состояние разбирается одним кодом.

Стор dmCall ведёт медиа-сессию поверх общей обёртки lib/livekit: те же
плитки, устройства, качество публикации и продление токена, но без
серверных ролей — мьют и деаф только свои. Рингтон синтезируется Web Audio
(файлов в проекте нет): входящий — пока звонят нам, исходящий — пока ждём
ответа; свёрнутая вкладка замолкает.

Карточка беседы несёт active_call: маркер «идёт звонок» приходит в READY и
в списке бесед, а дальше обновляется событиями DM_CALL_RING/UPDATE/END.
Завершение гасит локальную сессию и играет сигнал тем, кто был в звонке.
2026-09-26 17:12:52 +03:00
grendervill 0b55fbdfe2 feat(dm): ручки и события звонков в беседах (Фаза 7)
Звонки 1:1 и в групповых беседах до 10 участников поверх той же голосовой
инфраструктуры LiveKit: те же токены, что у серверных комнат, но без
серверных ролей — позвонить может участник беседы.

Ручки: POST /channels/{id}/call (создание, в ответе токен и адрес
сигналинга), accept, decline, end, PATCH /call/@me для флагов микрофона,
камеры и шаринга, GET /call (текущий звонок) и GET /calls (история: кто
звонил, когда, сколько длился, кто пропустил). Постороннему — 404,
существование чужой беседы не подтверждаем; в беседе больше десяти
участников звонить нельзя (dm.call_limit); выключенный голос отвечает
voice.disabled.

События: DM_CALL_RING приходит приглашённым, DM_CALL_UPDATE — участникам
при смене состояния, DM_CALL_END — с итогом и причиной. Звонок добавлен в
READY и в карточку беседы (active_call), поэтому «идёт звонок» переживает
перезагрузку. Вебхук LiveKit разбирает комнаты dm_call_* и синхронизирует
состояние, сторож голосовых состояний закрывает гудки без ответа и
опустевшие комнаты.

Тесты: жизненный цикл 1:1, отклонение и пропущенный, отказ постороннему
на всех ручках, предел десяти участников, поведение при выключенном
голосе, события Gateway и active_call в READY.
2026-09-26 17:02:55 +03:00
grendervill 50709396cd feat(dm): хранилище и миграция звонков в беседах (Фаза 7)
Звонок в личной и групповой беседе живёт в своей таблице: в voice_states
guild_id обязателен, а у беседы сервера нет. Миграция 00026 добавляет
dm_calls (статус, media, длительность, причина завершения) и
dm_call_participants (состояние участника и флаги микрофона, камеры и
экрана); уникальный частичный индекс держит один идущий звонок на беседу.

В DMParticipantProfiles сравнение с владельцем обёрнуто в COALESCE: у 1:1
dm_owner_id пуст, и NULL нельзя было прочитать в int — ручка звонка в
личной беседе падала на 500.

Тесты: жизненный цикл звонка в хранилище (гудки, active на втором
участнике, история, повторный звонок) и выборка сторожем.
2026-09-26 17:02:51 +03:00
grendervill 96e0cd5918 feat(templates): свои шаблоны в клиенте — создание сервера и настройки (Фаза 7)
Модалка создания сервера показывает блок «Мои шаблоны» рядом со
встроенными и отправляет template_id=personal:<id>; секция «Шаблоны
сервера» в настройках (видна с MANAGE_GUILD) сохраняет структуру
текущего сервера и удаляет ненужные шаблоны.

- api: fetchMyGuildTemplates, createGuildTemplate, deleteGuildTemplate,
  ключ myGuildTemplatesQueryKey и лимит MY_GUILD_TEMPLATE_LIMIT;
- i18n: ключи в ru.json и en.json (паритет);
- тесты: web/tests/guildTemplates.test.tsx.
2026-09-26 16:48:01 +03:00
grendervill 605b2038f1 feat(templates): личные шаблоны серверов в хранилище и API (Фаза 7)
AGENT.md 7.3: свои шаблоны серверов — сохранить структуру сервера
(роли с правами и цветом, категории и комнаты) и создать по ней новый.
Миграция 00023 создаёт guild_templates (structure_json, source_guild_id,
каскад по владельцу) с индексом владельца.

- store: CRUD личных шаблонов с фильтром по owner_id и защитой лимитов
  (20 шаблонов, 16 КиБ structure_json);
- server: POST /guild-templates, GET /guild-templates/mine и
  DELETE /guild-templates/{id} (MANAGE_GUILD, чужой шаблон — 404),
  создание сервера по template_id=personal:<id> (аудит template=personal:<id>);
- структура шаблона повторяет API ролей (права строкой) и хранит
  slowmode_seconds, user_limit и категорию по имени.
2026-09-26 16:48:01 +03:00
grendervill 8696d85514 feat(dm): иконка групповой беседы в хранилище и API (Фаза 7)
D-070: у групповой беседы не было своей иконки — показывались имя и первая
буква аватара, как у 1:1 без аватара. Колонки под файл у канала не было.

Миграция 00024 добавляет `channels.icon_file_id` (REFERENCES files ON DELETE
SET NULL): NULL у 1:1 и комнат сервера, файл — у группы. Store читает и пишет
поле через `Channel`/`UpdateChannelParams` (`IconFileID`/`ClearIcon`), отдаёт
его в `ListDMChannels` и в READY, а уборка сирот больше не считает иконку
беседы мусором. Когда в группе остаётся два участника, беседа снова обычная
личная — иконка сбрасывается вместе с именем и владельцем.

Ручки `POST/DELETE /channels/{id}/icon` принимают multipart (назначение файла
`dm_icon`, аватарный лимит, проверка `image/`) и доступны только владельцу
беседы: участнику-не-владельцу 403 `perm.denied`, посторонним 404 (существование
чужой беседы не подтверждаем), у 1:1 своей иконки нет — 422 `dm.not_group`.
Файл иконки отдаётся только участникам беседы. После изменения участникам
уходит `DM_CHANNEL_UPDATE`, поэтому иконка меняется без перезагрузки
(AGENT.md 7.7, 7.8, 8.3, 11.6; D-042, D-070).
2026-09-26 16:48:01 +03:00
grendervill c429cbab30 feat(web): владелец группы меняет иконку беседы (Фаза 7)
D-070: иконка группы приходит полем `icon_file_id` — у группы это её файл, у
1:1 по-прежнему аватар собеседника. Разбор беседы из REST вынесен в
`parseChannelPayload`: групповые поля и иконка не теряются, а у группы без
иконки поле очищается, чтобы не остался чужой аватар.

`api/dmIcon.ts` грузит и снимает иконку multipart-запросом (FormData уже умеет
общий `request`). В шапке групповой беседы у владельца появилось меню «Иконка
беседы»: загрузить или удалить; ответ сервера сразу кладётся в стор бесед, а
список перечитывается — снятая иконка приходит пустым полем (AGENT.md 7.8,
11.6). Строки добавлены в ru и en.
2026-09-26 16:48:01 +03:00
grendervill 5320618d31 feat(web): статистика, карточка пользователя и фильтры аудита в админ-панели (Фаза 7)
Новые разделы панели инстанса (AGENT.md 3.2, 7.18):

- вкладка «Статистика»: рост пользователей и сообщений по дням, размеры
  данных, топы серверов;
- карточка пользователя из списка: устройства с отзывом, серверы и роли,
  события безопасности, аудит по пользователю; отзыв устройства и сброс
  второго фактора подтверждаются step-up;
- вкладка «Аудит»: фильтры по действию, актору, цели, серверу и датам,
  пагинация и ссылка на выгрузку CSV;
- вкладка «Серверы»: поиск по названию, фильтр по владельцу и главному
  серверу, пагинация;
- ключи i18n для новых разделов (ru/en, паритет), тесты панели.
2026-09-26 16:48:00 +03:00
grendervill 3dc200c196 feat(api): расширенная админ-панель инстанса (Фаза 7)
Статистика, карточка пользователя и работа с журналом для администратора
инстанса (AGENT.md 3.2, 7.18):

- GET /instance/stats: рост пользователей и сообщений по дням, активность,
  размеры базы и файлов, топы серверов по участникам и сообщениям;
- GET /instance/users/{id}: профиль, активные устройства, серверы с ролями,
  события безопасности и аудит по пользователю;
- DELETE /instance/users/{id}/sessions/{sid} и POST .../reset-2fa: отзыв
  одного устройства и сброс второго фактора со step-up, аудитом и записью
  в события безопасности; чужой ключ администратора не сбрасывается;
- журнал инстанса: фильтры по действию, актору, цели, серверу и датам,
  пагинация с общим числом, список действий и выгрузка CSV (лимит 5/мин);
- список серверов: поиск по названию, владелец, главный сервер, пагинация;
- миграция 00025: нормализованное название сервера `name_lower` — SQLite
  lower() не знает кириллицу, поэтому регистр приводит приложение (как для
  текста сообщений), старые записи дополняются backfill'ом при старте.
2026-09-26 16:44:49 +03:00
grendervill 09b4f6878f fix(api): /users/@me отдаёт custom_status_emoji
Редактор профиля в настройках читает `user.custom_status_emoji` и вызывает
`.trim()` прямо при отрисовке, а ручка это поле не возвращала: открытие
«Настройки → Профиль» падало с «Cannot read properties of undefined (reading
'trim')» (дефект с 22.09, commit 085454d — клиент поле требовал, сервер не
отдавал). Теперь поле приходит всегда, как и `custom_status`.

Найдено живой проверкой на стенде; тест `profile_payload_test.go` проверяет, что
ключ есть в ответе даже с пустым значением, и что PATCH его сохраняет.
2026-09-26 16:16:17 +03:00
grendervill 6afbdf621b fix(web): раздел «Эмодзи» обновляется событием, а не перезапросом
`GUILD_EMOJIS_UPDATE` несёт набор эмодзи целиком, но раздел настроек читает
REST-кэш, а клиент на событие запрос инвалидировал. Под нагрузкой повторный
`GET /guilds/{id}/emojis` попадает под лимит частоты (429), а запрос списка идёт
с `retry: 0` — список оставался старым до перезахода. Тот же дефект, что у
раздела «Звуки» (коммит 4175b14), и та же правка: событие кладёт набор в кэш
запроса через `setQueryData`, а ошибка перезапроса больше не прячет уже
полученный список.

Регрессионный тест `web/tests/guildEmojisSettings.test.tsx` («показывает эмодзи
из GUILD_EMOJIS_UPDATE, даже если список по REST не отдался») падает на старом
коде.
2026-09-26 16:16:16 +03:00
grendervill 324d2aac72 fix(web): DM_CHANNEL_UPDATE и DM_CHANNEL_DELETE обновляют список бесед
Сервер присылал эти события и раньше, но клиент перечитывал список бесед только
по `DM_CHANNEL_CREATE` и READY: добавление участника, переименование группы и
выход из неё не появлялись в сайдбаре до перезагрузки, а удалённая беседа
оставалась открытой.

Теперь все три события перечитывают список (`DM_CHANNEL_UPDATE` меняет состав,
имя и аватар группы сразу), а `DM_CHANNEL_DELETE` дополнительно убирает беседу
из стора сессии и закрывает её, если она была открыта.

Тесты в `web/tests/friends.test.tsx` падают на старом коде: переименование
группы видно второму участнику, удалённая беседа исчезает и закрывается.
2026-09-26 16:16:10 +03:00
grendervill 45a5eaae64 feat(web): карточка превью ссылки в ленте (Фаза 7)
Под текстом сообщения показывается карточка: имя сайта, заголовок, описание и
картинка — всё с сервера (`GET /link-previews`), клиент сам в интернет не ходит.

- `web/src/lib/links.ts`: первая ссылка сообщения (markdown и «голая» формы),
  блоки кода и инлайновый код игнорируются — `curl https://…` из примера не
  должен тянуть чужую страницу;
- `web/src/components/chat/LinkPreviewCard.tsx`: запросов нет, пока
  `meta.features.unfurl_enabled` не подтверждён; карточка молчит на
  loading/ошибке/статусах empty, blocked и error (превью — украшение, а не
  действие); картинка рисуется только по http(s) с
  `referrerpolicy=no-referrer`, текст выводится обычными строками без
  `dangerouslySetInnerHTML`; компонент монтируется только при найденной ссылке,
  поэтому сообщение без ссылок не создаёт ни запроса, ни узла;
- `web/src/api/unfurl.ts`: типизированный клиент и ключ запроса.

Тесты: `web/tests/links.test.ts` (разбор ссылок) и `web/tests/linkPreview.test.tsx`
(карточка, отказ при выключенном unfurl, опасные схемы картинки, ровно один
запрос при 429).
2026-09-26 16:15:54 +03:00
grendervill f45cc21a9c feat(web): push-уведомления в браузере и раздел в настройках (7.16)
Клиент подписывает устройство на Web Push, а показывает уведомление service
worker — приложение при этом может быть и закрыто.

- `web/public/sw.js`: обработчики `push` (показывает уведомление, иначе браузер
  может отозвать подписку) и `notificationclick` (фокусирует окно и переводит
  его в комнату, иначе открывает новое). Путь из payload берётся только
  внутренний: `//host` и абсолютные адреса отбрасываются;
- `web/src/lib/webPush.ts`: поддержка API, запрос разрешения, подписка и
  отписка, преобразование ключей в base64url, слушатель перехода по клику
  (`push-navigate` → `history.pushState` без перезагрузки);
- `web/src/api/push.ts`: `GET /push/config`, `GET|POST|DELETE /push/subscriptions`;
- раздел «Push-уведомления» в настройках: тумблер «Включить на этом
  устройстве», состояние «включено», объяснение отказа браузера в разрешении и
  честная деградация — «браузер не поддерживает» или «на инстансе не настроено»;
- владелец инстанса управляет ключами, клиент только спрашивает публичный.

Тесты: `web/tests/webPush.test.ts` (поддержка API, ключи, подписка, отписка,
отказ в разрешении, переход по клику) и `web/tests/pushSettings.test.tsx`
(раздел настроек: деградация, включение, отключение, ошибка загрузки).
2026-09-26 16:15:24 +03:00
grendervill a9b1073877 feat(unfurl): превью ссылок с защитой от SSRF и кэшем в БД (Фаза 7)
Сервер сам загружает заголовок, описание и картинку страницы по ссылке из
сообщения и отдаёт клиенту готовую карточку.

Безопасность (главное здесь):
- только http/https и без userinfo; запрет петли, частных сетей, link-local
  (169.254.169.254), CGNAT, multicast и IPv4-mapped вариантов;
- проверка идёт по адресу, к которому реально открывается TCP
  (`net.Dialer.Control`), поэтому подмена DNS между проверкой и соединением
  (DNS rebinding) ничего не даёт;
- не больше 3 редиректов, каждый хоп проверяется заново; таймаут 5 с, тело
  ≤ 512 КБ, только `text/html`; прокси из окружения игнорируются, cookie и
  авторизация не отправляются; в логи попадают только хост и код причины;
- картинка по ссылке не скачивается — проверяется лишь её URL: экономия CPU на
  1 vCPU и минус класс атак через декодирование.

Кэш: таблица `link_previews` (миграция 00022, ключ — sha256 нормализованного
URL), TTL по статусу (ok — сутки, empty/blocked — час, error — 10 минут).
Ручка `GET /api/v1/link-previews?url=…` отвечает статусом
(ok/empty/blocked/error) и карточкой только при ok; 20 новых загрузок в минуту
на пользователя, кэшированные ответы лимит не тратят. `UNFURL_ENABLED=false`
выключает функцию целиком, `features.unfurl_enabled` виден в `/meta`. Retention
убирает истёкшие записи кэша.

Тесты: 21 в `internal/unfurl` (включая DNS rebinding через локальный
DNS-сервер, редирект во внутреннюю сеть, таймаут, лимиты размера и типа),
ручки, store и миграция.
2026-09-26 16:15:00 +03:00
grendervill 7afd23d6d4 feat(push): Web Push — VAPID, подписки устройств и отправка (AGENT.md 7.16)
Уведомления в браузере и на телефоне: сервер сам решает, кому их слать, и
подписывает запрос VAPID-ключом, поэтому уведомление приходит, даже когда
клиент закрыт.

Сервер:
- миграция 00021: `push_subscriptions` (эндпоинт уникален, ключи, счётчик
  неудач, время последней доставки);
- `internal/push` — VAPID-ключи (приватный PKCS#8 из конфига, публичный
  выводится из него), правила уведомлений повторяют
  `web/src/lib/desktopNotifications.ts` (упоминания и личные беседы, без своих
  и системных сообщений), очередь доставки, TTL 12 часов, Topic по комнате;
- 404/410 от push-сервиса удаляют подписку сразу, 5 неудач подряд — тоже,
  иначе копились бы мёртвые эндпоинты; retention чистит «молчащие» подписки;
- `internal/httpx/safeurl.go` — общий запрет внутренних адресов с проверкой
  адреса в момент подключения (DNS rebinding): эндпоинт подписки приходит от
  клиента, и без проверки сервер сам себе организует SSRF;
- `internal/gateway/presence.go` — `IsUserOnline`: если получатель в клиенте,
  уведомление покажет клиент, дублировать на телефон не нужно;
- ручки `GET /push/config`, `GET|POST|DELETE /push/subscriptions`, лимиты
  10 подписок и 20 уведомлений в минуту; ключ p256dh проверяется как настоящая
  точка P-256;
- установщик генерирует `VAPID_PRIVATE_KEY` (openssl, PKCS#8 DER в base64) и
  `VAPID_SUBJECT`, ключ переиспользуется при переустановке; для ручной установки
  есть `glchat vapid-keys`; `features.web_push_enabled` виден в `/meta`.

Тесты: правила и VAPID, доставка с поддельным push-эндпоинтом (шифрование,
заголовки VAPID/TTL/Topic, очистка мёртвых подписок), ручки и лимиты, отправка
при личном сообщении и упоминании, пропуск онлайн-получателей, миграция на
чистой БД.
2026-09-26 16:14:20 +03:00
grendervill f9464130cd feat(dm): групповые личные беседы (AGENT.md 7.8, Фаза 7)
Беседа на несколько участников живёт в тех же таблицах, что и 1:1
(channels.type = 'dm' + dm_participants), добавляется только владелец
(channels.dm_owner_id, миграция 00020).

Сервер: POST /users/@me/channels/group (2–9 приглашённых, имя необязательно —
собирается из имён), PUT/DELETE /channels/{id}/recipients/{user_id}
(добавляет любой участник, удаляет других и переименовывает только владелец,
выйти может каждый сам), PATCH /channels/{id}. Правила состава: группа — это
3+ участника; при двух беседа снова обычная личная (имя и владелец
сбрасываются), при одном — удаляется вместе с перепиской. Заблокированного
нельзя ни пригласить, ни добавить; посторонним беседа не видна (404).
READY и REST отдают is_group, name, member_count, owner_id и состав;
участники получают DM_CHANNEL_CREATE/DM_CHANNEL_UPDATE, удалённый —
DM_CHANNEL_DELETE.

Клиент: группа в сайдбаре с числом участников (без точки чужого статуса),
шапка беседы с числом участников и именами в подсказке, создание группы из
списка друзей (минимум двое) с переходом в новую беседу.

Тесты: Go — полный жизненный цикл (создание, состав у каждого участника,
добавление, запрет посторонним, удаление владельцем, выход, превращение в
1:1, удаление последней беседы), валидация (меньше трёх, дубликаты,
заблокированный) и группа в READY; web — 4 vitest (сайдбар, шапка, создание,
выключенная кнопка).
2026-09-26 15:40:17 +03:00
grendervill 50265c66b8 fix(install): готовность приложения проверяется по адресу привязки
Установщик ждал /readyz на 127.0.0.1:8080, хотя во внешнем режиме и на
стенде приложение слушает адрес туннеля (--app-bind-addr): установка
всегда сообщала «приложение не ответило за 60 с» и «готовность не
подтверждена», хотя инстанс был жив.

Логика та же, что в deploy/update.sh, restore.sh и glchat-cli.sh
(0.0.0.0/пусто → 127.0.0.1); адрес печатается в сообщении, чтобы причина
была видна сразу.
2026-09-26 15:32:59 +03:00
grendervill cca64451e2 test(web): e2e passkeys проверяет отказ после отзыва ключа
После отзыва ключа виртуальный аутентификатор ещё хранит credential и
подписывает challenge, поэтому отказ приходит от сервера
(auth.passkey_unknown): проверяем, что страница входа показывает ошибку и
не пускает в приложение, а не конкретный testid клиентской ошибки.

Живой прогон на стенде: регистрация ключа, вход по нему без пароля и
отзыв — 1 passed (виртуальный аутентификатор Chromium через CDP).
2026-09-26 15:30:14 +03:00
grendervill e305b417e8 fix(install): пустые OAUTH_* не ломают .env и docker compose
Установщик писал значения OAuth в кавычках, а при повторной установке
снимал их из .env вместе с кавычками и добавлял новые: получалось
`OAUTH_GITHUB_CLIENT_ID=""""`, и docker compose отказывался читать .env
(«unexpected character "\"" in variable name»), из-за чего стек не
поднимался после --reconfigure.

Теперь oauth_value снимает кавычки при чтении, а env_quote ставит их
только значениям со спецсимволами. Заодно уже испорченный .env лечится
сам: кавычки снимаются, значение записывается пустым.
2026-09-26 15:20:46 +03:00
grendervill 11b491d6d3 fix(db): миграция OAuth переживает уже созданную таблицу
На стенде таблицу oauth_accounts успела создать промежуточная редакция
миграции 00018 (в ней passkeys и OAuth жили одним файлом), при этом версия
00018 записалась, а 00019 — нет: при обновлении инстанс падал в цикле
перезапуска на «table oauth_accounts already exists».

CREATE TABLE/INDEX IF NOT EXISTS делают миграцию идемпотентной: на чистой
базе она по-прежнему создаёт таблицу, на стенде — просто фиксирует версию.
2026-09-26 15:16:34 +03:00
grendervill cebce0ae3b feat(auth): вход через OAuth-провайдеры (GitHub, Google, Discord)
Фаза 7, AGENT.md 7.1: провайдеры включаются переменными окружения
OAUTH_<PROVIDER>_CLIENT_ID/SECRET, привязка внешнего аккаунта идёт по
подтверждённому провайдером email через blind index, вход забаненному на
инстансе запрещён, все входы и привязки попадают в события безопасности,
привязка — ещё и в аудит инстанса.

Внешний идентификатор (subject) хранится только индексом HMAC-SHA-256,
токены провайдеров не сохраняются вовсе; state подписывается ключом сессий
и ограничен по времени, редирект после входа — только внутренний путь.
Если провайдер не настроен, ручка отвечает oauth.provider_not_configured,
а клиент показывает человеческий текст вместо кнопки.

Клиент: кнопки входа по списку включённых провайдеров на экране входа и
раздел «Вход через внешние сервисы» в настройках безопасности; адрес
возврата для настроек приложения виден в GET /auth/oauth/providers.

Миграция 00019 добавляет таблицу oauth_accounts. Установщик и .env.example
знают про OAUTH_* и сохраняют значения при --reconfigure.

Тесты: Go — выключенный провайдер, подписанный state (подмена и чужой
провайдер), создание и повторный вход, привязка к существующему аккаунту,
неподтверждённый email, выключенная регистрация, бан инстанса, mocked
провайдер (httptest) для обмена кода; web — кнопки провайдеров и ошибка
возврата; серверная ручка отдаёт понятный отказ без настроек.
2026-09-26 15:13:24 +03:00
grendervill cd1d662572 feat(auth): вход по ключу доступа (passkeys, WebAuthn)
Фаза 7, AGENT.md 7.1: регистрация ключа в настройках безопасности с
обязательным step-up, вход по ключу без пароля (в т.ч. без ввода почты —
обнаруживаемый ключ), несколько ключей на аккаунт, отзыв и переименование,
события безопасности и аудит.

Сервер: github.com/go-webauthn/webauthn (BSD-3-Clause), RP ID и Origin
берутся из конфига домена; если домен — IP-адрес (стенд без домена),
passkeys честно выключены (auth.passkey_unsupported). Церемонии живут в
памяти процесса 5 минут и одноразовые: повторная отправка challenge
отклоняется. Миграция 00018 пересобирает неиспользуемую таблицу
webauthn_credentials под полную запись credential в JSON. Новые ручки
входа ограничены по IP (10/мин).

Клиент: тонкая обёртка над navigator.credentials без тяжёлых SDK,
раздел «Ключи доступа» в настройках безопасности и кнопка «Войти по ключу»
на экране входа; понятные сообщения для браузеров без поддержки WebAuthn
и при отмене диалога.

Тесты: Go — регистрация/вход с эмулятором аутентификатора (реальная
проверка подписи P-256), отказ при чужом challenge, одноразовость
церемонии, обязательный step-up при управлении ключами, запрет входа
забаненному, ограничение allowCredentials при входе с почтой, лимит и
валидация имени; web — 12 vitest с моком navigator.credentials;
Playwright — живой сценарий с виртуальным аутентификатором Chromium.
2026-09-26 15:12:43 +03:00
grendervill 8e096ca6b2 fix(server): удаление и пины сообщений попадают в аудит сервера
AGENT.md 7.10 требует, чтобы действия с сообщениями были в журнале сервера, а
§11.5 перечисляет удаление и закрепление как действия инстанс-админа на чужом
сервере. Обе ручки рассылали только события Gateway, записей в аудите не было.

`message.edit` намеренно не пишется: история правок не хранится (7.6), а
правка своего сообщения — не модерационное действие и не входит в §11.5.

Личные беседы в аудит не попадают: у них нет сервера, а журнал ведётся по
серверам (7.10).

Тест: internal/server/audit_messages_test.go — удаление, pin и unpin чужого
сообщения инстанс-админом помечены `actor_instance_admin`, действие владельца
в журнале есть, но без флага; личные сообщения в аудит не пишутся.
2026-09-26 15:06:29 +03:00
grendervill 4175b1436e fix(web): свежий звук сервера виден в настройках и при ошибке списка
`GUILD_SOUNDS_UPDATE` приносит набор звуков целиком, но раздел «Звуки» читает
REST-кэш: клиент инвалидировал запрос вместо того, чтобы положить в кэш сам
payload события. Под нагрузкой повторный `GET /guilds/{id}/sounds` попадает
под лимит частоты (429), а запрос списка идёт с `retry: 0` — список оставался
старым, и новый звук не появлялся в разделе. Так падал пункт 5 матрицы 11.6 в
полной прогонке (build/e2e-all-run8.log, run9): в логах стенда видно
`POST .../sounds 200`, следом `GET .../sounds 200` (первый звук) и
`GET .../sounds 429` (второй) — без единой повторной попытки.

Теперь событие пишет список в кэш запроса, а ошибка перезапроса не прячет уже
полученный список: раздел показывает актуальный набор из снапшота.

Тест: web/tests/soundSettings.test.tsx — звук из `GUILD_SOUNDS_UPDATE`
появляется, когда список по REST отвечает 429.
2026-09-26 15:06:26 +03:00
grendervill ad7b3d21bb test(web): проверка саундборда дожидается локального воспроизведения
Клиент играет звук асинхронно: сначала скачивает файл через /files/{id} в
blob, и только потом создаёт Audio и зовёт play(). Подпись о проигрывании
появляется раньше звука, а проверка читала счётчики сразу после неё и ловила
гонку: в прогоне build/e2e-all-run8.log пункт 27 падал с __soundCalls = 0,
хотя сервер отвечал 200 и запрос файла уже шёл. Ждём факт запуска через
expect.poll — проверка снова проверяет воспроизведение, а не скорость сети.

Пункт был недоступен раньше: без медиапути LiveKit тест скипался, поэтому
гонка не проявлялась.
2026-09-26 14:42:47 +03:00
grendervill ee334a77bc test(web): голосовые e2e скипаются при недоступном медиапути
Пункты 3, 10 и 11 матрицы и тест саундборда падали не на продукте, а на
инфраструктуре: ICE через TURN на VPS не поднимается (STUN отвечает, но все
ICE-пары `failed` с `requestsReceived: 0`), и проверка трижды ждала кнопку
входа, после чего падал весь прогон.

В support.ts добавлен общий помощник joinVoiceIfPossible/joinVoiceOrSkip:
- три попытки, как раньше, но при уже показанной клиентом ошибке повтор не
  делается, а ожидание ограничено 25 секундами;
- при неудаче проверяется серверная часть входа (POST .../voice/join →
  токен LiveKit): живой сервер при мёртвом ICE — блокер среды, проверка
  скипается с точной причиной; нерабочая ручка — дефект продукта, тест
  падает. Проба ограничена по времени, а ответ 429 считается признаком
  живого сервера (лимит частоты к медиапути отношения не имеет);
- п. 11 при недоступном голосе не отменяется: голосовой мьют пропускается
  с аннотацией и предупреждением в лог, а тайм-аут, кик, возврат сервера в
  рейку и бан по-прежнему проверяются.

Три локальные копии joinVoice заменены одним помощником.

Прогоны на стенде (образ с HEAD): матрица 18 passed / 0 failed / 3 skipped,
§11.5 — 8 passed, два прогона подряд совпали; без правки было 3 failed.
2026-09-26 14:15:53 +03:00
grendervill 73dd51b121 fix(server): правки сервера, ника и комнат попадают в аудит
§11.5 требует, чтобы каждое мутирующее действие писалось в журнал сервера
с `actor.instance_admin=true`, но у PATCH /guilds/{id},
PATCH /guilds/{id}/members/{user_id} и у ручек комнат вызова `recordAudit`
не было: переименование сервера, смена ника, тайм-аут и создание, правка и
удаление комнаты не оставляли следа ни у владельца, ни у инстанс-админа.

- guild.update, member.update, member.timeout (в причине — срок тайм-аута),
  channel.create, channel.update, channel.delete;
- Go-тест TestAuditCoversGuildMemberAndChannelChanges проверяет записи на
  чужом сервере и флаг actor_instance_admin, а также что действие владельца
  этим флагом не помечается;
- e2e 19b в instance-admin.spec.ts включён вместо test.fixme с диагностикой.

Проверки: go test -tags sqlite_fts5 -race -count=1 ./internal/... — ok;
make go-lint — 0 issues; живьём на стенде тест 19b проходит.
2026-09-26 14:15:49 +03:00
grendervill 5a419112e0 test(web): e2e шапки сервера, прав кнопок и саундборда
Проверки, которые в матрице 11.6 обходились по REST, теперь идут через
интерфейс (AGENT.md 7.13, 7.15, 11.6).

- 25: 🔗/⚙/+ видны владельцу и не видны обычному участнику, а после выдачи
  роли с правами управления появляются без перезагрузки;
- 26: статус комнаты и медленный режим появляются в шапке по событию и
  пропадают при снятии;
- 27: саундборд — панель открывается в голосовой комнате, проигрывание
  уходит на сервер (200), звук играет локально (счётчик вызовов
  `Audio.play`, подпись «включил звук»). Если медиапуть LiveKit не
  поднялся, тест скипается с причиной: это внешняя зависимость;
- 28: счётчик непрочитанных в сайдбаре и явная фиксация того, что центра
  уведомлений в клиенте нет (заглушка — только переключатель системных
  уведомлений в настройках).
2026-09-26 11:06:51 +03:00
grendervill 8c2bb3e18c test(web): e2e личных серверов, DM, друзей и блокировок
Сценариев личных серверов и личных бесед в матрице 11.6 не было
(AGENT.md 7.2, 7.8, 7.9). Новый спек идёт под тем же флагом
`GLCHAT_E2E_REALTIME=1` и тем же стилем: два браузера, `support.ts`,
`trackLoads`, уборка в `finally`.

- 21: личный (непубличный) сервер — вход по ссылке-приглашению в браузере,
  сервера нет в каталоге, вход без приглашения 403, вошедший видит историю;
- 22: DM 1:1 — непрочитанное в списке бесед, история, «печатает», ответ из
  интерфейса, правка и удаление без перезагрузки;
- 23: заявка в друзья и её принятие кнопкой в интерфейсе;
- 24: блокировка из списка друзей — писать нельзя в обе стороны
  (`dm.blocked`), беседа не открывается, разблокировка возвращает переписку.

Приглашение (1 на прогон) создаёт владелец: суточная квота — 10 на
пользователя (AGENT.md 8.6).
2026-09-26 11:06:46 +03:00
grendervill 5176b277a6 test(web): e2e инстанс-админа на чужом сервере (§11.5)
Чек-лист §11.5 проверялся отдельными скриптами и живьём, но в спеках его
не было. Новый файл (`GLCHAT_E2E_INSTANCE=1`) работает на сервере, где
админ не участник: владелец — `rtprobe`, участник — `rtprobe2`.

- 14: видимость — чужой приватный сервер в `GET /instance/guilds` и в
  списке серверов админа, комнаты, история, поиск, участники, роли, баны,
  инвайты, эмодзи, звуки, вебхуки, аудит; чужие DM по-прежнему 404;
- 15: управление без 403 — имя сервера, ник владельца, роли (создание,
  поднятие выше своей, выдача и снятие), комнаты; аудит помечает действия
  `actor_instance_admin=true`;
- 16: модерация — бан с причиной и автором, разбан, кик, тайм-аут; попытки
  владельца кикнуть/забанить/замутить/лишить роли админа дают 403
  `instance.admin_protected`;
- 17: обход лимитов — slowmode, приватность комнаты, лимит участников
  голосовой комнаты;
- 18: UI — админ входит в чужой приватный сервер из рейки без инвайта,
  пишет в приватную комнату, а участник видит переименование и кик без
  перезагрузки;
- 19: аудит — прежние записи не затираются; 19b (`test.fixme`) фиксирует
  пробел: переименование сервера, ник и комнаты в аудит не пишутся;
- 20: безопасность не обходится — step-up на свежей сессии, чужой Origin,
  лимит реакций 429.

Уборка тестовых серверов — в `finally` от имени админа (удаление чужого
сервера требует step-up).
2026-09-26 11:06:42 +03:00
grendervill 71c0107b87 test(web): общие помощники живых e2e в support.ts
Новые спеки (§11.5, личные серверы и шапка сервера) используют тот же
набор шагов, что матрица 11.6, но её помощники лежали внутри
`realtime.spec.ts`. Вынес в `support.ts`, чтобы не копировать их в третий
раз: сессии и лимит входов, повтор при 429, step-up (в том числе с 2FA
админа), подготовка сервера и уборка, комнаты, роли, сообщения, звуки,
личные беседы, друзья и блокировки.
2026-09-26 11:06:36 +03:00
grendervill cf36488e6f fix(web): права в шапке сервера обновляются после выдачи роли
По MEMBER_UPDATE/ROLE_* клиент перечитывал участников, роли и комнаты, но
не сводку сервера: `my_permissions` в сторе сессии оставались прежними, и
кнопки управления (🔗 приглашение, ⚙ настройки, + комната) появлялись
только после перезагрузки. Матрица 11.6 требует, чтобы права сразу меняли
доступные действия (AGENT.md 11.6).

- в ветках `MEMBER_UPDATE`/`MEMBER_REMOVE` и `ROLE_*` вызывается
  `addGuildFromRest` — сводка сервера перечитывается и мержится;
- vitest: кнопки появляются по событию (тест падает без правки).
2026-09-26 11:06:32 +03:00
grendervill 70eae99873 feat(web): статус комнаты в шапке
Описание комнаты жило только в настройках: в шапке были имя и медленный
режим, поэтому «статус комнаты» из матрицы 11.6 приходилось проверять по
REST. Теперь описание показывается рядом с названием (длинное усекается,
полный текст — в подсказке), а само поле приходит и в READY, и в событиях
комнат (AGENT.md 7.5, 11.6).

- `Channel.description` и разбор `description` в `parseChannelPayload`;
- `channel-description` в шапке комнаты;
- vitest `tests/channelHeader.test.tsx`: статус из снапшота, отсутствие
  статуса без описания и обновление по `CHANNEL_UPDATE` без перезагрузки.
2026-09-26 11:06:28 +03:00
grendervill 5f21c9d069 fix(server): инстанс-админ видит все серверы инстанса в READY
Чек-лист §11.5 требует, чтобы READY и список серверов инстанс-админа
содержали все серверы инстанса, включая чужие и приватные. Фактически
список строился из членства (`ListGuildsForUser`), поэтому в интерфейсе
админа чужого сервера не было ни в рейке, ни в подстраховочном
`GET /users/@me/guilds` — войти в него без инвайта было нельзя, хотя права
на чужом сервере у него максимальные (AGENT.md 7.18, 7.19).

- `Snapshot.userGuilds`: админу — `ListAllGuilds`, остальным — членство;
- `GET /users/@me/guilds` отдаёт админу тот же список, иначе REST-ответ
  выкидывал бы чужой сервер из рейки при перечитывании сводки;
- Go-тест `TestReadyListsAllGuildsForInstanceAdmin`: READY и REST содержат
  чужой приватный сервер с полными правами, у обычного пользователя его нет.
2026-09-26 11:06:24 +03:00
grendervill 61cad0fdfc feat(server): статус комнаты в снапшоте READY
Шапка комнаты показывает статус (описание) рядом с названием, но в
снапшоте этого поля не было: клиент получал описание только REST-запросом
списка комнат, поэтому статус появлялся с задержкой и не приходил в
событиях комнат (AGENT.md 7.5, 11.6).

- `ReadyChannel.Description` + заполнение в `channelPayload` — статус
  приходит и в READY, и в `CHANNEL_CREATE`/`CHANNEL_UPDATE` (те же
  payload'ы, что у REST);
- Go-тест `TestReadyCarriesChannelStatus`: описание и медленный режим
  доезжают до снапшота.

Клиентская часть (парсер и шапка) идёт следующим коммитом.
2026-09-26 11:06:05 +03:00
grendervill 19d2501ab0 docs(desktop): замеры ресурсов на финальной сборке
Числа в README приведены к сборке, которая поставляется: холодный старт
773–898 мс (процесс 74–78 мс + страница 696–821 мс), RSS 254 МБ
(обёртка 117 + WebKit 137), в трее 256 МБ, физический след 106 МБ.
Прошлый прогон (708–740 мс, 252 МБ) снят с бинарника до правки выхода из трея —
расхождение в пределах разброса, но в документации должны стоять числа
отгружаемой сборки.
2026-09-22 23:21:52 +03:00
grendervill 4d39002a1f docs(desktop): манифест обновлений, поведение при клике по уведомлению, замеры и открытые пункты
README обёртки приведён в соответствие с кодом:

- раздел «Автообновление»: раскладка каталога `UPDATES_DIR`, публикация релиза
  (`make desktop-release`, `make desktop-release-upload HOST=…`), зачем нужен
  `latest.json`, проверка через curl и лог обёртки; отдельно отмечено, что
  владельцу внешнего прокси добавлять ничего не нужно;
- раздел «Уведомления и переход в канал»: реализованное поведение по активации
  приложения и честное ограничение (ОС не сообщает, что активация вызвана
  кликом по уведомлению);
- раздел «Ресурсы и холодный старт»: замеры 708–740 мс и 252 МБ RSS, как
  повторить (`scripts/desktop-perf.py`);
- «Что осталось оператору»: точные команды для `.dmg`
  (`make desktop-build` из обычного терминала), подписи Developer ID и
  нотаризации (`APPLE_*`, `stapler validate`, `spctl`), сборки и подписи
  Windows/Linux, требования к ключу обновлений; отдельно — поведение на Wayland
  (нативная тряска окна не работает, остаётся CSS).
2026-09-22 23:17:48 +03:00
grendervill 72a51f083d test(desktop): замеры холодного старта и памяти обёртки
Требование спецификации §1/§7 (idle RAM ≤ 350 МБ, холодный старт ≤ 2 с) до сих
пор не было измерено. `scripts/desktop-perf.py` запускает собранный `.app`,
меряет время от `open` до строки «загружена страница» в логе обёртки и память
после того, как клиент успокоился.

`ps` и `top` в песочнице агента недоступны, поэтому на macOS память и список
процессов читаются через `libproc` (`proc_pid_rusage`, `proc_listpids`), а для
Linux есть ветка на `/proc`. Помощники WebKit (`WebContent`, `GPU`,
`Networking`) запускаются через launchd, их родитель — не приложение, поэтому
«свои» определяются сравнением списка процессов до и после запуска: иначе RSS
главного процесса занижал бы потребление втрое.

Замеры (macOS arm64, Mac на M-серии, инстанс через внешний прокси):
холодный старт 708–740 мс, RSS открытого клиента 252 МБ (обёртка 117 + WebKit
135), в трее 251 МБ, физический след 103 МБ. Результаты — в `docs/PERF.md` и
`desktop/README.md`.

В Makefile добавлена цель `desktop-build-app` (`tauri build --bundles app`):
артефакты обновления собираются без `.dmg`, который в песочнице и headless-
окружении падает на `bundle_dmg.sh` (монтирование образа + AppleScript).
2026-09-22 23:17:39 +03:00
grendervill 085454d555 feat(web): палитра команд Ctrl/Cmd+K и подтверждение выхода с несохранёнными правками
Два требования desktop-клиента (docs/client-tauri.md §5), которые живут в
веб-слое — том же, что и в браузере:

- **Палитра команд.** Ctrl/Cmd+K (на документе или сообщением `openPalette` от
  обёртки) открывает быстрый переход: серверы (в первую доступную комнату),
  комнаты выбранного сервера с группировкой по категориям, личные беседы из уже
  загруженного снапшота, действия (настройки профиля/безопасности/внешнего вида,
  настройки сервера, создание сервера, друзья, микрофон и «не слышу» при
  активной голосовой сессии). Новых запросов палитра не делает; поиск по
  подстроке без учёта регистра, 8 пунктов на раздел, стрелки со
  `scrollIntoView`, `role=listbox/option` и `aria-activedescendant`;

- **Подтверждение выхода.** Формы с локальным черновиком регистрируются в общем
  реестре (`stores/unsaved.ts` + хук `useUnsavedChanges`): платформа получает
  один `setDirty` на переход «есть правки / нет правок», а на сообщение обёртки
  `closeRequested` клиент отвечает тряской контента и диалогом «Сохранить /
  Выйти без сохранения». При `prefers-reduced-motion: reduce` тряску заменяет
  подсветка рамки; в браузере вместо сообщения обёртки работает штатный
  `beforeunload`. Хук добавлен в формы с явной кнопкой «Сохранить»:
  профиль, общие настройки сервера, ник, эмодзи, звуки, оформление, вебхуки,
  лимиты инстанса, переименование сервера (автосохраняемые переключатели
  намеренно не помечаются);

- Deep link `glchat://dm/<channelId>` (личная беседа без сервера) — его
  присылает обёртка при клике по уведомлению о личном сообщении.

Тесты: 525 passed (501 база + 24 новых) — палитра, реестр правок, диалог
закрытия, `beforeunload`, разбор ссылок; `npm run check` и `npm run lint` зелёные.
2026-09-22 23:17:32 +03:00
grendervill 72c5189e8f feat(desktop): переход в комнату по уведомлению, выход без потери правок, Ctrl/Cmd+K
Три пункта спецификации клиента (docs/client-tauri.md §5), которые оставались
открытыми в обёртке:

- **Клик по уведомлению → переход в канал.** У `tauri-plugin-notification` на
  desktop нет колбэка действия, поэтому реализовано лучшее доступное поведение:
  уведомление, показанное при неактивном окне, запоминается, и когда приложение
  активируется в течение 10 секунд (окно получило фокус или macOS прислала
  `RunEvent::Reopen`), окно поднимается и открывается комната из уведомления —
  `glchat://guild/<id>/channel/<id>`, для личной беседы `glchat://dm/<id>`.
  Идентификаторы проверяются (только цифры), чтобы из текста уведомления нельзя
  было собрать произвольный deep link. Ограничение: ОС не сообщает, что
  активация вызвана именно кликом, поэтому переход сработает и при обычном
  возврате в приложение в эти 10 секунд — это описано в desktop/README.md;

- **Выход при несохранённых изменениях.** Клиент сообщает о правках командой
  `desktop_set_dirty`; закрытие окна и пункт «Выход» в трее перехватываются
  (`window::request_close_confirmation`), окно best-effort покачивается
  (`Window::set_position`, на Wayland молча пропускается), а решение принимает
  пользователь в диалоге клиента «Сохранить / Выйти без сохранения»; закрывает
  приложение команда `desktop_close_window`. Раньше выход из трея терял правки
  молча;

- **Ctrl/Cmd+K.** Комбинация регистрируется глобально (`shortcuts.rs`) и при
  неактивном окне поднимает окно и просит клиент открыть палитру команд
  (`WebMessage::OpenPalette`); повторы комбинаций с настраиваемыми шорткатами
  отсекаются по разобранной комбинации, иначе вторая регистрация падала бы.

Заодно: `updates.rs` получил тест, который разбирает манифест релиза типом
самого `tauri-plugin-updater` (`RemoteRelease`) — он ловит расхождение формата
(например, дату не в RFC 3339), из-за которого обновление молча не приходило бы.
Проверки: `make desktop-check` (fmt, clippy, 10 тестов).
2026-09-22 23:17:27 +03:00
grendervill 498201b413 feat(server): инстанс отдаёт манифест автообновления desktop-обёртки
Обёртка с первого инкремента Фазы 6 запрашивает
`GET /updates/{target}/{arch}/{current_version}`, но на инстансе этого пути не
было: запрос попадал в SPA-заглушку, клиент получал HTML с кодом 200 и писал
в лог «error decoding response body».

Что сделано:

- `internal/server/updates.go` — статика автообновления: каталог `UPDATES_DIR`
  (в контейнере `/app/updates:ro`), раскладка `<target>/<arch>/<version>.json` +
  артефакт и `.sig`; версия без расширения отображается на `<version>.json`,
  а если его нет — на `latest.json` (обёртка приходит со своей установленной
  версией, «новее ли релиз» решает клиент сравнением semver). Для имён
  с расширением артефакта запасного варианта нет: иначе вместо архива клиент
  скачал бы манифест и подпись не сошлась бы. Сегменты пути проверяются по
  символам, выход за каталог невозможен; `/updates/` добавлен в reservedPrefixes,
  поэтому отсутствующий манифест — честный 404, а не HTML-шелл;
- `scripts/desktop-release.sh` и цели `make desktop-release` /
  `make desktop-release-upload HOST=…`: берут собранный `glchat.app.tar.gz` + `.sig`,
  определяют target/arch по имени артефакта, копируют их в каталог инстанса и
  собирают манифест (версия из `tauri.conf.json`), объединяя платформы одной
  версии; есть `--dry-run` и загрузка на инстанс по SSH;
- `deploy/install.sh` и шаблоны compose: каталог `${GLCHAT_UPDATES_DIR}`
  (`/opt/glchat/updates`) создаётся установщиком и монтируется в контейнер
  только для чтения; в `.env` добавлен `GLCHAT_UPDATES_DIR`.

Владельцу внешнего прокси добавлять ничего не нужно: `/updates/...` уходит в
catch-all `handle` и доходит до приложения, как статика клиента.

Проверено на стенде: `curl https://gl.mhspx.su/updates/darwin/aarch64/0.1.0`
→ 200 `application/json; charset=utf-8`, артефакт → 200 `application/gzip`,
отсутствующие платформа и версия → 404 (не HTML), `readyz` → 200, обёртка
в логе пишет «обновлений нет» вместо ошибки разбора. Тесты: 6 новых в
`internal/server/updates_test.go`, манифест разбирается типом самого плагина
обновлений (`updates::tests::манифест_разбирается_плагином_обновлений`).
2026-09-22 23:17:22 +03:00
grendervill eeececaff8 test(e2e): таймаут прогона на живом стенде задаётся переменной
Подготовка файла (beforeAll) входит в пробные аккаунты, а лимит — 5 входов в
минуту на IP (AGENT.md 8.6): на живом стенде подготовка не укладывалась в
180 секунд, и весь файл падал в beforeAll ещё до тестов. Значение по умолчанию
не меняется, для прогонов на стенде поднимается GLCHAT_E2E_TIMEOUT_MS.
2026-09-22 22:28:10 +03:00
grendervill dcafb10046 test(e2e): матрица 11.6 — пункт 12 без fixme и возврат сервера в пункте 11
Пункт 12 включён: клиент уходит на экран входа при отзыве сессий, в том числе
когда открыт сервер (дефект исправлен в 751ecc6), а смена пароля на сервере
больше не рвёт соединение текущего устройства (05409d0).

Пункт 11 теперь проверяет то, ради чего он написан: после кика и повторного
входа плитка сервера возвращается в рейку без перезагрузки (правка клиента в
ea1b4a7). Возврат пароля пробного участника перенесён в finally: падение теста
не оставляет постоянный аккаунт с временным паролем, а неудачный возврат
сообщается явной проверкой.

Прогон: 13 passed (2,9 мин), лог — build/e2e-realtime-run4.log.
2026-09-22 22:28:10 +03:00
grendervill 05409d0914 fix(gateway): смена пароля не закрывает сессию текущего устройства
ChangePassword отзывает остальные сессии, но обработчик закрывал соединения
всех устройств пользователя — включая то, с которого пароль сменили: страница
теряла шлюз в момент успешного ответа, и подтверждение «Пароль изменён» не
показывалось (пункт 12 матрицы 11.6, меняли пароль через интерфейс).

Добавлен InvalidateUserExcept: текущее соединение опознаётся по хэшу токена
сессии, который теперь хранит и буфер RESUME. Для logout-all, бана и
админского сброса поведение прежнее — закрываются все соединения.

Тест: TestInvalidateUserExceptKeepsCurrentSession (второе устройство получает
INVALID_SESSION, текущее продолжает отвечать на heartbeat).
2026-09-22 21:58:23 +03:00
grendervill a7b4e3318e build(desktop): цели make, README и устойчивость обёртки к правам на логи
- Makefile: `desktop-check`, `desktop-build` (с ключом подписи обновлений,
  без ключа — сборка без артефактов обновления) и `desktop-run`; кэши
  cargo и npm держатся в .cache/, как у остальных инструментов проекта.
- desktop/README.md: сборка, запуск, аргументы командной строки, настройки,
  deep links и открытые пункты Фазы 6.
- bundle.targets = all: бандлы по умолчанию для текущей ОС (macOS: .app/.dmg).
- Логирование подключается в setup и больше не роняет приложение, если
  каталог логов недоступен на запись (песочница, домашний каталог только
  для чтения).
- Разбор аргументов вынесен в Cli::from_args и покрыт тестами
  (--instance, --minimized, --settings, --autostart on|off).
- cargo fmt по модулям обёртки.
2026-09-22 21:55:19 +03:00
grendervill 4a299155e7 fix(web): ключи settings.channel.background.* в ru и en
Раздел «Фон комнаты» запрашивал вложенные ключи, а строки лежали на уровень
выше (settings.channel.title и соседние), поэтому в интерфейсе виднелись
сырые ключи i18n. Строки перенесены под settings.channel.background в обеих
локалях, паритет ru/en сохранён — web/tests/i18n.test.ts зелёный.
2026-09-22 21:52:20 +03:00
grendervill 751ecc6d45 fix(web): отзыв сессий уводит на вход и при открытом сервере
Сервер отзывал сессии кадром {"op":4,"reason":"…","resumable":false} и закрывал
соединение, но устройство с открытым сервером (/app/<сервер>/<комната>)
оставалось в приложении: уход на /login зависел только от 401 на запросе
профиля, а перечитывание профиля в этой ветке не срабатывало (пункт 12
матрицы 11.6, проверено перехватом WS на стенде).

Теперь признак invalidated в сторе шлюза читает AuthGuard и сразу уводит на
экран входа — в любом состоянии приложения, не дожидаясь ответа REST.
Признак снимается при успешном входе, иначе форма входа зацикливалась бы.

Тесты: «отзыв сессии на открытом сервере уводит на /login»; живая проверка
build/verify-session-invalidation.mjs дополнена сценарием с открытым сервером.
2026-09-22 21:52:16 +03:00
grendervill ea1b4a7a90 fix(web): возврат в сервер после кика виден в рейке без перезагрузки
По GUILD_CREATE клиент забирал сводку серверов через fetchQuery, а список
myGuilds кэшируется на 30 секунд: после кика и повторного входа сервер
возвращался на сервере, но плитка в рейке не появлялась до перезагрузки
(AGENT.md 11.6).

Теперь обработчик GUILD_CREATE сначала помечает список серверов устаревшим и
только потом читает его, поэтому запрос уходит в REST даже при свежем кэше.
Сценарий кика в пункте 11 матрицы проверяет плитку в рейке, а не только
состав участников по ответу сервера.

Тест: «GUILD_CREATE перечитывает список серверов, а не берёт свежий кэш».
2026-09-22 21:52:12 +03:00
grendervill a6cec5919a fix(gateway): READY отдаёт права участника вместо пустого списка
Снапшот READY инициализировал my_permissions пустым списком и нигде его не
заполнял, поэтому у всех — включая владельца сервера — клиент выключал
композер («В эту комнату нельзя писать»), прятал шестерёнку, приглашения и
кнопку саундборда: состав прав в снапшоте не совпадал с REST
(GET /users/@me/guilds отдавал полный список).

Права считает тот же движок и тот же кэш, что REST (permissions.Calculator,
переданный в NewSnapshot), а инстанс-админ получает максимальный набор даже
на чужом сервере, где он не участник (AGENT.md 7.19, 11.6).

Тесты: TestReadyCarriesMyPermissions (непустые права роли по умолчанию,
VIEW_CHANNEL/SEND_MESSAGES и согласие с can_send комнаты) и
TestReadyGivesInstanceAdminAllPermissions.
2026-09-22 21:52:09 +03:00
grendervill cffcec72b8 feat(desktop): обёртка Tauri 2 вокруг веб-клиента
Первый инкремент Фазы 6 (docs/client-tauri.md): окно с тем же веб-клиентом
(по умолчанию https://gl.mhspx.su, адрес настраивается), трей с меню и
индикатором непрочитанных, нативные уведомления, глобальный push-to-talk и
переключение микрофона/deafen, автозапуск, single-instance, сохранение
геометрии окна, deep links glchat://…, автообновление по подписанному
манифесту.

Связь страницы с обёрткой — через платформенный адаптер
(web/src/lib/platform.ts) и минимальный типизированный набор команд;
в веб-клиенте появились системные уведомления об упоминаниях и личных
сообщениях и переключатель в настройках.

В web/src/stores/gateway.ts вместе с вызовом уведомления лежат правки по
дефектам realtime (READY и сессии) из параллельной волны: файл коммитится
целиком по договорённости с координатором.
2026-09-22 21:46:01 +03:00
grendervill 594a5c0af5 test(e2e): пункт 6 матрицы 11.6 не зависит от суточной квоты приглашений
Приглашения ограничены 10 на пользователя за 24 ч (AGENT.md 8.6), поэтому
проверка инвайтов создаёт отдельного свежего владельца сервера: у постоянных
пробных аккаунтов квота выгорает за серию прогонов. Прогон: 1 passed.
2026-09-22 21:24:22 +03:00
grendervill ab855fbd4f test(e2e): матрица realtime §11.6 — пункты 1–13
Два спектакля на общем модуле `web/e2e/support.ts` (запуск только с
`GLCHAT_E2E_REALTIME=1`):
- `realtime.spec.ts` — пункты 1–7: роли и права, приватная комната по роли,
  профиль и ники, оформление сервера, эмодзи/звуки/косметика, инвайты, комнаты;
- `realtime-social.spec.ts` — пункты 8–13: сообщения, непрочитанные и
  упоминания между устройствами, presence/typing/голос, модерация, два
  устройства, RESUME после реконнекта.

Владелец действует по REST, второй браузер видит результат через Gateway;
полные загрузки документа запрещены и проверяются `trackLoads`. Матрица нашла
четыре дефекта продукта (исправлены в `d241714` и `7ec9dbf`).
2026-09-22 21:19:37 +03:00
grendervill e4bbc27a7c fix(realtime): объединение частых синхронизаций и лог неожиданных ошибок
- клиент откладывает перечитывание комнат (окно 250 мс) и не запускает второй
  запрос, пока идёт первый: серия событий ролей и участников больше не даёт
  лавину GET members/roles и 429 на лимите API;
- неожиданные ошибки API пишутся в лог (и в chi-, и в huma-ветке): под
  параллельной нагрузкой на стенде видели разовые 500 на GET members/roles,
  но без текста ошибки разобраться было нельзя.
2026-09-22 20:58:49 +03:00
grendervill 63466fb75b fix(ops): update не делает shallow-fetch и объясняет конфликт ветки
Частичный fetch переводил обычный клон в shallow, после чего pull --ff-only
падал с «Not possible to fast-forward» без подсказки, что делать.
2026-09-22 20:49:29 +03:00
grendervill e97f260113 fix(ops): проверка готовности учитывает адрес привязки приложения
`glchat update` на стенде с внешним прокси всегда сообщал «приложение не
поднялось»: readyz проверялся на 127.0.0.1, а приложение слушает адрес в
WireGuard (GLCHAT_APP_BIND). Тот же дефект был в restore.sh и в `glchat status`
(health показывался как unreachable). Добавлен общий помощник app_check_url.
2026-09-22 20:49:13 +03:00
grendervill 6577c1fb39 build(security): образы пинятся по digest
AGENT.md 11.3 требует фиксировать образы по digest: тег в реестре может быть
перезаписан. Закреплены базовые образы Dockerfile (node, golang, alpine) и
образы установщика (caddy, livekit-server). Digest'ы проверены на стенде:
сборка образа и пересоздание LiveKit проходят, caddy тянется по digest.
2026-09-22 20:42:52 +03:00
grendervill c7446a5d4c feat(security): security.txt отдаётся, npm audit и SBOM в AppSec
- /.well-known/security.txt уходил в SPA-заглушку (200 text/html): добавлена
  копия в web/public и явный тип text/plain в статике (RFC 9116, AGENT.md 11.3);
- make security дополнен `npm audit --omit=dev --audit-level=high`;
- новая цель `make sbom` — CycloneDX-отчёт зависимостей (trivy, если есть).
2026-09-22 20:39:44 +03:00
grendervill 7ec9dbfdee fix(realtime): уход на вход после отзыва сессии и бан в открытом сервере
- invalidateCurrentUser перечитывает профиль, а не только удаляет кэш: без
  запроса AuthGuard не видит 401 и второе устройство остаётся в приложении
  после logout-all или смены пароля (AGENT.md 11.6, пункт 12);
- сброс сессии и уход из последнего сервера инвалидируют список серверов:
  пустой снапшот подставлял устаревший REST-ответ, и сервер возвращался в
  рейку (видно и после кика);
- joinGuild проверяет бан сервера: публичный сервер больше не обходится
  нажатием «Войти» вместо приглашения (AGENT.md 7.17, 7.20), тест расширен;
- e2e/voice-ten приведён к линту, console.log разрешён в e2e (диагностика
  прогона), артефакты Playwright исключены из prettier.
2026-09-22 20:35:13 +03:00
grendervill b2a48d8008 fix(cli): glchat profile сохраняет форму развёртывания
Переключение профиля — повторная установка, и без флагов она переписала бы
compose на встроенный прокси. Теперь CLI восстанавливает домен, режим прокси,
адреса привязки, внешний TURN и ignore-ip из .env.
2026-09-22 20:24:38 +03:00
grendervill aa33f2f14d chore(security): обоснованное исключение GO-2026-5932 для trivy
openpgp из x/crypto не импортируется (govulncheck: 0 вызовов), фиксированной
версии у находки нет — иначе trivy с --exit-code 1 валит прогон AppSec.
2026-09-22 20:18:50 +03:00
grendervill 77f81dc076 docs(cli): подсказка, что unban снимает баны своего хоста 2026-09-22 20:18:15 +03:00
grendervill 3bac21ad24 fix(instance): публичная информация отдаёт voice_enabled
Поле никогда не заполнялось, поэтому /api/v1/instance всегда сообщал
voice_enabled:false, хотя голос настроен (клиент читает признак из /meta,
но контракт 6.5 обещает его и здесь).
2026-09-22 20:15:12 +03:00
grendervill e5af7b1f83 style: форматирование после правок событий роли и комнат 2026-09-22 20:11:33 +03:00
grendervill d241714a6d fix(realtime): события ролей, комнат и отзыв сессий без перезагрузки
Дефекты, найденные e2e-матрицей AGENT.md 11.6 (пункты 1, 2, 7, 12):

- ROLE_CREATE/ROLE_UPDATE уходили телом роли без guild_id, а клиент ждал
  ссылку {guild_id, role_id} — событие отбрасывалось, роли у второго
  участника не обновлялись. В rolePayload добавлено guild_id, parseRoleEvent
  принимает и тело роли, и старую ссылку;
- выдача роли (MEMBER_UPDATE) и правки ролей не перечитывали список комнат,
  поэтому приватная комната не появлялась в сайдбаре без F5 — теперь клиент
  перечитывает комнаты по этим событиям;
- CHANNEL_CREATE/CHANNEL_UPDATE уходили телом комнаты с нулевым can_view
  (права персональные), и клиент прятал комнату по этому признаку. Сервер
  шлёт тело события без вычисленных прав плюс GUILD_CHANNELS_SYNC, а клиент
  по событию комнаты перечитывает список;
- отзыв сессий (logout-all, смена пароля, админский сброс и «выйти везде»,
  бан, удаление) не доходил до Gateway: второе устройство оставалось в
  приложении. Добавлен gateway.InvalidateUser — клиент получает
  INVALID_SESSION и закрывает соединение; клиент дополнительно обрабатывает
  событие SESSION_INVALIDATED.

Тесты: gateway (отзыв сессий закрывает соединения и не задевает чужого),
web (событие SESSION_INVALIDATED, перечитывание комнат по CHANNEL_CREATE).
2026-09-22 20:10:53 +03:00
grendervill ff2fc878b0 docs(install): подсказка про квоту внешнего TURN-релея
Нагрузочный тест 10 участников упирался в user-quota=12 на coturn: одному
участнику нужно два allocation, а TURN-пользователь у инстанса один.
2026-09-22 20:01:08 +03:00
grendervill 66dd07eb7b feat(ops): генератор нагрузки и подготовка аккаунтов для теста
- cmd/loadgen: держит заданное число WS-клиентов Gateway, шлёт сообщения с
  нужной частотой, измеряет задержку доставки (p50/p95/p99/max), ответы API,
  разрывы соединений и пишет отчёт в JSON;
- glchat create-user (CLI + обёртка): создаёт аккаунты в обход выключенной
  регистрации, пакетно (--count/--prefix), при --sessions сразу выдаёт сессии
  и складывает учётные данные в файл 600 — иначе сотня клиентов не сможет
  войти из-за лимита попыток по IP;
- auth: CreateUserByOperator и IssueSessionForOperator с тестом;
- web/e2e/voice-ten.spec.ts: десять участников в одной голосовой комнате,
  проверка плиток, входящего аудио и слоя 1080p60 у клиента; сессии берутся
  из файла (без формы входа);
- .gitignore: каталоги вывода Playwright test-results*.
2026-09-22 20:00:33 +03:00
grendervill 785af0dc7b feat(guilds): приватные комнаты — права доступа поверх серверных
Сервер:
- store: оверрайды всех комнат сервера одним запросом (без N+1) и снятие
  оверрайда с признаком «был ли он»;
- API: PUT/DELETE /guilds/{id}/channels/{cid}/overwrites/{role|user}/{tid}
  (права именами через `|`, как у ролей), step-up на изменение, проверка что
  роль принадлежит серверу, а участник состоит в нём;
- список комнат отдаёт permission_overwrites; после правки сбрасывается кэш
  прав комнаты, пишется аудит (channel.overwrite_set/delete) и уходит событие
  GUILD_CHANNELS_SYNC — видимость комнаты меняется у всех участников.

Клиент:
- редактор «Доступ к комнате»: приватность одним переключателем (запрет
  VIEW_CHANNEL для @everyone), права просмотра/переписки/входа для ролей и
  участников, подтверждение личности по требованию сервера;
- в настройках комнаты теперь и голосовые комнаты (фон и права), вебхуки —
  только у текстовых; событие GUILD_CHANNELS_SYNC перечитывает список комнат.

Тесты: 2 Go-теста (скрытие и открытие комнаты ролями, проверка цели и прав) и
3 web-теста редактора (маски, step-up, скрытие без MANAGE_ROLES).
2026-09-22 19:14:05 +03:00
grendervill b403f6c894 fix(security): обновление golang.org/x/crypto до v0.57.0
trivy на стенде нашёл в v0.55.0 две MEDIUM-уязвимости x/crypto/ssh
(CVE-2026-56855, CVE-2026-78662) и неподдерживаемый openpgp; сам код их не
вызывает (govulncheck чист), но обновление убирает находки из отчёта AppSec.
2026-09-22 19:03:29 +03:00
grendervill 787f822dc0 feat(instance): глобальный бан пользователя и поиск в админ-панели
Сервер:
- миграция 00017: таблица instance_bans (причина, автор, дата);
- auth: ErrUserBanned, проверка бана в Login (код user.banned, 403) и в
  ResolveSession — забаненный не получает сессию ни по cookie, ни по Bearer,
  ни в Gateway, а прежняя сессия удаляется;
- store: BannedAt/BanReason в модели пользователя, BanInstanceUser,
  UnbanInstanceUser, IsInstanceBanned, поиск и фильтр в ListUsers,
  CountUsersFiltered; мягкое удаление аккаунта убирает и запись о бане;
- API: POST /instance/users/{id}/ban и /unban со step-up (AGENT.md 7.1),
  отзыв сессий и SESSION_INVALIDATED, аудит instance.user_ban с причиной и
  instance.user_unban; себя и инстанс-админа забанить нельзя;
- GET /instance/users: q (логин и отображаемое имя), banned=true, total.

Клиент:
- панель: поиск, фильтр «только забаненные», бейдж бана с причиной, кнопки
  «Забанить» (с причиной) и «Разбанить» через общий шаг подтверждения
  личности; i18n ru/en, включая текст ошибки user.banned;
- keepPreviousData в списке пользователей: без этого поле поиска
  размонтировалось на первом же символе и набор обрывался.

Тесты: 4 Go-теста (бан блокирует вход и сессии, защита админов, поиск,
уборка бана при удалении), web-тест панели, живая проверка на стенде 19/19.
2026-09-22 18:59:53 +03:00
grendervill d2712c4c99 feat(ops): сверка .env инстанса с таблицей профилей
- scripts/test-profiles.sh --env <файл>: профиль берётся из .env, значения
  таблицы 10.10 пересчитываются и сверяются с производными ключами
  (SQLITE_MMAP_SIZE, MAX_UPLOAD_SIZE, APP_MEMORY_LIMIT, SHARE_SOFT_LIMIT,
  BACKUP_KEEP_DAYS и т. д.), offsite-ключи проверяются на наличие.
- make profiles-env — тот же прогон для /opt/glchat/.env.
- На стенде: профиль standard совпал (67 проверок, 0 ошибок); .env дополнен
  ключами offsite, установленный backup.sh обновлён, выгрузка и недоступный
  приёмник проверены вживую.
2026-09-22 18:46:49 +03:00
grendervill 3151327f6e feat(ops): offsite-бэкапы и проверки профилей
- deploy/backup.sh: BACKUP_OFFSITE_TARGET (rsync или rclone:remote:path)
  выгружает готовый архив после локальной ротации; неудача выгрузки только
  предупреждает. Ротация на приёмнике — опционально
  (BACKUP_OFFSITE_KEEP_DAYS, rclone delete --min-age). Имя архива получает
  суффикс, если бэкап запущен дважды в одну секунду.
- deploy/install.sh: offsite-настройки в .env (BACKUP_OFFSITE_TARGET,
  BACKUP_OFFSITE_KEEP_DAYS) отдельным разделом; детектор незаполненных
  плейсхолдеров учитывает цифры в именах (FAIL2BAN_ENABLED); dry-run больше не
  требует прав на /opt/glchat — явность GLCHAT_ETC_DIR фиксируется до
  подключения common.sh, иначе ветка временного каталога была недостижима.
- scripts/test-profiles.sh: сверка таблицы профилей 10.10 и одинакового набора
  PROFILE_* (46 проверок), make profiles-test / ops-test.
- scripts/test-backup.sh: бэкап и offsite на синтетическом инстансе — локальный
  rsync-приёмник, шим rclone, недоступный хост, нечисловой срок (12 проверок),
  make backup-test.
- scripts/test-install.sh: проверка offsite-ключей в .env после установки.
2026-09-22 18:44:50 +03:00
grendervill af03cfb063 feat(security): проверка Origin, строгий CSP и лимиты загрузок
- middleware OriginCheck: state-changing запросы с чужого Origin отклоняются
  (AGENT.md 9.7); запросы без Origin пропускаются — cookie уже SameSite=Lax
- CSP перечисляет директивы явно (script-src/worker-src/manifest-src 'self',
  style-src с inline для React, img-src с внешними https для аватаров вебхуков,
  connect-src с доменом файлов и LiveKit); добавлены Permissions-Policy,
  Cross-Origin-Opener-Policy и X-Permitted-Cross-Domain-Policies
- лимиты по AGENT.md 8.6: загрузки 10/мин и 100/сутки (вложения и аватары),
  реакции 20/мин; администратор инстанса лимиты обходит
- step-up: смена прав роли (в теле PATCH) и удаление сервера (перед вызовом
  /auth/step-up) требуют свежего подтверждения личности
- тесты: Origin (свой/чужой/GET), состав CSP, лимит реакций, step-up на права
  роли; исправлен вызов NewRateLimiter (второй аргумент — burst, не окно)
2026-09-22 00:40:10 +03:00
grendervill 1cb949a7a4 feat(ops): проверка бэкапа и уборка файлов без записи
- glchat verify-backup: расшифровка age, integrity_check, foreign_key_check,
  схема и состав данных, сверка с манифестом; живые данные не трогает
- чистка удаляет файлы на диске, у которых не осталось записи в базе (так
  оставались вложения удалённых серверов), и пустые подкаталоги; работает
  через os.Root, чтобы символическая ссылка не вывела за каталог файлов
- удаление сервера теперь чистит и содержимое файлов, а не только записи
- install.sh пишет значения .env с пробелами в кавычках: файл подключается
  через `set -a && . .env`, иначе имя главного сервера выполнялось как команда
- тесты: Go на уборку файлов без записи (свежие не трогаются, пустые
  каталоги удаляются)
2026-09-21 23:33:04 +03:00
grendervill ef871bcd96 feat(instance): админ-панель с лимитами медиа и действиями над пользователями
- лимиты медиа живут в настройках инстанса (миграция 00016): аватары,
  оформление сервера, эмодзи, звуки и галерея; все загрузки берут предел
  оттуда, значения по умолчанию — из AGENT.md 7.7
- действия администратора: временный пароль (показывается один раз, сессии
  отзываются), выход со всех устройств, мягкое удаление пользователя
  (сообщения и аудит остаются), переименование и удаление любого сервера
- админ-панель разбита на вкладки: обзор и здоровье, лимиты, пользователи,
  серверы, аудит; смена администраторов подтверждается личностью (step-up)
- тесты: Go (действия администратора, лимит эмодзи из настроек) и Vitest
  (вкладки, сброс пароля, выход, удаление, переименование сервера)
2026-09-21 22:37:47 +03:00
grendervill dd4432452c fix(web): манифест PWA отдаётся с правильным типом
Go определяет .webmanifest как text/plain, из-за чего браузер мог отказаться
ставить приложение: тип задаём явно вместе с js/css/svg.
2026-09-21 19:45:25 +03:00
grendervill 5afcfee744 feat(ops): чистка данных, reindex и PWA с офлайн-режимом
- internal/retention: истёкшие сессии, аудит старше RETENTION_AUDIT_DAYS и
  файлы без ссылок старше RETENTION_ORPHAN_HOURS; сиротой файл считается
  только если на него не ссылается ничего (вложения, аватары, оформление
  сервера, комнат, ролей, эмодзи, звуки, вебхуки, приглашения)
- обслуживание запускается в приложении по RETENTION_INTERVAL_SECONDS;
  команды glchat cleanup и glchat reindex делают то же вручную
- PWA: манифест с иконками и shortcuts, офлайн-страница, сервис-воркер
  (кэш только /assets/*, приватные запросы не кэшируются)
- тесты: Go на чистку (сирота удаляется, используемые файлы и свежие сироты
  остаются, истёкшая сессия уходит), Vitest на манифест и регистрацию воркера
2026-09-21 19:42:12 +03:00
grendervill 72537e9ff9 fix(invites): картинки оформления отдаются без сессии для страницы приглашения
- GET /files/{id} без сессии отдаёт только оформление сервера и картинку
  приглашения (guild_icon, guild_banner, guild_splash, invite_background):
  их показывают до входа (AGENT.md 7.9), остальное по-прежнему требует сессии
- optionalUser резолвит сессию без ответа об ошибке; путь файла проверяется на
  принадлежность каталогу данных (AGENT.md 9.2)
- аватар пригласившего показывается только вошедшим
- тесты: Go (splash анонимно, вложение закрыто) и правки живого скрипта
2026-09-21 19:30:25 +03:00
314 changed files with 51347 additions and 924 deletions
+20
View File
@@ -26,6 +26,11 @@ MAX_UPLOAD_SIZE=26214400 # байт, по умолчанию 25
SHUTDOWN_TIMEOUT_SECONDS=20 # graceful shutdown
DB_MAINTENANCE_INTERVAL_SECONDS=21600 # PRAGMA optimize раз в 6 часов
# --- чистка данных (Фаза 5) ---
RETENTION_INTERVAL_SECONDS=3600 # как часто запускать чистку (час)
RETENTION_AUDIT_DAYS=180 # сколько дней хранить журнал аудита (0 — всегда)
RETENTION_ORPHAN_HOURS=24 # через сколько часов удалять файлы без ссылок (0 — никогда)
# --- секреты (генерируются установщиком, никогда не коммитить) ---
SESSION_PEPPER= # HMAC-pepper перед Argon2id
MASTER_KEY= # AES-256-GCM для PII (hex, 32 байта)
@@ -33,4 +38,19 @@ TOTP_ENCRYPTION_KEY= # шифрование TOTP-секрет
WEBHOOK_SECRET= # подпись вебхуков
LIVEKIT_API_KEY= # ключ LiveKit (фаза 3)
LIVEKIT_API_SECRET= # секрет LiveKit (фаза 3)
# --- Web Push (Фаза 7): уведомления в браузере и на телефоне ---
# Приватный VAPID-ключ (P-256) в PKCS#8 DER (base64, одной строкой). Пусто —
# Web Push выключен. Ключ генерирует установщик; вручную — `glchat vapid-keys`.
VAPID_PRIVATE_KEY=
# Контакт администратора для push-сервисов (mailto:admin@<домен> или https://…).
VAPID_SUBJECT=
# --- превью ссылок (Фаза 7) ---
UNFURL_ENABLED=true # сервер сам загружает превью страниц по ссылкам
UNFURL_TIMEOUT_SECONDS=5 # таймаут загрузки превью
AGE_RECIPIENT= # публичный age-ключ для шифрования бэкапов
BACKUP_KEEP_DAYS= # сколько дней хранить бэкапы (профиль задаёт значение)
BACKUP_OFFSITE_TARGET= # необязательно: rsync-цель (user@host:/path) или rclone:remote:path
BACKUP_OFFSITE_KEEP_DAYS= # необязательно: чистить копии на приёмнике старше N дней (rclone)
+1 -1
View File
@@ -38,5 +38,5 @@ web/node_modules/.cache/
!.vscode/extensions.json
# артефакты Playwright (e2e-прогоны) — локальные
web/test-results/
web/test-results*/
web/playwright-report/
+5
View File
@@ -0,0 +1,5 @@
# GO-2026-5932: golang.org/x/crypto/openpgp — пакет не поддерживается.
# Мы его не импортируем: govulncheck подтверждает 0 вызовов уязвимых символов,
# фиксированной версии у находки нет. Остальные сканеры (govulncheck, gitleaks)
# чистые; см. docs/DECISIONS.md D-057.
GO-2026-5932
+3
View File
@@ -4,3 +4,6 @@ Expires: 2027-12-31T00:00:00.000Z
Preferred-Languages: ru, en
Canonical: https://gl.mhspx.su/.well-known/security.txt
Policy: https://gl.mhspx.su/security
# Этот файл отдаётся по адресу /.well-known/security.txt: копия для сборки
# лежит в web/public/.well-known/security.txt и должна совпадать с этой.
+6 -3
View File
@@ -1,7 +1,10 @@
# Образы пинятся по digest (AGENT.md 11.3): тег может быть перезаписан в
# реестре, digest — нет. Обновление: получить новый digest
# (`docker buildx imagetools inspect <образ>` или Docker Hub API) и заменить.
# syntax=docker/dockerfile:1
# ---------- web ----------
FROM node:24-bookworm-slim AS web
FROM node:24-bookworm-slim@sha256:0e0ff40c39bc087845bfb27465a0df4ea419520094bc35842ff83dd8cbe6f9b6 AS web
WORKDIR /src/web
COPY web/package.json web/package-lock.json ./
RUN npm ci --no-audit --no-fund
@@ -9,7 +12,7 @@ COPY web/ ./
RUN npm run build
# ---------- go ----------
FROM golang:1.27-bookworm AS gobuild
FROM golang:1.27-bookworm@sha256:69a7b9788769bec032d238959b61854e9ae87f57be9029ec04e9885fabf99195 AS gobuild
WORKDIR /src
ARG VERSION=dev
ARG COMMIT=none
@@ -36,7 +39,7 @@ RUN CC=musl-gcc CGO_ENABLED=1 go build \
# ---------- runtime ----------
# База alpine + статический бинарник: образ укладывается в бюджет §9.1
# (<50 MB). Проверка: docs/DECISIONS.md, решение D-007.
FROM alpine:3.20 AS runtime
FROM alpine:3.20@sha256:d9e853e87e55526f6b2917df91a2115c36dd7c696a35be12163d44e6e2a4b6bc AS runtime
RUN addgroup -S -g 10001 glchat \
&& adduser -S -u 10001 -G glchat -h /app -s /sbin/nologin glchat \
+91 -2
View File
@@ -3,7 +3,10 @@ SHELL := /usr/bin/env bash
APP := glchat
MODULE := glchat
VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || echo dev)
# Версия релиза — единственный источник правды: файл VERSION в корне (его же
# читает установщик, а tauri.conf.json/Cargo.toml держат то же значение).
# Без файла (например, в архиве без него) работает прежнее правило по git.
VERSION ?= $(shell cat $(CURDIR)/VERSION 2>/dev/null || git describe --tags --always --dirty 2>/dev/null || echo dev)
COMMIT ?= $(shell git rev-parse --short HEAD 2>/dev/null || echo none)
BUILD_DATE ?= $(shell date -u +%Y-%m-%dT%H:%M:%SZ)
@@ -142,18 +145,29 @@ generate: tools ## Проверить сгенерированные артеф
else echo "==> sqlc: запросов пока нет, пропускаем"; fi
.PHONY: security
security: tools ## AppSec: govulncheck, gitleaks, trivy (по возможности)
security: tools ## AppSec: govulncheck, gitleaks, npm audit, trivy (по возможности)
@mkdir -p $(BUILD_DIR)
@echo "==> govulncheck"
@govulncheck ./cmd/... ./internal/... || { echo "govulncheck нашёл уязвимости"; exit 1; }
@echo "==> gitleaks"
@$(ROOT)/scripts/gitleaks-scan.sh
@echo "==> npm audit (web, только production-зависимости)"
@cd $(WEB_DIR) && npm audit --omit=dev --audit-level=high || { echo "npm audit нашёл уязвимости"; exit 1; }
@echo "==> trivy filesystem scan"
@if command -v trivy >/dev/null 2>&1; then \
trivy fs --quiet --scanners vuln,secret,misconfig --exit-code 1 \
--skip-dirs .cache --skip-dirs .git --skip-dirs web/node_modules .; \
else echo "trivy not installed — skipped"; fi
.PHONY: sbom
sbom: ## SBOM зависимостей в build/sbom.cdx.json (trivy, если установлен)
@mkdir -p $(BUILD_DIR)
@if command -v trivy >/dev/null 2>&1; then \
trivy fs --quiet --format cyclonedx --output $(BUILD_DIR)/sbom.cdx.json \
--skip-dirs .cache --skip-dirs web/node_modules . && \
echo "SBOM: $(BUILD_DIR)/sbom.cdx.json"; \
else echo "trivy не установлен — SBOM не собран (запустите на сервере сборки)"; fi
.PHONY: secrets-scan
secrets-scan: tools ## Только проверка на утечки секретов
@$(ROOT)/scripts/gitleaks-scan.sh
@@ -175,6 +189,66 @@ web-test: ## Тесты web-клиента (vitest)
web-build: ## Сборка web-клиента в web/dist
cd $(WEB_DIR) && npm run build
# Desktop-обёртка (Фаза 6, docs/client-tauri.md). Кэши инструментов — в .cache,
# адрес инстанса по умолчанию задаёт сама обёртка (https://gl.mhspx.su).
DESKTOP_DIR := $(ROOT)/desktop
DESKTOP_TAURI_DIR := $(DESKTOP_DIR)/src-tauri
DESKTOP_KEY ?= $(BUILD_DIR)/desktop-keys/glchat-updater.key
# Бандлы Tauri: `all` — обычная сборка (значение по умолчанию из tauri.conf.json),
# `app` — только .app (и артефакты обновления) без .dmg: сборка .dmg монтирует
# образ и требует Finder. Tauri CLI не принимает `all` в --bundles: при `all`
# флаг не передаётся вовсе, и список бандлов берётся из bundle.targets.
DESKTOP_BUNDLES ?= all
DESKTOP_BUNDLES_ARG := $(if $(filter-out all,$(DESKTOP_BUNDLES)),--bundles $(DESKTOP_BUNDLES),)
TAURI_CLI ?= npx --yes @tauri-apps/cli@^2
export CARGO_HOME ?= $(CACHE_DIR)/cargo
export NPM_CONFIG_CACHE ?= $(CACHE_DIR)/npm
.PHONY: desktop-check
desktop-check: ## Проверки Rust-обёртки: fmt, clippy, тесты
cd $(DESKTOP_TAURI_DIR) && cargo fmt --check
cd $(DESKTOP_TAURI_DIR) && cargo clippy --release --all-targets
cd $(DESKTOP_TAURI_DIR) && cargo test --release
.PHONY: desktop-build
desktop-build: ## Собрать desktop-приложение (бандлы по умолчанию для текущей ОС)
@if [ -f "$(DESKTOP_KEY)" ]; then \
echo " ключ подписи обновлений: $(DESKTOP_KEY)"; \
cd $(DESKTOP_TAURI_DIR) && \
TAURI_SIGNING_PRIVATE_KEY="$$(cat $(DESKTOP_KEY))" \
TAURI_SIGNING_PRIVATE_KEY_PASSWORD="$${TAURI_SIGNING_PRIVATE_KEY_PASSWORD:-glchat-local-dev}" \
$(TAURI_CLI) build $(DESKTOP_BUNDLES_ARG); \
else \
echo " ключа $(DESKTOP_KEY) нет: сборка без артефактов автообновления"; \
cd $(DESKTOP_TAURI_DIR) && \
$(TAURI_CLI) build $(DESKTOP_BUNDLES_ARG) \
--config '{"bundle":{"createUpdaterArtifacts":false}}'; \
fi
.PHONY: desktop-run
desktop-run: ## Запустить собранное приложение (macOS); доп. аргументы — ARGS=…
open -a "$(DESKTOP_TAURI_DIR)/target/release/bundle/macos/glchat.app" $(ARGS)
.PHONY: desktop-build-app
desktop-build-app: ## Собрать .app и артефакты обновления без .dmg (dmg требует Finder/AppleScript)
@$(MAKE) --no-print-directory desktop-build DESKTOP_BUNDLES=app
# Каталог обновлений инстанса и его публичный адрес (docs/client-tauri.md §4).
# Раскладку делает scripts/desktop-release.sh: манифест версии + latest.json,
# артефакт и подпись; приложение отдаёт их по /updates/{target}/{arch}/{version}.
DESKTOP_UPDATES_DIR ?= /opt/glchat/updates
DESKTOP_BASE_URL ?= https://gl.mhspx.su
.PHONY: desktop-release
desktop-release: ## Разложить артефакты обновления в DESKTOP_UPDATES_DIR (нужен sudo)
bash scripts/desktop-release.sh --dir "$(DESKTOP_UPDATES_DIR)" \
--base-url "$(DESKTOP_BASE_URL)" $(ARGS)
.PHONY: desktop-release-upload
desktop-release-upload: ## Загрузить обновления на инстанс: make desktop-release-upload HOST=user@server
bash scripts/desktop-release.sh --remote "$(HOST)" \
--base-url "$(DESKTOP_BASE_URL)" $(ARGS)
.PHONY: docker-build
docker-build: ## Собрать образ приложения
docker build \
@@ -193,6 +267,21 @@ install-dry-run: ## Проверить установщик без измене
install-test: ## Прогон install.sh в чистом контейнере Ubuntu 24.04
bash scripts/test-install.sh
.PHONY: profiles-test
profiles-test: ## Проверить таблицу профилей производительности (AGENT.md 10.10)
bash scripts/test-profiles.sh
.PHONY: backup-test
backup-test: ## Проверить бэкап и offsite-выгрузку на синтетическом инстансе
bash scripts/test-backup.sh
.PHONY: profiles-env
profiles-env: ## Сверить .env установленного инстанса с таблицей профилей
sudo bash scripts/test-profiles.sh --env "$${GLCHAT_ETC_DIR:-/opt/glchat}/.env"
.PHONY: ops-test
ops-test: profiles-test backup-test ## Проверки эксплуатационных скриптов
.PHONY: clean
clean: ## Удалить артефакты сборки
rm -rf $(BUILD_DIR)
+3
View File
@@ -36,6 +36,9 @@ Copyright (C) 2026 glchat contributors.
| [TanStack Query](https://tanstack.com/query) | MIT | Серверное состояние клиента |
| [i18next](https://www.i18next.com) | MIT | Локализация ru/en |
| [age](https://age-encryption.org) (filippo.io/age) | BSD-3-Clause | Шифрование бэкапов |
| [go-webauthn/webauthn](https://github.com/go-webauthn/webauthn) | BSD-3-Clause | Passkeys (WebAuthn): регистрация и вход по ключу (Фаза 7) |
| [golang.org/x/oauth2](https://pkg.go.dev/golang.org/x/oauth2) | BSD-3-Clause | OAuth2-провайдеры входа: GitHub, Google, Discord (Фаза 7) |
| [fxamacker/cbor](https://github.com/fxamacker/cbor) | MIT | CBOR для WebAuthn (транзитивная зависимость go-webauthn) |
Полный список Go-зависимостей с версиями — `go.mod`/`go.sum`; JS-зависимостей —
`web/package.json`/`web/package-lock.json`. Совместимость лицензий с AGPL-3.0
+1
View File
@@ -0,0 +1 @@
0.1.2
+232
View File
@@ -2,18 +2,23 @@ package main
import (
"context"
"encoding/json"
"errors"
"flag"
"fmt"
"log/slog"
"os"
"strconv"
"time"
webpush "github.com/SherClockHolmes/webpush-go"
"github.com/pquerna/otp/totp"
"glchat/internal/auth"
"glchat/internal/bootstrap"
"glchat/internal/config"
"glchat/internal/database"
"glchat/internal/retention"
"glchat/internal/store"
)
@@ -31,6 +36,16 @@ func runCLI(args []string) int {
return cliTOTPSetup(args[1:])
case "totp-reset":
return cliTOTPReset(args[1:])
case "create-user":
return cliCreateUser(args[1:])
case "delete-user":
return cliDeleteUser(args[1:])
case "cleanup":
return cliCleanup(args[1:])
case "reindex":
return cliReindex(args[1:])
case "vapid-keys":
return cliVAPIDKeys(args[1:])
default:
fmt.Fprintf(os.Stderr, "glchat: неизвестная команда %q\n", args[0])
return 2
@@ -264,3 +279,220 @@ func cliSetInstanceAdmin(command string, args []string) int {
return nil
})
}
// cliCreateUser создаёт аккаунты по команде обслуживания: регистрация на
// инстансе может быть выключена, а для нагрузочного теста (AGENT.md 11.4)
// нужны десятки пользователей. Пароль задаёт оператор, аккаунты создаются без
// сессий; список логинов и паролей можно выгрузить в файл для генератора
// нагрузки (--out).
func cliCreateUser(args []string) int {
flags := flag.NewFlagSet("create-user", flag.ContinueOnError)
email := flags.String("email", "", "email пользователя")
username := flags.String("username", "", "username (по умолчанию — часть email)")
password := flags.String("password", "", "пароль")
count := flags.Int("count", 1, "сколько аккаунтов создать (для нагрузки)")
prefix := flags.String("prefix", "", "префикс логина в пакетном режиме, например loadtest")
domain := flags.String("domain", "loadtest.local", "домен email в пакетном режиме")
out := flags.String("out", "", "файл с созданными учётными данными (JSON)")
sessions := flags.Bool("sessions", false, "выдать каждому аккаунту сессию (для нагрузочного теста)")
if err := flags.Parse(args); err != nil {
return 2
}
if *password == "" {
fmt.Fprintln(os.Stderr, "glchat create-user: укажите --password")
return 2
}
if *count > 1 && *prefix == "" {
fmt.Fprintln(os.Stderr, "glchat create-user: для --count > 1 нужен --prefix")
return 2
}
if *count <= 1 && *email == "" {
fmt.Fprintln(os.Stderr, "glchat create-user: укажите --email")
return 2
}
type credentials struct {
ID string `json:"id"`
Username string `json:"username"`
Email string `json:"email"`
Password string `json:"password"`
Token string `json:"token,omitempty"`
}
created := make([]credentials, 0, *count)
code := withServices(func(ctx context.Context, _ *store.Store, authService *auth.Service, _ config.Config) error {
for index := 1; index <= *count; index += 1 {
name := *username
address := *email
if *count > 1 {
name = fmt.Sprintf("%s%d", *prefix, index)
address = fmt.Sprintf("%s%d@%s", *prefix, index, *domain)
}
user, err := authService.CreateUserByOperator(ctx, auth.RegisterInput{
Username: name,
Email: address,
Password: *password,
})
if err != nil {
if errors.Is(err, auth.ErrUsernameTaken) || errors.Is(err, auth.ErrEmailTaken) {
fmt.Printf("пропущен (уже существует): %s\n", address)
continue
}
return fmt.Errorf("создать %s: %w", address, err)
}
entry := credentials{
ID: strconv.FormatUint(user.ID, 10),
Username: user.Username,
Email: address,
Password: *password,
}
if *sessions {
token, err := authService.IssueSessionForOperator(ctx, user.ID, "glchat-cli loadtest")
if err != nil {
return fmt.Errorf("сессия для %s: %w", address, err)
}
entry.Token = token
}
created = append(created, entry)
if *count == 1 || index%25 == 0 || index == *count {
fmt.Printf("создано %d из %d\n", index, *count)
}
}
return nil
})
if code != 0 {
return code
}
if *out != "" {
// #nosec G117 -- файл с тестовыми паролями создаётся намеренно, права 600.
encoded, err := json.MarshalIndent(created, "", " ")
if err != nil {
fmt.Fprintf(os.Stderr, "glchat create-user: %v\n", err)
return 1
}
// Файл содержит пароли тестовых аккаунтов: права 600.
if err := os.WriteFile(*out, encoded, 0o600); err != nil {
fmt.Fprintf(os.Stderr, "glchat create-user: %v\n", err)
return 1
}
fmt.Printf("учётные данные: %s (%d)\n", *out, len(created))
}
fmt.Printf("создано аккаунтов: %d\n", len(created))
return 0
}
// cliDeleteUser мягко удаляет аккаунт по команде обслуживания — ровно то же,
// что делает админ-панель инстанса (`DELETE /instance/users/{id}`): запись
// пользователя остаётся, но он исчезает из серверов, списков и сессий, а
// сообщения и журнал аудита сохраняются (AGENT.md 6.4). Аккаунт инстанс-админа
// удалить нельзя: это оставило бы инстанс без администратора — сначала
// `glchat remove-admin`.
func cliDeleteUser(args []string) int {
flags := flag.NewFlagSet("delete-user", flag.ContinueOnError)
email := flags.String("email", "", "email пользователя")
yes := flags.Bool("yes", false, "подтверждение удаления (без него команда только показывает, что будет удалено)")
positional := flags.Args()
if err := flags.Parse(args); err != nil {
return 2
}
if *email == "" && len(positional) > 0 {
*email = positional[0]
}
if *email == "" {
fmt.Fprintln(os.Stderr, "glchat delete-user: укажите email")
return 2
}
return withServices(func(ctx context.Context, st *store.Store, authService *auth.Service, _ config.Config) error {
user, err := authService.UserByEmail(ctx, *email)
if err != nil {
return fmt.Errorf("пользователь с таким email не найден")
}
if user.IsInstanceAdmin {
return fmt.Errorf("аккаунт %s — администратор инстанса: сначала снимите права командой glchat remove-admin", user.Username)
}
if !*yes {
fmt.Printf("будет удалён аккаунт %s <%s> (id %d): сессии отзываются, участие в серверах снимается,\n"+
"сообщения и журнал аудита остаются. Ничего не удалено — повторите с --yes.\n", user.Username, *email, user.ID)
return nil
}
if err := st.SoftDeleteUser(ctx, user.ID); err != nil {
return err
}
fmt.Printf("аккаунт %s <%s> удалён, сессии отозваны\n", user.Username, *email)
return nil
})
}
// cliCleanup запускает чистку вручную: сессии, аудит и файлы без ссылок
// (AGENT.md 6.4, 10.7).
func cliCleanup(args []string) int {
flags := flag.NewFlagSet("cleanup", flag.ContinueOnError)
auditDays := flags.Int("audit-days", -1, "сколько дней хранить журнал аудита (по умолчанию из конфигурации)")
orphanHours := flags.Int("orphan-hours", -1, "через сколько часов удалять файлы без ссылок")
if err := flags.Parse(args); err != nil {
return 2
}
return withServices(func(ctx context.Context, st *store.Store, _ *auth.Service, cfg config.Config) error {
options := retention.Options{
DataDir: cfg.DataDir,
AuditDays: cfg.RetainAuditDays,
OrphanHours: cfg.RetainOrphanHours,
Logger: slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelInfo})),
}
if *auditDays >= 0 {
options.AuditDays = *auditDays
}
if *orphanHours >= 0 {
options.OrphanHours = *orphanHours
}
stats, err := retention.New(st, options).RunOnce(ctx)
fmt.Printf("удалено: сессий %d, записей аудита %d, файлов %d, файлов без записи %d, "+
"подписок Web Push %d, превью ссылок %d (%d байт)\n",
stats.Sessions, stats.Audit, stats.Files, stats.Unknown,
stats.PushSubscriptions, stats.LinkPreviews, stats.Bytes)
return err
})
}
// cliReindex пересобирает индекс полнотекстового поиска (AGENT.md 10.7).
func cliReindex(_ []string) int {
return withServices(func(ctx context.Context, st *store.Store, _ *auth.Service, _ config.Config) error {
if err := st.RebuildSearchIndex(ctx); err != nil {
return err
}
fmt.Println("индекс поиска пересобран")
return nil
})
}
// cliVAPIDKeys печатает пару VAPID-ключей для Web Push (AGENT.md 7.16).
// Установщик генерирует ключ сам; команда нужна тем, кто ставил инстанс иначе
// или меняет ключ вручную. Публичный ключ приложение выводит из приватного,
// поэтому в .env достаточно одной строки.
func cliVAPIDKeys(args []string) int {
flags := flag.NewFlagSet("vapid-keys", flag.ContinueOnError)
subject := flags.String("subject", "", "контакт администратора для push-сервисов (mailto:… или https://…)")
if err := flags.Parse(args); err != nil {
return 2
}
domain := "localhost"
if cfg, err := config.Load(); err == nil && cfg.Domain != "" {
domain = cfg.Domain
}
contact := *subject
if contact == "" {
contact = "mailto:admin@" + domain
}
privateKey, publicKey, err := webpush.GenerateVAPIDKeys()
if err != nil {
fmt.Fprintf(os.Stderr, "glchat: не удалось сгенерировать ключи: %v\n", err)
return 1
}
fmt.Println("# Web Push (Фаза 7): строки ниже добавьте в /opt/glchat/.env и перезапустите glchat-app.")
fmt.Printf("VAPID_PRIVATE_KEY=%s\n", privateKey)
fmt.Printf("VAPID_SUBJECT=%s\n", contact)
// Публичный ключ выводится приложением из приватного: печатаем справочно.
fmt.Printf("# публичный ключ: %s\n", publicKey)
return 0
}
+22 -3
View File
@@ -18,6 +18,7 @@ import (
"glchat/internal/database"
"glchat/internal/gateway"
"glchat/internal/permissions"
"glchat/internal/retention"
"glchat/internal/server"
"glchat/internal/source"
"glchat/internal/store"
@@ -37,11 +38,12 @@ func main() {
os.Exit(healthcheck())
}
// Подкоманды обслуживания (AGENT.md 10.7): bootstrap-admin, reset-password,
// make-admin, remove-admin, totp-setup, totp-reset. Остальные команды
// реализует shell-обёртка glchat.
// make-admin, remove-admin, totp-setup, totp-reset, create-user, delete-user,
// cleanup, reindex. Остальные команды реализует shell-обёртка glchat.
if len(os.Args) > 1 {
switch os.Args[1] {
case "bootstrap-admin", "reset-password", "make-admin", "remove-admin", "totp-setup", "totp-reset":
case "bootstrap-admin", "reset-password", "make-admin", "remove-admin", "totp-setup", "totp-reset",
"create-user", "delete-user", "cleanup", "reindex", "vapid-keys":
os.Exit(runCLI(os.Args[1:]))
}
}
@@ -159,10 +161,27 @@ func run() error {
} else if updated > 0 {
logger.InfoContext(ctx, "search index backfilled", slog.Int("messages", updated))
}
// То же для названий серверов: поиск в админ-панели инстанса ищет по
// нормализованной копии (AGENT.md 7.18).
if updated, err := st.BackfillGuildNameLower(ctx, 500); err != nil {
logger.WarnContext(ctx, "backfill guild names failed", slog.Any("error", err))
} else if updated > 0 {
logger.InfoContext(ctx, "guild names backfilled", slog.Int("guilds", updated))
}
srv := server.New(cfg, db, logger, server.Deps{
Store: st, Auth: authService, Gateway: gatewayService, Permissions: calculator,
})
// Чистка данных инстанса: сессии, аудит и файлы без ссылок (AGENT.md 6.4).
cleaner := retention.New(st, retention.Options{
DataDir: cfg.DataDir,
AuditDays: cfg.RetainAuditDays,
OrphanHours: cfg.RetainOrphanHours,
Logger: logger,
})
stopRetention := cleaner.Run(ctx, cfg.RetainInterval)
defer stopRetention()
// Сторож голосовых состояний: убирает «призрачные» записи после сбоев.
srv.StartVoiceWatchdog(context.Background(), 0)
errCh := make(chan error, 1)
+577
View File
@@ -0,0 +1,577 @@
// loadgen — генератор нагрузки для проверки целевых показателей AGENT.md 9.1,
// 11.4: держит заданное число WebSocket-клиентов Gateway, шлёт сообщения с
// нужной частотой и измеряет задержку доставки и ответы API.
//
// Аккаунты готовит команда обслуживания на сервере:
//
// glchat create-user --count 100 --prefix loadtest --password '...' \
// --out /tmp/loadtest-users.json --sessions
//
// Затем генератор запускается с машины-генератора (не с сервера, иначе он
// отбирает у сервера процессор и портит замеры):
//
// go run ./cmd/loadgen -base https://gl.mhspx.su \
// -creds /tmp/loadtest-users.json -invite <код> \
// -clients 100 -rate 20 -duration 60s
package main
import (
"context"
"encoding/json"
"errors"
"flag"
"fmt"
"math/rand"
"net/http"
"os"
"sort"
"strings"
"sync"
"sync/atomic"
"time"
"github.com/coder/websocket"
)
type credentials struct {
ID string `json:"id"`
Username string `json:"username"`
Email string `json:"email"`
Password string `json:"password"`
Token string `json:"token"`
}
// envelope — кадр Gateway (AGENT.md 8.2).
type envelope struct {
Op int `json:"op"`
T string `json:"t,omitempty"`
D json.RawMessage `json:"d,omitempty"`
S int64 `json:"s,omitempty"`
}
const (
opDispatch = 0
opHeartbeat = 1
opIdentify = 2
opHello = 10
opHeartbeatAck = 11
)
// report — итог прогона: его печатают и складывают в JSON.
type report struct {
Clients int `json:"clients"`
Rate float64 `json:"messages_per_second"`
DurationSeconds float64 `json:"duration_seconds"`
Sent int64 `json:"messages_sent"`
SendErrors int64 `json:"send_errors"`
Received int64 `json:"events_received"`
Delivered int64 `json:"messages_delivered"`
DeliveryP50MS float64 `json:"delivery_p50_ms"`
DeliveryP95MS float64 `json:"delivery_p95_ms"`
DeliveryP99MS float64 `json:"delivery_p99_ms"`
DeliveryMaxMS float64 `json:"delivery_max_ms"`
APIP50MS float64 `json:"api_p50_ms"`
APIP95MS float64 `json:"api_p95_ms"`
APIMaxMS float64 `json:"api_max_ms"`
APIRequests int64 `json:"api_requests"`
APIErrors int64 `json:"api_errors"`
ReadyClients int `json:"ready_clients"`
Disconnects int64 `json:"disconnects"`
StartedAt time.Time `json:"started_at"`
}
func main() {
base := flag.String("base", "https://gl.mhspx.su", "адрес инстанса")
credsPath := flag.String("creds", "", "файл с учётными данными (glchat create-user --out --sessions)")
invite := flag.String("invite", "", "код приглашения в тестовый сервер (необязательно)")
channelID := flag.String("channel", "", "комната для сообщений (по умолчанию — первая текстовая сервера)")
guildID := flag.String("guild", "", "сервер, в котором работают клиенты")
clients := flag.Int("clients", 100, "число WS-клиентов")
rate := flag.Float64("rate", 20, "частота сообщений в секунду (все клиенты вместе)")
duration := flag.Duration("duration", 60*time.Second, "длительность нагрузки")
warmup := flag.Duration("warmup", 5*time.Second, "прогрев до начала замера")
apiEvery := flag.Duration("api-interval", 2*time.Second, "частота проверок API одним клиентом")
jsonOut := flag.String("json", "", "куда записать отчёт в JSON")
flag.Parse()
if *credsPath == "" {
fmt.Fprintln(os.Stderr, "loadgen: укажите -creds")
os.Exit(2)
}
all, err := readCredentials(*credsPath)
if err != nil {
fmt.Fprintf(os.Stderr, "loadgen: %v\n", err)
os.Exit(1)
}
if *clients > len(all) {
fmt.Fprintf(os.Stderr, "loadgen: в файле %d аккаунтов, нужно %d\n", len(all), *clients)
os.Exit(2)
}
baseURL := strings.TrimRight(*base, "/")
runner := &runner{
baseURL: baseURL,
clients: all[:*clients],
rate: *rate,
warmup: *warmup,
apiEvery: *apiEvery,
}
if *invite != "" {
if err := runner.joinGuild(context.Background(), *invite); err != nil {
fmt.Fprintf(os.Stderr, "loadgen: %v\n", err)
os.Exit(1)
}
}
target, err := runner.resolveChannel(context.Background(), *guildID, *channelID)
if err != nil {
fmt.Fprintf(os.Stderr, "loadgen: %v\n", err)
os.Exit(1)
}
runner.guildID = target.guildID
runner.channelID = target.channelID
fmt.Printf("сервер %s, комната %s, клиентов %d, темп %.1f msg/s, длительность %s\n",
target.guildID, target.channelID, len(runner.clients), *rate, duration.String())
final := runner.run(context.Background(), *duration)
printReport(final)
if *jsonOut != "" {
if err := writeReport(*jsonOut, final); err != nil {
fmt.Fprintf(os.Stderr, "loadgen: %v\n", err)
os.Exit(1)
}
fmt.Printf("отчёт: %s\n", *jsonOut)
}
}
func readCredentials(path string) ([]credentials, error) {
// Файл создаёт оператор командой create-user: путь задаётся явно.
raw, err := os.ReadFile(path) //nolint:gosec
if err != nil {
return nil, err
}
var list []credentials
if err := json.Unmarshal(raw, &list); err != nil {
return nil, fmt.Errorf("разобрать %s: %w", path, err)
}
for _, item := range list {
if item.Token == "" {
return nil, errors.New("в файле нет сессий: создайте аккаунты с флагом --sessions")
}
}
return list, nil
}
type channelTarget struct {
guildID string
channelID string
}
type runner struct {
baseURL string
clients []credentials
rate float64
warmup time.Duration
apiEvery time.Duration
client *http.Client
guildID string
channelID string
sent atomic.Int64
sendErrors atomic.Int64
received atomic.Int64
delivered atomic.Int64
disconnect atomic.Int64
apiCount atomic.Int64
apiErrors atomic.Int64
ready atomic.Int64
mu sync.Mutex
sendTimes map[string]time.Time
latencies []float64
apiLatency []float64
}
func (r *runner) httpClient() *http.Client {
if r.client == nil {
r.client = &http.Client{Timeout: 15 * time.Second}
}
return r.client
}
// joinGuild вступает в тестовый сервер по приглашению от имени всех клиентов.
func (r *runner) joinGuild(ctx context.Context, code string) error {
failed := atomic.Int64{}
var wg sync.WaitGroup
for _, item := range r.clients {
wg.Add(1)
go func(item credentials) {
defer wg.Done()
response, err := r.do(ctx, item.Token, http.MethodPost, "/api/v1/invites/"+code, nil)
if err != nil {
failed.Add(1)
return
}
defer func() { _ = response.Body.Close() }()
// 409 — уже участник: это не ошибка прогона.
if response.StatusCode >= 400 && response.StatusCode != http.StatusConflict {
failed.Add(1)
}
}(item)
}
wg.Wait()
if failed.Load() > 0 {
return fmt.Errorf("не все клиенты вошли в сервер: ошибок %d", failed.Load())
}
fmt.Printf("клиенты вошли в сервер по приглашению %s\n", code)
return nil
}
// resolveChannel выбирает сервер и текстовую комнату для нагрузки.
func (r *runner) resolveChannel(ctx context.Context, guildID, channelID string) (channelTarget, error) {
if guildID == "" {
response, err := r.do(ctx, r.clients[0].Token, http.MethodGet, "/api/v1/users/@me/guilds", nil)
if err != nil {
return channelTarget{}, err
}
defer func() { _ = response.Body.Close() }()
var payload struct {
Guilds []struct {
ID string `json:"id"`
} `json:"guilds"`
}
if err := json.NewDecoder(response.Body).Decode(&payload); err != nil {
return channelTarget{}, err
}
if len(payload.Guilds) == 0 {
return channelTarget{}, errors.New("у клиента нет серверов: передайте -guild и -invite")
}
guildID = payload.Guilds[0].ID
}
response, err := r.do(ctx, r.clients[0].Token, http.MethodGet, "/api/v1/guilds/"+guildID+"/channels", nil)
if err != nil {
return channelTarget{}, err
}
defer func() { _ = response.Body.Close() }()
var payload struct {
Channels []struct {
ID string `json:"id"`
Type string `json:"type"`
} `json:"channels"`
}
if err := json.NewDecoder(response.Body).Decode(&payload); err != nil {
return channelTarget{}, err
}
if channelID == "" {
for _, channel := range payload.Channels {
if channel.Type == "text" {
channelID = channel.ID
break
}
}
}
if channelID == "" {
return channelTarget{}, errors.New("в сервере нет текстовых комнат")
}
return channelTarget{guildID: guildID, channelID: channelID}, nil
}
// run поднимает клиентов, греет их, затем держит нагрузку заданное время.
func (r *runner) run(ctx context.Context, duration time.Duration) report {
r.sendTimes = make(map[string]time.Time, 4096)
started := time.Now().UTC()
runCtx, cancel := context.WithCancel(ctx)
defer cancel()
var wg sync.WaitGroup
for index := range r.clients {
wg.Add(1)
go func(index int) {
defer wg.Done()
r.clientLoop(runCtx, index)
}(index)
}
// Ждём READY у большинства клиентов: иначе замер пойдёт по «холодным» сессиям.
deadline := time.Now().Add(30 * time.Second)
for time.Now().Before(deadline) && r.ready.Load() < int64(len(r.clients)*9/10) {
time.Sleep(200 * time.Millisecond)
}
fmt.Printf("готовы к работе: %d из %d клиентов\n", r.ready.Load(), len(r.clients))
time.Sleep(r.warmup)
sendCtx, stopSending := context.WithTimeout(runCtx, duration)
defer stopSending()
r.sendLoop(sendCtx)
// Даём последним сообщениям дойти до клиентов.
time.Sleep(2 * time.Second)
cancel()
wg.Wait()
final := r.report(started, duration)
return final
}
// sendLoop шлёт сообщения с заданной частотой, распределяя их по клиентам.
func (r *runner) sendLoop(ctx context.Context) {
interval := time.Duration(float64(time.Second) / r.rate)
ticker := time.NewTicker(interval)
defer ticker.Stop()
random := rand.New(rand.NewSource(time.Now().UnixNano()))
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
sender := r.clients[random.Intn(len(r.clients))]
go r.sendMessage(ctx, sender)
}
}
}
// sendMessage отправляет сообщение и запоминает время отправки по id.
func (r *runner) sendMessage(ctx context.Context, sender credentials) {
body := map[string]any{"content": fmt.Sprintf("нагрузка %d", time.Now().UnixNano())}
response, err := r.do(ctx, sender.Token, http.MethodPost, "/api/v1/channels/"+r.channelID+"/messages", body)
if err != nil {
r.sendErrors.Add(1)
return
}
defer func() { _ = response.Body.Close() }()
if response.StatusCode != http.StatusOK {
r.sendErrors.Add(1)
return
}
var payload struct {
Message struct {
ID string `json:"id"`
} `json:"message"`
}
if err := json.NewDecoder(response.Body).Decode(&payload); err != nil || payload.Message.ID == "" {
r.sendErrors.Add(1)
return
}
r.mu.Lock()
r.sendTimes[payload.Message.ID] = time.Now()
r.mu.Unlock()
r.sent.Add(1)
}
// clientLoop держит WebSocket, отвечает на HELLO, шлёт heartbeat и меряет
// задержку доставки MESSAGE_CREATE, а также периодически дёргает API.
func (r *runner) clientLoop(ctx context.Context, index int) {
item := r.clients[index]
wsURL := strings.Replace(strings.Replace(r.baseURL, "https://", "wss://", 1), "http://", "ws://", 1) + "/gateway"
conn, handshake, err := websocket.Dial(ctx, wsURL, &websocket.DialOptions{
HTTPHeader: http.Header{"Authorization": []string{"Bearer " + item.Token}},
})
if handshake != nil && handshake.Body != nil {
_ = handshake.Body.Close()
}
if err != nil {
r.disconnect.Add(1)
return
}
defer func() { _ = conn.CloseNow() }()
conn.SetReadLimit(1 << 20)
heartbeat := time.NewTicker(30 * time.Second)
defer heartbeat.Stop()
go func() {
for {
select {
case <-ctx.Done():
return
case <-heartbeat.C:
payload, _ := json.Marshal(envelope{Op: opHeartbeat})
if err := conn.Write(ctx, websocket.MessageText, payload); err != nil {
return
}
}
}
}()
if r.apiEvery > 0 {
go r.apiLoop(ctx, item)
}
for {
kind, data, err := conn.Read(ctx)
if err != nil {
if ctx.Err() == nil {
r.disconnect.Add(1)
}
return
}
if kind != websocket.MessageText {
continue
}
var frame envelope
if err := json.Unmarshal(data, &frame); err != nil {
continue
}
switch frame.Op {
case opHello:
// Токен передаём в IDENTIFY: cookie рукопожатия тоже подошла бы,
// но Bearer-токен — основной путь для внешних клиентов (AGENT.md 8.1).
identify, _ := json.Marshal(map[string]any{"token": item.Token})
payload, _ := json.Marshal(envelope{Op: opIdentify, D: identify})
if err := conn.Write(ctx, websocket.MessageText, payload); err != nil {
return
}
case opHeartbeatAck:
// Ответ на heartbeat: разрыв соединения не нужен.
case opDispatch:
r.received.Add(1)
switch frame.T {
case "READY", "RESUMED":
r.ready.Add(1)
case "MESSAGE_CREATE":
r.noteMessage(frame.D)
}
}
}
}
// noteMessage фиксирует задержку доставки конкретного сообщения.
func (r *runner) noteMessage(payload json.RawMessage) {
var message struct {
ID string `json:"id"`
}
if err := json.Unmarshal(payload, &message); err != nil || message.ID == "" {
return
}
now := time.Now()
r.mu.Lock()
sentAt, ok := r.sendTimes[message.ID]
if ok {
delete(r.sendTimes, message.ID)
r.latencies = append(r.latencies, float64(now.Sub(sentAt).Microseconds())/1000)
}
r.mu.Unlock()
if ok {
r.delivered.Add(1)
}
}
// apiLoop измеряет задержку обычных запросов API тем же клиентом.
func (r *runner) apiLoop(ctx context.Context, item credentials) {
ticker := time.NewTicker(r.apiEvery)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
started := time.Now()
response, err := r.do(ctx, item.Token, http.MethodGet, "/api/v1/users/@me", nil)
if err != nil {
r.apiErrors.Add(1)
continue
}
if response.StatusCode != http.StatusOK {
r.apiErrors.Add(1)
_ = response.Body.Close()
continue
}
_ = response.Body.Close()
r.mu.Lock()
r.apiLatency = append(r.apiLatency, float64(time.Since(started).Microseconds())/1000)
r.mu.Unlock()
r.apiCount.Add(1)
}
}
}
func (r *runner) do(ctx context.Context, token, method, path string, body any) (*http.Response, error) {
var reader *strings.Reader
if body == nil {
reader = strings.NewReader("")
} else {
encoded, err := json.Marshal(body)
if err != nil {
return nil, err
}
reader = strings.NewReader(string(encoded))
}
request, err := http.NewRequestWithContext(ctx, method, r.baseURL+path, reader)
if err != nil {
return nil, err
}
request.Header.Set("Authorization", "Bearer "+token)
if body != nil {
request.Header.Set("Content-Type", "application/json")
}
return r.httpClient().Do(request)
}
func (r *runner) report(started time.Time, duration time.Duration) report {
r.mu.Lock()
latencies := append([]float64(nil), r.latencies...)
apiLatency := append([]float64(nil), r.apiLatency...)
undelivered := len(r.sendTimes)
r.mu.Unlock()
sort.Float64s(latencies)
sort.Float64s(apiLatency)
final := report{
Clients: len(r.clients),
Rate: r.rate,
DurationSeconds: duration.Seconds(),
Sent: r.sent.Load(),
SendErrors: r.sendErrors.Load(),
Received: r.received.Load(),
Delivered: r.delivered.Load(),
APIRequests: r.apiCount.Load(),
APIErrors: r.apiErrors.Load(),
ReadyClients: int(r.ready.Load()),
Disconnects: r.disconnect.Load(),
StartedAt: started,
}
final.DeliveryP50MS = percentile(latencies, 0.50)
final.DeliveryP95MS = percentile(latencies, 0.95)
final.DeliveryP99MS = percentile(latencies, 0.99)
if len(latencies) > 0 {
final.DeliveryMaxMS = latencies[len(latencies)-1]
}
final.APIP50MS = percentile(apiLatency, 0.50)
final.APIP95MS = percentile(apiLatency, 0.95)
if len(apiLatency) > 0 {
final.APIMaxMS = apiLatency[len(apiLatency)-1]
}
if undelivered > 0 {
fmt.Printf("внимание: %d сообщений не дошли до клиентов за время прогона\n", undelivered)
}
return final
}
func percentile(sorted []float64, quantile float64) float64 {
if len(sorted) == 0 {
return 0
}
index := int(quantile * float64(len(sorted)-1))
return sorted[index]
}
func printReport(final report) {
fmt.Printf("\n=== нагрузка: %d клиентов, %.0f msg/s, %.0f с ===\n",
final.Clients, final.Rate, final.DurationSeconds)
fmt.Printf("клиентов с READY: %d\n", final.ReadyClients)
fmt.Printf("отправлено сообщений: %d (ошибок %d)\n", final.Sent, final.SendErrors)
fmt.Printf("доставлено: %d\n", final.Delivered)
fmt.Printf("доставка, мс: p50 %.1f | p95 %.1f | p99 %.1f | max %.1f\n",
final.DeliveryP50MS, final.DeliveryP95MS, final.DeliveryP99MS, final.DeliveryMaxMS)
fmt.Printf("API /users/@me, мс: p50 %.1f | p95 %.1f | max %.1f (запросов %d, ошибок %d)\n",
final.APIP50MS, final.APIP95MS, final.APIMaxMS, final.APIRequests, final.APIErrors)
fmt.Printf("событий получено: %d, разрывов WS: %d\n", final.Received, final.Disconnects)
}
func writeReport(path string, final report) error {
encoded, err := json.MarshalIndent(final, "", " ")
if err != nil {
return err
}
// Отчёт не содержит секретов, но рядом лежат учётные данные: 600.
return os.WriteFile(path, encoded, 0o600)
}
+73 -1
View File
@@ -9,6 +9,7 @@ BACKUP_DIR="${GLCHAT_DATA_DIR}/backups"
KEEP_DAYS_DEFAULT=7
log() { printf '[backup] %s\n' "$*"; }
warn() { printf '[backup] внимание: %s\n' "$*" >&2; }
die() {
printf '[backup] ошибка: %s\n' "$*" >&2
exit 1
@@ -20,10 +21,35 @@ set -a && . "$ENV_FILE" && set +a
KEEP_DAYS="${BACKUP_KEEP_DAYS:-$KEEP_DAYS_DEFAULT}"
AGE_RECIPIENT="${AGE_RECIPIENT:-}"
# Выгрузка копии наружу: rsync-цель (user@host:/path) или `rclone:remote:path`
# (AGENT.md 10.8, опциональный offsite). Ошибка выгрузки не отменяет локальный
# бэкап: о ней пишем предупреждение.
OFFSITE_TARGET="${BACKUP_OFFSITE_TARGET:-}"
# Ротация на принимающей стороне — только по явной настройке: пусто значит
# «копим копии, чистим вручную».
OFFSITE_KEEP_DAYS="${BACKUP_OFFSITE_KEEP_DAYS:-}"
case "$OFFSITE_KEEP_DAYS" in
"") ;;
*[!0-9]*)
warn "BACKUP_OFFSITE_KEEP_DAYS='${OFFSITE_KEEP_DAYS}' не число — offsite-ротация выключена"
OFFSITE_KEEP_DAYS=""
;;
esac
STAMP="$(date -u +%Y%m%dT%H%M%SZ)"
# Два запуска в одну секунду не должны затирать друг друга: имя с суффиксом.
SUFFIX=""
suffix_n=1
while [ -e "${BACKUP_DIR}/glchat-${STAMP}${SUFFIX}.tar.gz" ] ||
[ -e "${BACKUP_DIR}/glchat-${STAMP}${SUFFIX}.tar.gz.age" ]; do
SUFFIX="-${suffix_n}"
suffix_n=$((suffix_n + 1))
done
WORK_DIR="$(mktemp -d)"
ARCHIVE="${BACKUP_DIR}/glchat-${STAMP}.tar.gz"
ARCHIVE="${BACKUP_DIR}/glchat-${STAMP}${SUFFIX}.tar.gz"
TARGET="${ARCHIVE}.age"
# ARCHIVE_OR_TARGET — готовый файл бэкапа (с шифрованием или без): его выгружает
# offsite-копирование.
ARCHIVE_OR_TARGET=""
cleanup() { rm -rf "$WORK_DIR"; }
trap cleanup EXIT
@@ -71,13 +97,59 @@ if [ -n "$AGE_RECIPIENT" ]; then
age -r "$AGE_RECIPIENT" -o "$TARGET" "$ARCHIVE" || die "age-шифрование не удалось"
rm -f "$ARCHIVE"
chmod 600 "$TARGET"
ARCHIVE_OR_TARGET="$TARGET"
log "готово: ${TARGET} ($(du -h "$TARGET" | cut -f1))"
else
chmod 600 "$ARCHIVE"
ARCHIVE_OR_TARGET="$ARCHIVE"
log "готово (БЕЗ шифрования, AGE_RECIPIENT не задан): ${ARCHIVE}"
fi
log "ротация: оставляем ${KEEP_DAYS} дн."
find "$BACKUP_DIR" -maxdepth 1 -name 'glchat-*.tar.gz*' -mtime "+${KEEP_DAYS}" -print -delete || true
# Offsite идёт после локальной ротации: наружу уезжает уже актуальный набор.
# Неудача выгрузки не отменяет локальный бэкап — только предупреждение.
if [ -n "$OFFSITE_TARGET" ]; then
log "выгружаем копию: ${OFFSITE_TARGET}"
offsite_ok=0
case "$OFFSITE_TARGET" in
rclone:*)
remote="${OFFSITE_TARGET#rclone:}"
if command -v rclone >/dev/null 2>&1; then
rclone copy "$ARCHIVE_OR_TARGET" "$remote" && offsite_ok=1
else
warn "rclone не установлен: offsite-копия не сделана"
fi
;;
*)
if command -v rsync >/dev/null 2>&1; then
# Цель — каталог на приёмнике; без --delete копии только копятся.
rsync -az --partial "$ARCHIVE_OR_TARGET" "${OFFSITE_TARGET%/}/" && offsite_ok=1
else
warn "rsync не установлен: offsite-копия не сделана"
fi
;;
esac
if [ "$offsite_ok" = "1" ]; then
log "offsite-копия отправлена"
else
warn "offsite-копия не отправлена (локальный бэкап на месте)"
fi
if [ -n "$OFFSITE_KEEP_DAYS" ]; then
case "$OFFSITE_TARGET" in
rclone:*)
if [ "$offsite_ok" = "1" ]; then
rclone delete --min-age "${OFFSITE_KEEP_DAYS}d" "${OFFSITE_TARGET#rclone:}" &&
log "offsite-ротация: удалены копии старше ${OFFSITE_KEEP_DAYS} дн." ||
warn "offsite-ротация не удалась"
fi
;;
*)
warn "offsite-ротация по возрасту поддержана только для rclone — почистите приёмник вручную"
;;
esac
fi
fi
log "бэкапов в каталоге: $(find "$BACKUP_DIR" -maxdepth 1 -name 'glchat-*.tar.gz*' | wc -l)"
+1 -7
View File
@@ -55,10 +55,4 @@ ${DOMAIN} {
}
}
${FILES_DOMAIN} {
encode zstd gzip
import security_headers
handle {
reverse_proxy app:8080
}
}
${GLCHAT_FILES_BLOCK}
+6 -12
View File
@@ -1,7 +1,8 @@
# Сгенерировано deploy/install.sh — не редактируйте вручную.
# Режим: внешний reverse proxy (--external-proxy). Собственный Caddy не запускается:
# блок ниже нужно вставить в конфиг вашего прокси (например, Caddyfile) и перезагрузить его.
# Приложение и LiveKit слушают только 127.0.0.1.
# Приложение и LiveKit слушают ${APP_BIND}:${APP_PORT} и ${LIVEKIT_BIND}:${LIVEKIT_WS_PORT}
# (по умолчанию 127.0.0.1 — в этом случае прокси и стек на одном хосте).
{
email ${ACME_EMAIL}
}
@@ -31,7 +32,7 @@ ${DOMAIN} {
# WebSocket Gateway (фаза 1): длинные соединения без таймаутов.
@gateway path /gateway
handle @gateway {
reverse_proxy 127.0.0.1:${APP_PORT} {
reverse_proxy ${APP_BIND}:${APP_PORT} {
transport http {
read_timeout 1h
write_timeout 1h
@@ -42,7 +43,7 @@ ${DOMAIN} {
# LiveKit сигналинг (WebSocket).
@rtc path /rtc*
handle @rtc {
reverse_proxy 127.0.0.1:${LIVEKIT_WS_PORT} {
reverse_proxy ${LIVEKIT_BIND}:${LIVEKIT_WS_PORT} {
transport http {
read_timeout 1h
write_timeout 1h
@@ -52,16 +53,9 @@ ${DOMAIN} {
# API и статика клиента.
handle {
reverse_proxy 127.0.0.1:${APP_PORT}
reverse_proxy ${APP_BIND}:${APP_PORT}
}
}
# Пользовательский контент — отдельный origin (изоляция cookies/CSP).
${FILES_DOMAIN} {
encode zstd gzip
import glchat_security_headers
handle {
reverse_proxy 127.0.0.1:${APP_PORT}
}
}
${GLCHAT_FILES_BLOCK}
# --- glchat: конец блока --------------------------------------------------
+4
View File
@@ -15,6 +15,9 @@ x-app-env: &app-env
LISTEN_ADDR: "0.0.0.0:8080"
DATA_DIR: "/app/data"
WEB_ROOT: "/app/web"
# Каталог обновлений desktop-обёртки: манифесты и артефакты релиза
# (docs/client-tauri.md §4); смонтирован только для чтения.
UPDATES_DIR: "/app/updates"
LOG_LEVEL: "${LOG_LEVEL}"
LOG_FORMAT: "json"
SQLITE_READ_POOL: "${SQLITE_READ_POOL}"
@@ -39,6 +42,7 @@ services:
- "${GLCHAT_DATA_DIR}/db:/app/data/db"
- "${GLCHAT_DATA_DIR}/files:/app/data/files"
- "${GLCHAT_DATA_DIR}/backups:/app/data/backups"
- "${GLCHAT_UPDATES_DIR}:/app/updates:ro"
logging: *default-logging
deploy:
resources:
+17 -9
View File
@@ -16,6 +16,9 @@ x-app-env: &app-env
LISTEN_ADDR: "0.0.0.0:8080"
DATA_DIR: "/app/data"
WEB_ROOT: "/app/web"
# Каталог обновлений desktop-обёртки: манифесты и артефакты релиза
# (docs/client-tauri.md §4); смонтирован только для чтения.
UPDATES_DIR: "/app/updates"
LOG_LEVEL: "${LOG_LEVEL}"
LOG_FORMAT: "json"
SQLITE_READ_POOL: "${SQLITE_READ_POOL}"
@@ -46,6 +49,7 @@ services:
- "${GLCHAT_DATA_DIR}/db:/app/data/db"
- "${GLCHAT_DATA_DIR}/files:/app/data/files"
- "${GLCHAT_DATA_DIR}/backups:/app/data/backups"
- "${GLCHAT_UPDATES_DIR}:/app/updates:ro"
logging: *default-logging
deploy:
resources:
@@ -72,15 +76,19 @@ services:
LIVEKIT_KEYS: "${LIVEKIT_API_KEY}: ${LIVEKIT_API_SECRET}"
ports:
- "${GLCHAT_LIVEKIT_BIND}:${LIVEKIT_WS_PORT}:7880"
# Медиапорты: тот же адрес привязки (для инстанса за NAT это адрес туннеля).
- "${GLCHAT_LIVEKIT_BIND}:${LIVEKIT_TCP_PORT}:7881"
- "${GLCHAT_LIVEKIT_BIND}:${LIVEKIT_UDP_PORT}:7882/udp"
# TURN: UDP и TURN/TLS. На хосте без белого IP эти порты доступны только
# из туннеля (внешний прокси/релей публикует их на своём адресе).
- "${GLCHAT_LIVEKIT_BIND}:${TURN_UDP_PORT}:${TURN_UDP_PORT}/udp"
- "${GLCHAT_LIVEKIT_BIND}:${TURN_TLS_PORT}:${TURN_TLS_PORT}/tcp"
# Диапазон релея TURN (медиа через relay).
- "${GLCHAT_LIVEKIT_BIND}:${TURN_RELAY_START}-${TURN_RELAY_END}:${TURN_RELAY_START}-${TURN_RELAY_END}/udp"
# Медиапорты: по умолчанию тот же адрес привязки (инстанс за NAT
# публикует их на адресе туннеля). Когда прокси стоит на том же хосте,
# что и стек, адреса расходятся: сигналинг отдаётся наружу через прокси и
# публикуется только для него, а медиапорты обязаны быть доступны
# клиентам из интернета — GLCHAT_LIVEKIT_MEDIA_BIND (по умолчанию равен
# адресу привязки, то есть поведение прежнее).
- "${GLCHAT_LIVEKIT_MEDIA_BIND}:${LIVEKIT_TCP_PORT}:7881"
- "${GLCHAT_LIVEKIT_MEDIA_BIND}:${LIVEKIT_UDP_PORT}:7882/udp"
# TURN: UDP, TURN/TLS и диапазон релея. Строки подставляет установщик
# (GLCHAT_TURN_PORTS): при --no-turn встроенного TURN в стеке нет, и порты
# не публикуются вовсе — иначе занятый на хосте порт (например, чужим
# coturn) ломает запуск стека, а наружу открывались бы лишние порты.
${GLCHAT_TURN_PORTS}
volumes:
- ./livekit/livekit.yaml:/etc/livekit.yaml:ro
- ${GLCHAT_TURN_CERT_DIR}:/etc/certs:ro
+131 -13
View File
@@ -9,6 +9,22 @@ ENV_FILE="${GLCHAT_ETC_DIR}/.env"
COMPOSE="docker compose -f ${COMPOSE_FILE}"
# app_check_url печатает адрес /readyz с учётом привязки приложения: во внешнем
# режиме оно слушает WireGuard-адрес, и проверка localhost всегда падала бы.
app_check_url() {
local port="$1" host="${2:-}"
case "$host" in
0.0.0.0 | "" | "::") host="127.0.0.1" ;;
esac
printf 'http://%s:%s/readyz' "$host" "$port"
}
# env_get читает значение из .env инстанса (пусто, если ключа нет).
env_get() {
[ -f "$ENV_FILE" ] || return 0
sed -n "s/^$1=//p" "$ENV_FILE" | tail -1
}
usage() {
cat <<'EOF'
glchat — управление инстансом
@@ -18,15 +34,23 @@ glchat — управление инстансом
glchat restart [service] перезапуск стека или сервиса
glchat backup бэкап БД и файлов (age-шифрование)
glchat restore <файл> восстановление из бэкапа
glchat verify-backup <файл> проверить бэкап, не трогая данные
glchat update [--channel X] обновление (бэкап → pull → up → smoke)
glchat reset-password <email> сброс пароля пользователя (фаза 1)
glchat make-admin <email> выдать права инстанс-админа (фаза 1)
glchat remove-admin <email> снять права инстанс-админа (фаза 1)
glchat unban <ip> снять бан fail2ban
glchat reset-password <email> сброс пароля пользователя; --password <пароль>
glchat make-admin <email> выдать права инстанс-админа
glchat remove-admin <email> снять права инстанс-админа
glchat totp-setup <email> включить 2FA и напечатать секрет с резервными кодами
glchat totp-reset <email> удалить секрет 2FA (потеря устройства и кодов)
glchat delete-user <email> мягко удалить аккаунт (нужен --yes)
glchat unban <ip> снять бан fail2ban (jail'ы этого хоста;
на внешнем прокси — fail2ban-client там)
glchat fail2ban-status статус jails и забаненных IP
glchat profile [tier] показать/применить профиль производительности
glchat security-check аудит: SSH, fail2ban, порты, лимиты
glchat reindex перестроить FTS-индекс (фаза 2)
glchat reindex пересобрать индекс поиска (FTS5)
glchat vapid-keys сгенерировать пару VAPID-ключей для Web Push
glchat cleanup чистка: сессии, аудит, файлы без ссылок
glchat create-user создать аккаунты (--count/--prefix/--sessions)
glchat change-domain <domain> сменить домен
glchat uninstall [--keep-data] удалить стек
glchat version версия CLI и приложения
@@ -46,9 +70,11 @@ cmd_status() {
echo "== compose =="
$COMPOSE ps
echo
meta="$(curl -fsS "http://127.0.0.1:8080/api/v1/meta" 2>/dev/null || true)"
local check_url
check_url="$(app_check_url "${APP_PORT:-8080}" "$(env_get GLCHAT_APP_BIND)")"
meta="$(curl -fsS "${check_url%/readyz}/api/v1/meta" 2>/dev/null || true)"
version="$(printf '%s' "$meta" | sed -n 's/.*"version":"\([^"]*\)".*/\1/p')"
health="$(curl -fsS "http://127.0.0.1:8080/readyz" 2>/dev/null || echo '{"status":"unreachable"}')"
health="$(curl -fsS "${check_url}" 2>/dev/null || echo '{"status":"unreachable"}')"
echo "== app =="
echo "version: ${version:-unknown}"
echo "readyz: ${health}"
@@ -104,13 +130,52 @@ cmd_fail2ban_status() {
done
}
# cmd_profile показывает текущий профиль или переключает его. Переключение —
# это повторная установка, поэтому форма развёртывания (внешний прокси, адреса
# привязки, внешний TURN) восстанавливается из .env: иначе `glchat profile lite`
# переписал бы compose на встроенный прокси и сломал инстанс за чужим прокси.
cmd_profile() {
if [ "$#" -eq 0 ]; then
echo "профиль: $(sed -n 's/^PROFILE=//p' "$ENV_FILE" 2>/dev/null || echo unknown)"
grep -E '^(SQLITE_|GOMAXPROCS|MAX_UPLOAD_SIZE|APP_CPUS|APP_MEMORY_LIMIT)' "$ENV_FILE" 2>/dev/null || true
return
fi
GLCHAT_ETC_DIR="$GLCHAT_ETC_DIR" bash "${GLCHAT_ETC_DIR}/install.sh" --profile "$1" --reconfigure --yes
require_stack
local args=()
local domain app_bind livekit_bind proxy_mode relay_host
domain="$(env_get DOMAIN)"
[ -n "$domain" ] || {
echo "glchat: в ${ENV_FILE} нет DOMAIN" >&2
exit 1
}
args=(--profile "$1" --reconfigure --yes --domain "$domain")
proxy_mode="$(env_get PROXY_MODE)"
if [ "$proxy_mode" = "external" ]; then
args+=(--external-proxy)
[ "$(env_get TLS_BY_PROXY)" = "true" ] && args+=(--tls-terminated-by-proxy)
fi
app_bind="$(env_get GLCHAT_APP_BIND)"
[ -n "$app_bind" ] && args+=(--app-bind-addr "$app_bind")
livekit_bind="$(env_get GLCHAT_LIVEKIT_BIND)"
[ -n "$livekit_bind" ] && args+=(--livekit-bind-addr "$livekit_bind")
if [ "$(env_get TURN_ENABLED)" = "false" ]; then
args+=(--no-turn)
fi
relay_host="$(env_get TURN_RELAY_HOST)"
if [ -n "$relay_host" ]; then
args+=(--turn-relay-host "$relay_host")
[ -n "$(env_get TURN_RELAY_PORT)" ] && args+=(--turn-relay-port "$(env_get TURN_RELAY_PORT)")
[ -n "$(env_get TURN_RELAY_TLS_PORT)" ] && args+=(--turn-relay-tls-port "$(env_get TURN_RELAY_TLS_PORT)")
[ -n "$(env_get TURN_RELAY_TLS_HOST)" ] && args+=(--turn-relay-tls-host "$(env_get TURN_RELAY_TLS_HOST)")
fi
local ip
for ip in $(env_get FAIL2BAN_IGNOREIP); do
args+=(--ignore-ip "$ip")
done
[ -n "$(env_get ADMIN_EMAIL)" ] && args+=(--admin "$(env_get ADMIN_EMAIL)")
[ -n "$(env_get ADMIN_PASSWORD)" ] && args+=(--password "$(env_get ADMIN_PASSWORD)")
echo "==> переключение профиля на $1 (форма развёртывания берётся из .env)"
GLCHAT_ETC_DIR="$GLCHAT_ETC_DIR" bash "${GLCHAT_ETC_DIR}/install.sh" "${args[@]}"
}
cmd_security_check() {
@@ -150,9 +215,57 @@ cmd_version() {
[ -f "$ENV_FILE" ] && grep -E '^(APP_VERSION|IMAGE_TAG|DOMAIN|PROFILE)=' "$ENV_FILE" || true
}
# cmd_verify_backup проверяет архив бэкапа: расшифровка, целостность базы и
# состав данных (AGENT.md 10.8).
cmd_verify_backup() {
require_stack
[ -n "${1:-}" ] || {
echo "использование: glchat verify-backup <файл.tar.gz[.age]>" >&2
exit 2
}
GLCHAT_ETC_DIR="${GLCHAT_ETC_DIR}" bash "${GLCHAT_ETC_DIR}/verify-backup.sh" "$@"
}
# app_cli выполняет подкоманду обслуживания внутри контейнера приложения
# (AGENT.md 10.7): команды, которым нужна БД, живут в Go-бинарнике, а shell-обёртка
# доставляет их с хоста. `</dev/null` обязателен: `docker compose exec -T`
# подключает stdin и без этого съедает его у вызывающего скрипта — а обёртку
# часто зовут из скрипта, поданного на stdin (`ssh … bash -s`), и он обрывался
# после первой же команды.
app_cli() {
require_stack
${COMPOSE} exec -T app glchat "$@" </dev/null
}
# cmd_reindex пересобирает индекс полнотекстового поиска (AGENT.md 10.7).
cmd_reindex() {
echo "glchat reindex появится в фазе 2 (FTS5)." >&2
exit 1
app_cli reindex
}
# cmd_vapid_keys печатает новую пару VAPID-ключей (Фаза 7): строки из вывода
# добавляются в .env инстанса вручную — смена ключа отзывает все подписки,
# поэтому автоматически её никто не делает.
cmd_vapid_keys() {
app_cli vapid-keys "$@"
}
# cmd_cleanup запускает чистку данных вручную: те же правила, что у
# периодического обслуживания (AGENT.md 6.4).
cmd_cleanup() {
app_cli cleanup "$@"
}
# cmd_create_user создаёт аккаунты командой обслуживания: регистрация может
# быть выключена, а для нагрузочного теста нужны десятки пользователей
# (AGENT.md 10.7, 11.4). Флаг --sessions сразу выдаёт сессии для генератора.
cmd_create_user() {
app_cli create-user "$@"
}
# cmd_account обслуживает аккаунт: сброс пароля, права инстанс-админа, 2FA и
# мягкое удаление (AGENT.md 10.7) — тонкая обёртка над подкомандами бинарника.
cmd_account() {
app_cli "$@"
}
main() {
@@ -165,15 +278,20 @@ main() {
backup) cmd_backup "$@" ;;
restore) cmd_restore "$@" ;;
update) cmd_update "$@" ;;
reset-password | make-admin | remove-admin)
echo "glchat ${cmd}: команда появится в фазе 1 (аккаунты и инстанс)." >&2
exit 1
reset-password | make-admin | remove-admin | totp-setup | totp-reset | delete-user)
# Команды аккаунтов живут в Go-бинарнике (AGENT.md 10.7): обёртка
# только доставляет их в контейнер приложения.
cmd_account "$cmd" "$@"
;;
unban) cmd_unban "$@" ;;
fail2ban-status) cmd_fail2ban_status "$@" ;;
profile) cmd_profile "$@" ;;
security-check) cmd_security_check "$@" ;;
reindex) cmd_reindex "$@" ;;
vapid-keys) cmd_vapid_keys "$@" ;;
verify-backup) cmd_verify_backup "$@" ;;
cleanup) cmd_cleanup "$@" ;;
create-user) cmd_create_user "$@" ;;
change-domain) cmd_change_domain "$@" ;;
uninstall) cmd_uninstall "$@" ;;
version) cmd_version "$@" ;;
+213 -24
View File
@@ -37,6 +37,13 @@ if [ -z "$DEPLOY_DIR" ] || [ ! -f "${DEPLOY_DIR}/lib/common.sh" ]; then
exec bash "${TMP_REPO}/deploy/install.sh" "$@"
fi
# Путь установки задан явно? Спрашиваем до common.sh: он подставляет дефолт
# /opt/glchat, после чего отличить явное значение от дефолтного нельзя.
GLCHAT_ETC_DIR_EXPLICIT=0
if [ -n "${GLCHAT_ETC_DIR:-}" ]; then
GLCHAT_ETC_DIR_EXPLICIT=1
fi
# shellcheck source=lib/common.sh
. "${DEPLOY_DIR}/lib/common.sh"
@@ -71,6 +78,11 @@ APP_BIND_ADDR="${APP_BIND_ADDR:-127.0.0.1}"
ALLOW_PORTS="${ALLOW_PORTS:-}"
ALLOW_FROM="${ALLOW_FROM:-10.77.0.0/24}"
LIVEKIT_BIND_ADDR="${LIVEKIT_BIND_ADDR:-127.0.0.1}"
# Адрес публикации медиапортов LiveKit (TCP/UDP): по умолчанию совпадает с
# адресом привязки сигналинга. Разделять нужно, когда прокси стоит на том же
# хосте: сигналинг публикуется только для прокси, а медиа обязано быть
# доступно клиентам из интернета (AGENT.md 10.4, 10.13).
LIVEKIT_MEDIA_BIND_ADDR="${LIVEKIT_MEDIA_BIND_ADDR:-}"
TURN_CERT_REMOTE="${TURN_CERT_REMOTE:-}"
TURN_CERT_REMOTE_CRT="${TURN_CERT_REMOTE_CRT:-}"
TURN_CERT_REMOTE_KEY="${TURN_CERT_REMOTE_KEY:-}"
@@ -98,9 +110,15 @@ ENABLE_UFW="${ENABLE_UFW:-true}"
ENABLE_NFTABLES="${ENABLE_NFTABLES:-true}"
ENABLE_SSH_HARDENING="${ENABLE_SSH_HARDENING:-true}"
APP_IMAGE="${APP_IMAGE:-glchat:local}"
IMAGE_TAG="${IMAGE_TAG:-v0.1.0}"
CADDY_IMAGE_TAG="${CADDY_IMAGE_TAG:-2.10-alpine}"
LIVEKIT_IMAGE_TAG="${LIVEKIT_IMAGE_TAG:-v1.9.7}"
# Версия образа: файл VERSION в корне репозитория (тот же, что читает Makefile и
# держит desktop-обёртка), с запасным значением для установки без репозитория.
SCRIPT_VERSION="$(cat "${REPO_DIR}/VERSION" 2>/dev/null || true)"
SCRIPT_VERSION="${SCRIPT_VERSION:-0.1.0}"
IMAGE_TAG="${IMAGE_TAG:-v${SCRIPT_VERSION#v}}"
# Тег + digest: реестр может перезаписать тег, digest фиксирует содержимое
# (AGENT.md 11.3). Обновление — заменить digest после проверки образа.
CADDY_IMAGE_TAG="${CADDY_IMAGE_TAG:-2.10-alpine@sha256:4c6e91c6ed0e2fa03efd5b44747b625fec79bc9cd06ac5235a779726618e530d}"
LIVEKIT_IMAGE_TAG="${LIVEKIT_IMAGE_TAG:-v1.9.7@sha256:ee33f59dc8d2fe176db7f2ef74e1b3ec754f29b50b0ae519b3fa893f7b015d07}"
MAX_UPLOAD_MB="${MAX_UPLOAD_MB:-}"
LOG_LEVEL="${LOG_LEVEL:-info}"
SKIP_TLS="${SKIP_TLS:-0}"
@@ -111,14 +129,25 @@ GLCHAT_FORCE_OS="${GLCHAT_FORCE_OS:-0}"
GLCHAT_ASSUME_YES="${GLCHAT_ASSUME_YES:-0}"
TARGET_DIR="${GLCHAT_ETC_DIR:-/opt/glchat}"
if [ "$DRY_RUN" = "1" ] && [ -z "${GLCHAT_ETC_DIR:-}" ]; then
TARGET_DIR="$(mktemp -d /tmp/glchat-dry-run.XXXXXX)"
fi
GLCHAT_ETC_DIR="$TARGET_DIR"
GLCHAT_DATA_DIR="${TARGET_DIR}/data"
# Каталог обновлений desktop-обёртки: манифесты и артефакты раскладывает
# scripts/desktop-release.sh, приложение отдаёт их по /updates/… (docs/client-tauri.md §4).
GLCHAT_UPDATES_DIR="${GLCHAT_UPDATES_DIR:-${TARGET_DIR}/updates}"
GLCHAT_INSTALL_LOG="${TARGET_DIR}/install.log"
GLCHAT_ENV_FILE="${TARGET_DIR}/.env"
# app_check_url печатает адрес /readyz с учётом привязки приложения: во внешнем
# режиме и на стенде приложение слушает не localhost, а адрес туннеля, поэтому
# проверка 127.0.0.1 всегда падала бы (AGENT.md 10.13).
app_check_url() {
local port="$1" host="${2:-}"
case "$host" in
0.0.0.0 | "" | "::") host="127.0.0.1" ;;
esac
printf 'http://%s:%s/readyz' "$host" "$port"
}
usage() {
cat <<'EOF'
install.sh — установка glchat (AGENT.md 10).
@@ -150,7 +179,10 @@ install.sh — установка glchat (AGENT.md 10).
--turn-cert <файл> сертификат для TURN/TLS (нужен при включённом TURN)
--turn-key <файл> приватный ключ сертификата TURN/TLS
--turn-relay-host <host> внешний TURN-релей с публичным IP (например, VPS):
клиенты получают его в списке turn_servers
клиенты получают его в списке turn_servers.
На релее поднимите user-quota: одному участнику
нужно два allocation, а TURN-пользователь у
инстанса один (см. docs/DECISIONS.md)
--turn-relay-port <port> UDP-порт внешнего релея (по умолчанию 3478)
--turn-relay-tls-port <port> TLS-порт внешнего релея (по умолчанию 5349)
--turn-relay-tls-host <host> имя хоста для TURN/TLS (по умолчанию turn.<домен>):
@@ -162,7 +194,11 @@ install.sh — установка glchat (AGENT.md 10).
--allow-port <порт/прото> разрешить порт только из подсети --allow-from
(можно указывать несколько раз), например 8080/tcp
--allow-from <cidr> источник для --allow-port (по умолчанию 10.77.0.0/24)
--livekit-bind-addr <addr> адрес LiveKit (по умолчанию 127.0.0.1)
--livekit-bind-addr <addr> адрес публикации LiveKit WS (по умолчанию 127.0.0.1)
--livekit-media-bind-addr <addr> адрес публикации медиапортов LiveKit TCP/UDP
(по умолчанию — как у WS; отдельный адрес нужен,
когда прокси стоит на том же хосте: WS публикуется
только для прокси, медиа — для клиентов из интернета)
--turn-cert-remote <user@host> тянуть сертификат TURN/TLS с внешнего узла по SSH
--turn-cert-remote-crt <путь> путь к сертификату на внешнем узле
--turn-cert-remote-key <путь> путь к ключу на внешнем узле
@@ -227,6 +263,7 @@ parse_args() {
--allow-port) ALLOW_PORTS="${ALLOW_PORTS} ${2:?}"; shift 2 ;;
--allow-from) ALLOW_FROM="${2:?}"; shift 2 ;;
--livekit-bind-addr) LIVEKIT_BIND_ADDR="${2:?}"; shift 2 ;;
--livekit-media-bind-addr) LIVEKIT_MEDIA_BIND_ADDR="${2:?}"; shift 2 ;;
--turn-cert-remote) TURN_CERT_REMOTE="${2:?}"; shift 2 ;;
--turn-cert-remote-crt) TURN_CERT_REMOTE_CRT="${2:?}"; shift 2 ;;
--turn-cert-remote-key) TURN_CERT_REMOTE_KEY="${2:?}"; shift 2 ;;
@@ -256,6 +293,19 @@ parse_args() {
*) die "неизвестный аргумент: $1 (см. --help)" ;;
esac
done
# Dry-run обязан быть безвредным: если /opt/glchat недоступен для записи и
# путь не задан явно — пишем .env и конфиги во временный каталог.
if [ "$DRY_RUN" = "1" ] && [ "$GLCHAT_ETC_DIR_EXPLICIT" = "0" ] && [ ! -w "$TARGET_DIR" ]; then
local unwritable="$TARGET_DIR"
TARGET_DIR="$(mktemp -d /tmp/glchat-dry-run.XXXXXX)"
GLCHAT_ETC_DIR="$TARGET_DIR"
GLCHAT_DATA_DIR="${TARGET_DIR}/data"
GLCHAT_UPDATES_DIR="${TARGET_DIR}/updates"
GLCHAT_INSTALL_LOG="${TARGET_DIR}/install.log"
GLCHAT_ENV_FILE="${TARGET_DIR}/.env"
info "каталог ${unwritable} недоступен для записи: dry-run работает во временном ${TARGET_DIR}"
fi
}
interactive_defaults() {
@@ -369,6 +419,7 @@ resolve_derived_values() {
fi
GLCHAT_DATA_DIR="${GLCHAT_DATA_DIR}"
GLCHAT_UPDATES_DIR="${GLCHAT_UPDATES_DIR}"
GLCHAT_CADDY_PROFILE="${CADDY_PROFILE}"
GLCHAT_TURN_CERT_DIR="${TURN_CERT_DIR}"
# TLS считается включённым и когда сертификаты обслуживает внешний прокси
@@ -389,11 +440,49 @@ resolve_derived_values() {
fi
GLCHAT_APP_BIND="${APP_BIND_ADDR}"
GLCHAT_LIVEKIT_BIND="${LIVEKIT_BIND_ADDR}"
# Пустое значение — прежнее поведение: медиа публикуется там же, где сигналинг.
[ -n "$LIVEKIT_MEDIA_BIND_ADDR" ] || LIVEKIT_MEDIA_BIND_ADDR="$LIVEKIT_BIND_ADDR"
GLCHAT_LIVEKIT_MEDIA_BIND="${LIVEKIT_MEDIA_BIND_ADDR}"
# Порты TURN публикуются только при включённом TURN. С --no-turn встроенного
# TURN в стеке нет: публикация занимала бы порты хоста (3478/udp и диапазон
# релея) и ломала запуск, если их держит чужой сервис — например, отдельный
# coturn на том же VPS (найдено при установке на VPS 27.09.2026), а наружу
# открывались бы порты, за которыми ничего не слушает.
if [ "$TURN_ENABLED" = "true" ]; then
GLCHAT_TURN_PORTS=" # TURN: UDP, TURN/TLS и диапазон релея (медиа через relay).
- \"${LIVEKIT_MEDIA_BIND_ADDR}:${TURN_UDP_PORT}:${TURN_UDP_PORT}/udp\"
- \"${LIVEKIT_MEDIA_BIND_ADDR}:${TURN_TLS_PORT}:${TURN_TLS_PORT}/tcp\"
- \"${LIVEKIT_MEDIA_BIND_ADDR}:${TURN_RELAY_START}-${TURN_RELAY_END}:${TURN_RELAY_START}-${TURN_RELAY_END}/udp\""
else
GLCHAT_TURN_PORTS=" # TURN выключен (--no-turn): порты 3478/5349 и диапазон релея не публикуются"
fi
# Блок пользовательского контента (files.<домен>) в конфиг прокси попадает
# только когда для него есть отдельный домен. Иначе в Caddyfile оказывались
# два блока с одним и тем же адресом, и Caddy отказывался стартовать
# («ambiguous site definition») — найдено при установке с --no-files-subdomain
# на VPS 27.09.2026. Адрес апстрима зависит от режима: во встроенном прокси
# это имя контейнера, во внешнем — адрес привязки приложения.
if [ "$PROXY_MODE" = "embedded" ]; then
files_upstream="app:8080"
else
files_upstream="${APP_BIND_ADDR}:${APP_PORT}"
fi
if [ "$FILES_DOMAIN_RESOLVED" != "$DOMAIN" ]; then
GLCHAT_FILES_BLOCK="$(printf '# Пользовательский контент — отдельный origin (изоляция cookies/CSP).\n%s {\n\tencode zstd gzip\n\timport glchat_security_headers\n\thandle {\n\t\treverse_proxy %s\n\t}\n}' \
"${FILES_DOMAIN_RESOLVED}" "${files_upstream}")"
if [ "$PROXY_MODE" = "embedded" ]; then
# Во встроенном Caddy сниппет заголовков называется иначе.
GLCHAT_FILES_BLOCK="${GLCHAT_FILES_BLOCK/import glchat_security_headers/import security_headers}"
fi
else
GLCHAT_FILES_BLOCK="# Отдельного домена для пользовательского контента нет
# (--no-files-subdomain): файлы отдаются с основного домена по /files/*."
fi
export FILES_DOMAIN_RESOLVED CADDY_PROFILE LIVEKIT_PUBLISH TURN_EXTERNAL_TLS \
livekit_extra_codecs \
livekit_extra_codecs GLCHAT_TURN_PORTS GLCHAT_FILES_BLOCK \
PROFILE_LIVEKIT_DISABLE_SIMULCAST PROFILE_LIVEKIT_USE_EXTERNAL_IP \
GLCHAT_DATA_DIR GLCHAT_CADDY_PROFILE GLCHAT_TURN_CERT_DIR \
GLCHAT_APP_BIND GLCHAT_LIVEKIT_BIND TLS_ENABLED \
GLCHAT_DATA_DIR GLCHAT_UPDATES_DIR GLCHAT_CADDY_PROFILE GLCHAT_TURN_CERT_DIR \
GLCHAT_APP_BIND GLCHAT_LIVEKIT_BIND GLCHAT_LIVEKIT_MEDIA_BIND TLS_ENABLED \
TURN_DOMAIN_RESOLVED TURN_CERT_DIR TURN_RELAY_HOST_RESOLVED TURN_RELAY_TLS_HOST \
TURN_RELAY_USER TURN_RELAY_PASSWORD
}
@@ -480,6 +569,7 @@ print_plan() {
if [ "$PROXY_MODE" = "external" ]; then
printf ' %-18s %-10s %s\n' "app (API+web)" "${APP_BIND_ADDR}:${APP_PORT}" "внутренний, наружу не публикуется"
printf ' %-18s %-10s %s\n' "LiveKit WS" "${LIVEKIT_BIND_ADDR}:${LIVEKIT_WS_PORT}" "внутренний"
printf ' %-18s %-10s %s\n' "LiveKit медиа" "${LIVEKIT_MEDIA_BIND_ADDR}:${LIVEKIT_TCP_PORT}/${LIVEKIT_UDP_PORT}" "TCP+UDP для клиентов"
printf ' %-18s %-10s %s\n' "80/443" "-" "обслуживает внешний прокси (не меняем)"
else
printf ' %-18s %-10s %s\n' "HTTP" "${HTTP_PORT}/tcp" "ACME + редирект на HTTPS"
@@ -601,6 +691,24 @@ print("".join(charset[d] for d in out))
PYEOF
}
# generate_vapid_key создаёт приватный ключ VAPID (P-256, PKCS#8 DER в base64).
# Публичный ключ приложение выводит из приватного, поэтому в .env хранится
# только приватный. Без openssl ключ не сделать: функция честно возвращает
# ошибку, и Web Push остаётся выключенным.
generate_vapid_key() {
have openssl || return 1
local tmp key
tmp="$(mktemp -d)"
if ! openssl ecparam -name prime256v1 -genkey -noout -out "${tmp}/key.pem" 2>/dev/null; then
rm -rf "$tmp"
return 1
fi
key="$(openssl pkcs8 -topk8 -nocrypt -in "${tmp}/key.pem" -outform DER 2>/dev/null | base64 -w0)"
rm -rf "$tmp"
[ -n "$key" ] || return 1
printf '%s' "$key"
}
generate_secrets() {
info "секреты"
local env_existing=0
@@ -610,6 +718,20 @@ generate_secrets() {
# расшифровывает email/PII и TOTP-секреты, SESSION_PEPPER — blind index,
# LIVEKIT_API_SECRET подписывает токены. Их смена делает существующие
# данные нечитаемыми, поэтому она возможна только явным --rotate-secrets.
# existing_value отдаёт значение необязательной настройки: сначала из
# окружения (оператор задал при запуске), затем из существующего .env —
# чтобы `--reconfigure` её не потерял.
existing_value() {
local key="$1"
local value="${!key:-}"
if [ -z "$value" ] && [ "$env_existing" = "1" ]; then
value="$(env_get "$key" || true)"
fi
# Кавычки из .env снимаем: иначе при перезаписи файла они удваиваются
# (`KEY=""` → `KEY=""""`) и docker compose отказывается читать .env.
printf '%s' "${value//\"/}"
}
secret_or_new() {
local key="$1" generator="${2:-rand_hex}"
local current=""
@@ -643,6 +765,25 @@ generate_secrets() {
LIVEKIT_API_KEY="$(secret_or_new LIVEKIT_API_KEY rand_hex)"
LIVEKIT_API_SECRET="$(secret_or_new LIVEKIT_API_SECRET rand_hex)"
# VAPID-ключ Web Push (Фаза 7, AGENT.md 7.16): приватный ключ P-256 в
# PKCS#8 DER (base64 одной строкой), публичный приложение выводит само.
# Ключ переиспользуется при повторной установке: его смена отозвала бы все
# подписки браузеров, поэтому она возможна только с --rotate-secrets.
VAPID_PRIVATE_KEY="$(secret_or_new VAPID_PRIVATE_KEY generate_vapid_key || true)"
if [ -z "$VAPID_PRIVATE_KEY" ]; then
warn "VAPID-ключ не сгенерирован: Web Push будет выключен (нужен openssl)"
fi
# Контакт администратора для push-сервисов: значение можно задать заранее,
# иначе берём email администратора или адрес домена.
VAPID_SUBJECT="$(existing_value VAPID_SUBJECT)"
if [ -z "$VAPID_SUBJECT" ]; then
if [ -n "${ADMIN_EMAIL:-}" ]; then
VAPID_SUBJECT="mailto:${ADMIN_EMAIL}"
else
VAPID_SUBJECT="mailto:admin@${DOMAIN}"
fi
fi
if [ "$DRY_RUN" = "1" ]; then
log_file_only "DRY-RUN: секреты сгенерированы в памяти (в файл не пишутся)"
ok "секреты подготовлены (dry-run)"
@@ -651,6 +792,10 @@ generate_secrets() {
mkdir -p "${GLCHAT_DATA_DIR}"/{db,files,backups}
chmod 700 "$GLCHAT_DATA_DIR"
# Каталог обновлений монтируется в контейнер только для чтения: его
# наполняет релизный скрипт от root, приложению нужен лишь доступ на чтение.
mkdir -p "${GLCHAT_UPDATES_DIR}"
chmod 755 "${GLCHAT_UPDATES_DIR}"
# Каталоги монтируются в контейнер, который работает не от root (UID 10001):
# без смены владельца приложение не сможет создать базу (AGENT.md 4.3).
set_data_ownership
@@ -695,6 +840,16 @@ ensure_age_key() {
}
write_env_file() {
# env_quote оформляет значение для .env: простые значения пишем как есть,
# сложные — в кавычках (docker compose разбирает .env своим парсером).
env_quote() {
local value="$1"
case "$value" in
*[!A-Za-z0-9._~:/@+=-]*) printf '"%s"' "${value//\"/\\\"}" ;;
*) printf '%s' "$value" ;;
esac
}
info "пишем ${GLCHAT_ENV_FILE}"
if [ "$DRY_RUN" = "1" ]; then
log_file_only "DRY-RUN: .env не записывается"
@@ -730,10 +885,14 @@ TLS_BY_PROXY=@TLS_BY_PROXY@
# --- интерполяция compose (пути, адреса привязки, профиль) ---
GLCHAT_DATA_DIR=@GLCHAT_DATA_DIR@
GLCHAT_UPDATES_DIR=@GLCHAT_UPDATES_DIR@
GLCHAT_CADDY_PROFILE=@CADDY_PROFILE@
GLCHAT_TURN_CERT_DIR=@GLCHAT_TURN_CERT_DIR@
GLCHAT_APP_BIND=@GLCHAT_APP_BIND@
GLCHAT_LIVEKIT_BIND=@GLCHAT_LIVEKIT_BIND@
# Адрес публикации медиапортов LiveKit: обычно совпадает с адресом WS, но при
# прокси на том же хосте отличается (WS — только для прокси, медиа — в интернет).
GLCHAT_LIVEKIT_MEDIA_BIND=@GLCHAT_LIVEKIT_MEDIA_BIND@
# --- порты ---
APP_PORT=@APP_PORT@
@@ -768,7 +927,6 @@ LIVEKIT_API_KEY=@LIVEKIT_API_KEY@
LIVEKIT_API_SECRET=@LIVEKIT_API_SECRET@
TURN_RELAY_USER=@TURN_RELAY_USER@
TURN_RELAY_PASSWORD=@TURN_RELAY_PASSWORD@
AGE_RECIPIENT=@AGE_RECIPIENT@
# --- профиль производительности ---
SQLITE_READ_POOL=@SQLITE_READ_POOL@
@@ -793,6 +951,19 @@ SHARE_MAX_LIMIT=@SHARE_MAX@
BACKUP_KEEP_DAYS=@BACKUP_KEEP@
SWAP_SIZE_GB=@SWAP_SIZE@
# --- бэкапы: offsite-выгрузка (опция, AGENT.md 10.8) ---
# Цель: rsync (user@host:/path) или rclone:remote:path; пусто — только локально.
BACKUP_OFFSITE_TARGET=@BACKUP_OFFSITE_TARGET@
# Ротация на приёмнике (дни, только rclone); пусто — копим без ротации.
BACKUP_OFFSITE_KEEP_DAYS=@BACKUP_OFFSITE_KEEP_DAYS@
AGE_RECIPIENT=@AGE_RECIPIENT@
# --- Web Push (Фаза 7) ---
# Приватный VAPID-ключ (PKCS#8 DER в base64). Пусто — Web Push выключен.
VAPID_PRIVATE_KEY=@VAPID_PRIVATE_KEY@
# Контакт администратора для push-сервисов: mailto:… или https://…
VAPID_SUBJECT=@VAPID_SUBJECT@
# --- функции ---
REGISTRATION_ENABLED=@REGISTRATION_ENABLED@
ANTI_BOT_ENABLED=@ANTI_BOT_ENABLED@
@@ -814,8 +985,10 @@ ENVEOF
cat >"$pairs_file" <<PAIREOF
DOMAIN=${DOMAIN}
FILES_DOMAIN=${FILES_DOMAIN_RESOLVED}
INSTANCE_NAME=${INSTANCE_NAME}
MAIN_GUILD_NAME=${MAIN_GUILD_NAME}
# Значения с пробелами — в кавычках: .env подключается через `set -a && . .env`,
# иначе оболочка попытается выполнить имя главного сервера как команду.
INSTANCE_NAME="${INSTANCE_NAME}"
MAIN_GUILD_NAME="${MAIN_GUILD_NAME}"
PROFILE=${PROFILE_RESOLVED}
PROXY_MODE=${PROXY_MODE}
IMAGE_TAG=${IMAGE_TAG}
@@ -827,10 +1000,12 @@ TLS_ENABLED=${TLS_ENABLED}
SKIP_TLS=${SKIP_TLS}
TLS_BY_PROXY=${TLS_BY_PROXY}
GLCHAT_DATA_DIR=${GLCHAT_DATA_DIR}
GLCHAT_UPDATES_DIR=${GLCHAT_UPDATES_DIR}
CADDY_PROFILE=${CADDY_PROFILE}
GLCHAT_TURN_CERT_DIR=${TURN_CERT_DIR}
GLCHAT_APP_BIND=${APP_BIND_ADDR}
GLCHAT_LIVEKIT_BIND=${LIVEKIT_BIND_ADDR}
GLCHAT_LIVEKIT_MEDIA_BIND=${LIVEKIT_MEDIA_BIND_ADDR}
APP_PORT=${APP_PORT}
LIVEKIT_WS_PORT=${LIVEKIT_WS_PORT}
LIVEKIT_TCP_PORT=${LIVEKIT_TCP_PORT}
@@ -859,6 +1034,13 @@ LIVEKIT_API_KEY=${LIVEKIT_API_KEY}
LIVEKIT_API_SECRET=${LIVEKIT_API_SECRET}
LIVEKIT_PUBLIC_URL=${LIVEKIT_PUBLIC_URL}
# Web Push (Фаза 7): приватный VAPID-ключ переиспользуется при переустановке.
VAPID_PRIVATE_KEY=${VAPID_PRIVATE_KEY}
VAPID_SUBJECT=$(env_quote "$VAPID_SUBJECT")
# Offsite-бэкапы: цель и срок хранения задаются вручную после установки.
BACKUP_OFFSITE_TARGET=${BACKUP_OFFSITE_TARGET:-}
BACKUP_OFFSITE_KEEP_DAYS=${BACKUP_OFFSITE_KEEP_DAYS:-}
AGE_RECIPIENT=${AGE_RECIPIENT:-}
SQLITE_READ_POOL=${PROFILE_SQLITE_READ_POOL}
SQLITE_CACHE_SIZE=-${PROFILE_SQLITE_CACHE_MB}000
@@ -905,7 +1087,8 @@ for line in open(pairs_path, encoding="utf-8"):
for key, value in values.items():
template = template.replace("@" + key + "@", value)
leftovers = [t for t in __import__("re").findall(r"@[A-Z_]{2,}@", template)]
# В именах плейсхолдеров есть цифры (FAIL2BAN_ENABLED) — их тоже ловим.
leftovers = [t for t in __import__("re").findall(r"@[A-Z0-9_]{2,}@", template)]
open(template_path, "w", encoding="utf-8").write(template)
if leftovers:
print("незаполненные плейсхолдеры: " + ", ".join(sorted(set(leftovers))), file=sys.stderr)
@@ -1080,7 +1263,8 @@ render_all_templates() {
"PROFILE=${PROFILE_RESOLVED}" "APP_IMAGE=${APP_IMAGE}" \
"APP_PORT=${APP_PORT}" "LIVEKIT_WS_PORT=${LIVEKIT_WS_PORT}" \
"LIVEKIT_TCP_PORT=${LIVEKIT_TCP_PORT}" "LIVEKIT_UDP_PORT=${LIVEKIT_UDP_PORT}" \
"GLCHAT_DATA_DIR=${GLCHAT_DATA_DIR}" "LOG_LEVEL=${LOG_LEVEL}" \
"GLCHAT_DATA_DIR=${GLCHAT_DATA_DIR}" "GLCHAT_UPDATES_DIR=${GLCHAT_UPDATES_DIR}" \
"LOG_LEVEL=${LOG_LEVEL}" \
"SQLITE_READ_POOL=${PROFILE_SQLITE_READ_POOL}" \
"SQLITE_CACHE_SIZE=-${PROFILE_SQLITE_CACHE_MB}000" \
"SQLITE_MMAP_SIZE=$((PROFILE_SQLITE_MMAP_MB * 1024 * 1024))" \
@@ -1095,6 +1279,8 @@ render_all_templates() {
"GLCHAT_CADDY_PROFILE=${CADDY_PROFILE}" \
"GLCHAT_TURN_CERT_DIR=${TURN_CERT_DIR}" \
"GLCHAT_APP_BIND=${APP_BIND_ADDR}" "GLCHAT_LIVEKIT_BIND=${LIVEKIT_BIND_ADDR}" \
"GLCHAT_LIVEKIT_MEDIA_BIND=${LIVEKIT_MEDIA_BIND_ADDR}" \
"GLCHAT_TURN_PORTS=${GLCHAT_TURN_PORTS}" \
"TURN_UDP_PORT=${TURN_UDP_PORT}" "TURN_TLS_PORT=${TURN_TLS_PORT}" \
"TURN_RELAY_START=${TURN_RELAY_START}" "TURN_RELAY_END=${TURN_RELAY_END}" \
"LIVEKIT_API_KEY=${LIVEKIT_API_KEY}" "LIVEKIT_API_SECRET=${LIVEKIT_API_SECRET}" \
@@ -1103,11 +1289,13 @@ render_all_templates() {
if [ "$PROXY_MODE" = "external" ]; then
render_template "${DEPLOY_DIR}/caddy/external-proxy.conf.tpl" "${TARGET_DIR}/external-proxy.conf" \
"DOMAIN=${DOMAIN}" "FILES_DOMAIN=${FILES_DOMAIN_RESOLVED}" "ACME_EMAIL=${ACME_EMAIL}" \
"APP_PORT=${APP_PORT}" "LIVEKIT_WS_PORT=${LIVEKIT_WS_PORT}"
"APP_PORT=${APP_PORT}" "LIVEKIT_WS_PORT=${LIVEKIT_WS_PORT}" \
"APP_BIND=${APP_BIND_ADDR}" "LIVEKIT_BIND=${LIVEKIT_BIND_ADDR}" \
"GLCHAT_FILES_BLOCK=${GLCHAT_FILES_BLOCK}"
else
render_template "${DEPLOY_DIR}/caddy/Caddyfile.tpl" "${TARGET_DIR}/caddy/Caddyfile" \
"DOMAIN=${DOMAIN}" "FILES_DOMAIN=${FILES_DOMAIN_RESOLVED}" "ACME_EMAIL=${ACME_EMAIL}" \
"PROXY_MODE=${PROXY_MODE}"
"PROXY_MODE=${PROXY_MODE}" "GLCHAT_FILES_BLOCK=${GLCHAT_FILES_BLOCK}"
fi
render_template "${DEPLOY_DIR}/livekit/livekit.yaml.tpl" "${TARGET_DIR}/livekit/livekit.yaml" \
@@ -1559,22 +1747,23 @@ start_stack() {
restart_livekit_if_config_changed
info "ожидаем готовности приложения"
local waited=0
until curl -fsS "http://127.0.0.1:${APP_PORT}/readyz" >/dev/null 2>&1; do
local ready_url waited=0
ready_url="$(app_check_url "$APP_PORT" "$APP_BIND_ADDR")"
until curl -fsS "$ready_url" >/dev/null 2>&1; do
waited=$((waited + 2))
if [ "$waited" -ge 60 ]; then
warn "приложение не ответило за 60 с — смотрите: glchat logs app"
warn "приложение не ответило за 60 с ($ready_url) — смотрите: glchat logs app"
break
fi
sleep 2
done
curl -fsS "http://127.0.0.1:${APP_PORT}/readyz" >/dev/null 2>&1 && ok "приложение готово" || warn "готовность не подтверждена"
curl -fsS "$ready_url" >/dev/null 2>&1 && ok "приложение готово ($ready_url)" || warn "готовность не подтверждена ($ready_url)"
}
bootstrap_admin() {
info "bootstrap инстанса (админ, главный сервер, роли)"
if [ -z "$ADMIN_EMAIL" ]; then
warn "админ не задан (--admin): bootstrap будет выполнен вручную в фазе 1"
warn "админ не задан (--admin): bootstrap пропущен — создайте администратора командой glchat bootstrap-admin --email <email>"
return 0
fi
if [ "$DRY_RUN" = "1" ]; then
@@ -1586,7 +1775,7 @@ bootstrap_admin() {
--instance-name "$INSTANCE_NAME" --main-guild "$MAIN_GUILD_NAME" >/dev/null 2>&1; then
ok "инстанс-админ создан"
else
warn "bootstrap-admin недоступен в этой версии образа (появится в фазе 1); данные сохранены в .env"
warn "bootstrap-admin не выполнился: посмотрите 'docker logs glchat-app' и повторите вручную — glchat bootstrap-admin --email <email>; данные сохранены в .env"
fi
# 2FA обязательна для инстанс-админов (AGENT.md 7.19): секрет и резервные
# коды печатает команда totp-setup. По умолчанию не выполняем её, чтобы
+18 -1
View File
@@ -8,6 +8,23 @@ ENV_FILE="${GLCHAT_ETC_DIR}/.env"
COMPOSE_FILE="${GLCHAT_ETC_DIR}/compose.yaml"
log() { printf '[restore] %s\n' "$*"; }
# env_get читает значение из .env инстанса (пусто, если ключа нет).
env_get() {
[ -f "$ENV_FILE" ] || return 0
sed -n "s/^$1=//p" "$ENV_FILE" | tail -1
}
# app_check_url печатает адрес /readyz с учётом привязки приложения: во внешнем
# режиме приложение слушает WireGuard-адрес, и проверка localhost не работает.
app_check_url() {
local port="$1" host="${2:-}"
case "$host" in
0.0.0.0 | "" | "::") host="127.0.0.1" ;;
esac
printf 'http://%s:%s/readyz' "$host" "$port"
}
die() {
printf '[restore] ошибка: %s\n' "$*" >&2
exit 1
@@ -79,7 +96,7 @@ log "запускаем приложение"
(cd "$GLCHAT_ETC_DIR" && docker compose start app) || (cd "$GLCHAT_ETC_DIR" && docker compose up -d app)
for _ in $(seq 1 30); do
if curl -fsS "http://127.0.0.1:${APP_PORT:-8080}/readyz" >/dev/null 2>&1; then
if curl -fsS "$(app_check_url "${APP_PORT:-8080}" "$(env_get GLCHAT_APP_BIND)")" >/dev/null 2>&1; then
log "приложение готово"
GLCHAT_ETC_DIR="$GLCHAT_ETC_DIR" bash "${GLCHAT_ETC_DIR}/smoke.sh" || true
log "восстановление завершено"
+23 -6
View File
@@ -7,6 +7,16 @@ ENV_FILE="${GLCHAT_ETC_DIR}/.env"
COMPOSE_FILE="${GLCHAT_ETC_DIR}/compose.yaml"
REPO_DIR_DEFAULT="${GLCHAT_REPO_DIR:-/opt/glchat-src}"
# app_check_url печатает адрес /readyz с учётом привязки приложения: во внешнем
# режиме оно слушает WireGuard-адрес, и проверка localhost всегда падала бы.
app_check_url() {
local port="$1" host="${2:-}"
case "$host" in
0.0.0.0 | "" | "::") host="127.0.0.1" ;;
esac
printf 'http://%s:%s/readyz' "$host" "$port"
}
log() { printf '[update] %s\n' "$*"; }
die() {
printf '[update] ошибка: %s\n' "$*" >&2
@@ -49,11 +59,16 @@ if [ "$APP_IMAGE" = "glchat:local" ] || [ -z "${APP_IMAGE:-}" ]; then
REPO_DIR="${GLCHAT_REPO_DIR:-$REPO_DIR_DEFAULT}"
if [ -d "${REPO_DIR}/.git" ]; then
log "обновляем исходники в ${REPO_DIR}"
git -C "$REPO_DIR" fetch --tags --depth 1 origin
# Без --depth: частичный fetch переводит обычный клон в shallow, и
# следующий `pull --ff-only` падает с «Not possible to fast-forward».
git -C "$REPO_DIR" fetch --tags origin ||
die "не удалось получить обновления; проверьте доступ к ${REPO_DIR}"
if [ -n "$TARGET_VERSION" ]; then
git -C "$REPO_DIR" checkout "$TARGET_VERSION"
git -C "$REPO_DIR" checkout "$TARGET_VERSION" ||
die "не удалось переключиться на ${TARGET_VERSION}"
else
git -C "$REPO_DIR" pull --ff-only
git -C "$REPO_DIR" pull --ff-only ||
die "локальные изменения мешают обновлению: git -C ${REPO_DIR} reset --hard origin/main"
fi
log "пересобираем образ"
docker build -t "${APP_IMAGE:-glchat:local}" \
@@ -72,16 +87,18 @@ fi
log "применяем новую версию"
(cd "$GLCHAT_ETC_DIR" && docker compose "${compose_args[@]}" up -d --remove-orphans) || die "не удалось поднять стек"
log "ожидаем готовности (миграции выполняются на старте приложения)"
READY_URL="$(app_check_url "${APP_PORT:-8080}" "${GLCHAT_APP_BIND:-}")"
log "ожидаем готовности ${READY_URL} (миграции выполняются на старте приложения)"
for _ in $(seq 1 45); do
if curl -fsS "http://127.0.0.1:${APP_PORT:-8080}/readyz" >/dev/null 2>&1; then
if curl -fsS "$READY_URL" >/dev/null 2>&1; then
log "приложение готово"
break
fi
sleep 2
done
if ! curl -fsS "http://127.0.0.1:${APP_PORT:-8080}/readyz" >/dev/null 2>&1; then
if ! curl -fsS "$READY_URL" >/dev/null 2>&1; then
die "приложение не поднялось; откат: glchat restore <бэкап>, затем docker compose up -d"
fi
+109
View File
@@ -0,0 +1,109 @@
#!/usr/bin/env bash
# glchat: проверка бэкапа без восстановления (AGENT.md 10.8).
#
# Расшифровывает архив, распаковывает во временный каталог, проверяет целостность
# базы (PRAGMA integrity_check), схему и состав данных, сверяет манифест.
# Живые данные не трогает: можно запускать на рабочем инстансе.
set -euo pipefail
GLCHAT_ETC_DIR="${GLCHAT_ETC_DIR:-/opt/glchat}"
GLCHAT_DATA_DIR="${GLCHAT_DATA_DIR:-${GLCHAT_ETC_DIR}/data}"
ENV_FILE="${GLCHAT_ETC_DIR}/.env"
log() { printf '[verify] %s\n' "$*"; }
die() {
printf '[verify] ошибка: %s\n' "$*" >&2
exit 1
}
ARCHIVE="${1:-}"
[ -n "$ARCHIVE" ] || die "использование: glchat verify-backup <файл.tar.gz[.age]>"
[ -f "$ARCHIVE" ] || die "файл не найден: $ARCHIVE"
if [ -f "$ENV_FILE" ]; then
# shellcheck disable=SC1090
set -a && . "$ENV_FILE" && set +a
fi
WORK_DIR="$(mktemp -d)"
cleanup() { rm -rf "$WORK_DIR"; }
trap cleanup EXIT
# 1. Расшифровка: age-архив требует приватный ключ.
case "$ARCHIVE" in
*.age)
command -v age >/dev/null 2>&1 || die "age не установлен"
KEY_FILE="${AGE_KEY_FILE:-${GLCHAT_DATA_DIR}/backups/age-key.txt}"
[ -f "$KEY_FILE" ] || die "не найден приватный age-ключ (${KEY_FILE}); укажите AGE_KEY_FILE"
log "расшифровываем $(basename "$ARCHIVE")"
age -d -i "$KEY_FILE" -o "${WORK_DIR}/backup.tar.gz" "$ARCHIVE" ||
die "не удалось расшифровать бэкап (проверьте ключ)"
;;
*) cp "$ARCHIVE" "${WORK_DIR}/backup.tar.gz" ;;
esac
# 2. Распаковка и обязательные файлы.
log "распаковываем"
tar -xzf "${WORK_DIR}/backup.tar.gz" -C "$WORK_DIR" || die "архив повреждён"
[ -f "${WORK_DIR}/glchat.db" ] || die "в архиве нет glchat.db — это не бэкап glchat"
stats() { printf '%s' "$1"; }
DB="${WORK_DIR}/glchat.db"
# 3. Целостность базы: sqlite3 есть на хосте или в контейнере приложения.
sqlite_query() {
local query="$1"
if command -v sqlite3 >/dev/null 2>&1; then
sqlite3 -readonly "$DB" "$query"
return
fi
if docker ps --format '{{.Names}}' | grep -q '^glchat-app$'; then
docker cp "$DB" glchat-app:/tmp/verify-backup.db >/dev/null
docker exec glchat-app sh -c "sqlite3 -readonly /tmp/verify-backup.db \"$query\""
docker exec glchat-app rm -f /tmp/verify-backup.db >/dev/null 2>&1 || true
return
fi
die "нет sqlite3 ни на хосте, ни в контейнере glchat-app"
}
log "проверяем целостность базы"
integrity="$(sqlite_query 'PRAGMA integrity_check;')"
[ "$integrity" = "ok" ] || die "integrity_check вернул: ${integrity}"
log "integrity_check: ok"
foreign_keys="$(sqlite_query 'PRAGMA foreign_key_check;' || true)"
[ -z "$foreign_keys" ] || die "нарушены внешние ключи: ${foreign_keys}"
log "foreign_key_check: чисто"
schema_version="$(sqlite_query 'SELECT MAX(version_id) FROM goose_db_version;' 2>/dev/null || echo '?')"
users="$(sqlite_query 'SELECT COUNT(*) FROM users WHERE deleted_at IS NULL;')"
guilds="$(sqlite_query 'SELECT COUNT(*) FROM guilds;')"
messages="$(sqlite_query 'SELECT COUNT(*) FROM messages;')"
files="$(sqlite_query 'SELECT COUNT(*) FROM files;')"
# 4. Манифест: подсказывает, с какого инстанса снят бэкап.
manifest="нет"
if [ -f "${WORK_DIR}/MANIFEST.txt" ]; then
manifest="$(tr '\n' ' ' <"${WORK_DIR}/MANIFEST.txt")"
fi
files_on_disk=0
if [ -d "${WORK_DIR}/files" ]; then
files_on_disk="$(find "${WORK_DIR}/files" -type f | wc -l | tr -d ' ')"
fi
printf '\n=== проверка бэкапа ===\n'
printf ' архив: %s\n' "$(basename "$ARCHIVE")"
printf ' схема: %s\n' "$schema_version"
printf ' пользователи: %s\n' "$users"
printf ' серверы: %s\n' "$guilds"
printf ' сообщения: %s\n' "$messages"
printf ' файлов в БД: %s (на диске: %s)\n' "$files" "$files_on_disk"
printf ' манифест: %s\n' "$manifest"
# 5. Файлы: если в базе есть записи о файлах, каталог не должен быть пустым.
if [ "${files:-0}" -gt 0 ] && [ "${files_on_disk:-0}" -eq 0 ]; then
die "в базе ${files} файлов, а в архиве нет каталога files — бэкап неполный"
fi
log "бэкап пригоден к восстановлению"
+491
View File
@@ -0,0 +1,491 @@
# desktop — desktop-обёртка glchat (Tauri 2)
Тот же веб-клиент из `web/`, обёрнутый в Tauri 2 (docs/client-tauri.md). Отдельного
UI-кода нет: страница инстанса загружается в системный webview, а обёртка даёт
платформенную интеграцию — трей, нативные уведомления, глобальный push-to-talk,
автозапуск, deep links и автообновление.
```
desktop/
└── src-tauri/ # Rust-проект обёртки
├── src/ # окно, трей, шорткаты, уведомления, deep links, обновления
├── capabilities/ # allowlist IPC: локальная страница настроек и домен инстанса
├── ui/ # локальная страница настроек обёртки (tauri://localhost)
├── icons/ # иконки приложения (сгенерированы из web/public/icons)
└── tauri.conf.json # конфигурация сборки и плагинов
```
## Сборка и запуск
```bash
make desktop-check # cargo fmt --check, clippy, тесты (кэш в .cache/)
make desktop-build # бандлы текущей ОС (macOS: .app и .dmg)
make desktop-build-app # только .app + артефакты обновления, без .dmg
open desktop/src-tauri/target/release/bundle/macos/glchat.app
```
Требуется Rust 1.77+ и Node 22 (Tauri CLI ставится через `npx`). Кэши `cargo` и
`npm` держатся в `.cache/` — как у остальных инструментов проекта.
`make desktop-build-app` (`tauri build --bundles app`) нужен там, где `.dmg`
собрать нельзя: `bundle_dmg.sh` монтирует образ через `hdiutil` и управляет
Finder через AppleScript, поэтому из песочницы агента и из headless-окружения он
падает (см. «Что не сделано»).
`open --args` из песочницы агента аргументы не доставляет (проверено на
macOS 27: и на бандле glchat, и на системном TextEdit — процесс запускается без
них; из обычного терминала то же самое работает). Поэтому `make desktop-run
ARGS='--minimized'` и замер свёрнутого окна в `scripts/desktop-perf.py` надо
запускать из обычного терминала, а не из песочницы.
Сборка релиза с артефактами автообновления использует ключ подписи:
```bash
# ключ и пароль не коммитятся: приватный ключ живёт в CI-секретах,
# локально — в build/desktop-keys/ (каталог build/ в .gitignore)
npx @tauri-apps/cli@^2 signer generate -w build/desktop-keys/glchat-updater.key -p '…'
make desktop-build
```
Публичный ключ уже вписан в `src-tauri/tauri.conf.json` и в `UPDATER_PUBKEY`
(`src/updates.rs`). Без локального ключа `make desktop-build` собирает бандл без
артефактов обновления, чтобы сборка не падала у тех, у кого ключа нет.
## Аргументы командной строки
| Аргумент | Что делает |
|---|---|
| `--instance <url>` | адрес инстанса вместо сохранённого (`--instance=https://…` тоже работает) |
| `--minimized` | запуск свёрнутым в трей (используется автозапуском) |
| `--settings` | сразу открыть страницу настроек обёртки |
| `--autostart on\|off` | включить или выключить автозапуск при установке скриптом |
| `--update-now` | сразу проверить обновление и поставить его без диалога (тихое обновление) |
## Настройки
Хранятся в `settings.json` каталога конфигурации приложения
(`~/Library/Application Support/su.mhspx.glchat/` на macOS):
| Ключ | Смысл |
|---|---|
| `instanceUrl` | адрес клиента, по умолчанию `https://gl.mhspx.su` |
| `closeToTray` | закрытие окна сворачивает приложение в трей |
| `autostart` | запуск при входе в систему (`--minimized`) |
| `notifications` | показывать системные уведомления |
| `pttShortcut`, `muteShortcut`, `deafenShortcut` | глобальные комбинации (пусто — выключено) |
Меняются из меню трея и со страницы настроек обёртки (трей → «Настройки обёртки…»).
Сессия и токены живут только в cookie webview: обёртка их не хранит.
## Deep links
Схема `glchat://` (регистрируется бандлом):
- `glchat://invite/<code>` → страница приглашения;
- `glchat://guild/<guildId>/channel/<channelId>` → сервер и комната.
Ссылка, пришедшая до загрузки клиента (холодный старт), ждёт в очереди и
доставляется после `desktop_ready`.
## Автообновление
Обёртка проверяет обновления сама: фоновая проверка через 30 секунд после старта
и вручную — трей → «Проверить обновления…». Адрес манифеста —
`GET {instance}/updates/{target}/{arch}/{current_version}` (target:
`darwin`/`windows`/`linux`, arch: `aarch64`/`x86_64`; см. `src/updates.rs`).
Найденное обновление показывается в интерфейсе, установка спрашивается
отдельным диалогом («во время звонка перезапуск без согласия недопустим»).
Для управляемых машин есть тихий режим — флаг `--update-now`:
```bash
open -a /Applications/glchat.app --args --update-now # из обычного терминала
```
С этим флагом обёртка проверяет обновление сразу при старте (не через 30 секунд)
и ставит найденное без вопросов, после чего перезапускается; диалогов нет вовсе,
а если обновления нет — приложение просто работает дальше. По умолчанию флаг
выключен, поведение обычного запуска не меняется. Подпись артефакта проверяется
в обоих режимах: флаг убирает только вопрос пользователю, но не проверку
(docs/DECISIONS.md, D-077).
В журнале (`~/Library/Logs/su.mhspx.glchat/glchat.log`) видны и версия запущенной
сборки, и ход обновления:
```
обёртка запущена: версия=0.1.0, адрес=https://gl.mhspx.su, окно=видимо, автозапуск=true
тихое обновление по флагу --update-now: проверка сразу при старте
доступно обновление 0.1.1
тихое обновление по флагу: 0.1.0 → 0.1.1
обновление 0.1.1 установлено, перезапуск
обёртка запущена: версия=0.1.1, …
```
Манифест отдаёт сам инстанс (`internal/server/updates.go`), отдельная статика в
Caddy не нужна: и встроенный Caddy профиля, и внешний прокси стенда проксируют
весь домен на приложение, поэтому `/updates/...` доходит до него без правок
чужих конфигов.
Раскладка каталога (`UPDATES_DIR`; на стенде `/opt/glchat/updates`, в контейнере
смонтирован как `/app/updates:ro`):
```
/opt/glchat/updates/
└── darwin/aarch64/
├── glchat.app.tar.gz # артефакт релиза (создаёт tauri build)
├── glchat.app.tar.gz.sig # подпись ed25519 (формат minisign)
├── 0.1.1.json # манифест текущего релиза
└── latest.json # манифест последнего релиза — его и отдаёт инстанс
```
Манифестов прошлых релизов в каталоге быть не должно: обёртка спрашивает
`/updates/{target}/{arch}/{своя версия}`, и оставшийся `<старая версия>.json`
перекрыл бы новый релиз — клиент со старой версией получил бы «обновлений нет».
Публикующий скрипт сам убирает такие файлы (см. ниже).
## Выпуск релиза
Версия живёт в одном месте — файл `VERSION` в корне репозитория; из него её
берут `Makefile` (версия сервера и образов), `deploy/install.sh` (`IMAGE_TAG`)
и desktop-обёртка (`desktop/src-tauri/tauri.conf.json` + `Cargo.toml` — их
значения должны совпадать с `VERSION`).
1. Поднять версию:
```bash
echo 0.1.2 > VERSION # версия сервера и обёртки
# те же значения — в desktop/src-tauri/tauri.conf.json, Cargo.toml, Cargo.lock
make web-build && make build # сервер собирается с новой версией
```
2. Проверки и сборка бандла:
```bash
make go-lint && go test -tags sqlite_fts5 -race -count=1 ./internal/...
cd web && npm run check && npm run lint && npm test && cd ..
make desktop-check # fmt, clippy, cargo test
make desktop-build # .app + .dmg + артефакты обновления
```
3. Зафиксировать и пометить релиз:
```bash
git add VERSION Makefile deploy/install.sh desktop/src-tauri scripts/desktop-release.sh
git commit -m "release: 0.1.2"
git tag -a v0.1.2 -m "glchat 0.1.2" && git push origin main --tags
```
4. Опубликовать обновление на инстансе:
```bash
sudo make desktop-release # локальный инстанс
make desktop-release-upload HOST=user@server # по SSH (sudo спросит пароль)
make desktop-release-upload HOST=user@server \
GLCHAT_SSH_OPTS="-i ~/.ssh/key -o IdentitiesOnly=yes" # свой ключ, без TTY (CI)
make desktop-release ARGS='--dry-run' # посмотреть план
```
5. Проверить канал и живьём обновиться со старой версии — см. следующий раздел.
Скрипт `scripts/desktop-release.sh` берёт версию из `tauri.conf.json`, сам
определяет target/arch по имени артефакта, копирует артефакт и `.sig`, собирает
манифест (`version`, `notes`, `pub_date`, `platforms.<target>-<arch>.signature/url`),
кладёт его и как `<version>.json`, и как `latest.json`, а манифесты прошлых
релизов убирает. Несколько платформ одной версии (macOS + Windows + Linux)
складываются в один манифест: запускайте скрипт по разу на артефакт. Каталог
обновлений на инстансе обычно принадлежит root — тогда rsync идёт через sudo;
если каталог записываем (стенд), sudo не нужен и загрузка работает без
терминала.
`latest.json` нужен потому, что обёртка приходит со **своей установленной**
версией: «новее ли релиз» решает клиент сравнением semver
(`tauri-plugin-updater`), поэтому на запрос любой версии сервер отдаёт
последний манифест, а если есть манифест конкретной версии — его. Без этого
обновление не получил бы никто, кроме тех, у кого уже стоит последняя версия.
Владельцу прокси добавлять ничего не нужно: `/updates/...` уходит в
catch-all `handle` и доходит до приложения (так же, как статика клиента).
Вариант «раздавать обновления статикой мимо приложения» требует правила
`rewrite` на `latest.json` с подстановкой `target`/`arch` в Caddy — лишняя
работа без выгоды, поэтому не рекомендуется.
Проверка (стенд):
```bash
curl -s https://gl.mhspx.su/updates/darwin/aarch64/0.1.0 | jq . # манифест для старой версии
curl -sI https://gl.mhspx.su/updates/darwin/aarch64/glchat.app.tar.gz | head -3 # 200 + размер
# в логе обёртки (~/Library/Logs/su.mhspx.glchat/glchat.log) —
# «обновлений нет» (манифест разобран) вместо
# «проверка обновлений не удалась: … 404»
```
Порядок обновления: проверка → уведомление в интерфейсе → скачивание →
подтверждение в диалоге (во время звонка перезапуск без согласия не делается) →
подпись проверяется до установки, downgrade отклоняется.
### Приёмка релиза на живом инстансе (26.09.2026, 0.1.0 → 0.1.1)
Проверено на боевом канале стенда, без локальных заглушек:
- манифест опубликован `scripts/desktop-release.sh --remote`, артефакт
`glchat.app.tar.gz` (3 204 953 Б) и подпись лежат в
`/opt/glchat/updates/darwin/aarch64/`;
- обёртка 0.1.0, запущенная с `--update-now`, в журнале (UTC) показала:
`доступно обновление 0.1.1` → `тихое обновление по флагу: 0.1.0 → 0.1.1` →
`обновление 0.1.1 установлено, перезапуск` → `обёртка запущена: версия=0.1.1` →
`обновлений нет` (17:08:23–17:08:25Z);
- запрос старой версии `GET /updates/darwin/aarch64/0.1.0` отдаёт манифест
**0.1.1** — это и есть та точка, где раньше мешал оставшийся `0.1.0.json`;
- `/Applications/glchat.app` после установки — 0.1.1, ярлык на рабочем столе
(`~/Desktop/glchat.app`) указывает на него.
**Важно про место установки.** `tauri-plugin-updater` распаковывает обновление
во временный каталог и переносит бандл `rename(2)`. Если приложение лежит на
другом томе, чем временный каталог (например, на внешнем диске
`/Volumes/…`, а `TMPDIR` — на загрузочном), установка падает с
`установка обновления не удалась: Cross-device link (os error 18)`, хотя
скачивание и проверка подписи проходят. Поэтому приложение для автообновления
надо держать на загрузочном томе — так делает `.dmg` (ставит в `/Applications`);
ярлык на рабочем столе лучше вести на `/Applications/glchat.app`, а не на
сборку в репозитории.
### Локальная проверка «поверх старой версии» (без стенда)
Установка обновления проверена локально на настоящем сервере инстанса: сборка
0.1.0 → манифест 0.1.1 → скачивание → проверка подписи → установка → перезапуск
на 0.1.1. Порядок действий (все данные — в каталоге репозитория, стенд не
затрагивается):
```bash
# 1. артефакт текущей версии и приложение 0.1.0 (с ключом подписи)
make desktop-build-app
# 2. версия 0.1.1 в desktop/src-tauri/tauri.conf.json и Cargo.toml, затем:
make desktop-build-app
# 3. раскладка манифеста в свой каталог, ссылки — на локальный сервер
bash scripts/desktop-release.sh --artifact \
desktop/src-tauri/target/release/bundle/macos/glchat.app.tar.gz \
--version 0.1.1 --base-url http://127.0.0.1:8100 --dir /tmp/updates
# 4. настоящий сервер инстанса со своим каталогом обновлений и клиентом
LISTEN_ADDR=127.0.0.1:8099 DATA_DIR=/tmp/glchat-e2e/data WEB_ROOT=$PWD/web/dist \
UPDATES_DIR=/tmp/updates DOMAIN=127.0.0.1:8099 TLS_ENABLED=false \
APP_VERSION=0.1.0-local SESSION_PEPPER=… MASTER_KEY=… TOTP_ENCRYPTION_KEY=… \
./build/glchat
# 5. запуск 0.1.0 с тихим обновлением (адрес инстанса — локальный)
/Applications/glchat.app/Contents/MacOS/glchat-desktop \
--instance http://127.0.0.1:8099 --update-now
```
Что нужно знать, повторяя это:
- **локальный стенд — http, а `tauri-plugin-updater` по умолчанию требует
`https`** («The configured updater endpoint must use a secure protocol»), для
проверки сборку делают с временным флагом
`tauri build --config '{"plugins":{"updater":{"dangerousInsecureTransportProtocol":true}}}'`.
В релизной сборке этого флага быть не должно: боевой адрес — `https://`;
- **приложение должно лежать на пути без символических ссылок**: macOS-проверка
`tauri-utils` отвергает `current_exe()` с symlink-компонентом, поэтому
`/tmp/glchat-e2e/…` не годится (`/tmp` — ссылка на `/private/tmp`), а
`/private/tmp/glchat-e2e/…` годится. Тот же смысл у требования ставить
приложение в `/Applications`, а не запускать через ссылку;
- **`--update-now` обязателен**: без него установку спрашивают диалогом, а
кликнуть в headless-прогоне некому;
- диагностика в логе: `GET /updates/darwin/aarch64/0.1.0` в журнале сервера,
затем `доступно обновление 0.1.1` → `тихое обновление по флагу: 0.1.0 → 0.1.1`
→ `обновление 0.1.1 установлено, перезапуск` → строка запуска с
`версия=0.1.1` и запрос манифеста уже для `0.1.1`;
- проверка подписи проверяется и «от обратного»: если испортить байт в
артефакте, установка обязана упасть с `The signature verification failed`,
а версия на диске — остаться прежней.
## Уведомления и переход в канал
Решение «показывать ли уведомление» принимает веб-клиент (упоминания и личные
сообщения, окно неактивно или комната не открыта), обёртка показывает системное
уведомление и обновляет счётчик.
Клик по уведомлению: у `tauri-plugin-notification` на desktop нет колбэка
действия, поэтому реализовано лучшее доступное поведение — уведомление,
показанное при неактивном окне, запоминается, и когда приложение
активируется в течение 10 секунд (окно получило фокус или macOS прислала
`Reopen`), окно поднимается и открывается комната из уведомления
(`glchat://guild/<id>/channel/<id>`, для личной беседы `glchat://dm/<id>`).
Ограничение (честно): ОС не сообщает, что активация вызвана именно кликом по
уведомлению, поэтому переход сработает и при обычном возврате в приложение в
течение этих 10 секунд. Клик по уведомлению старше 10 секунд только поднимает
окно. Полноценный колбэк требует своего моста к `UNUserNotificationCenter`
(`UNUserNotificationCenterDelegate`) — отдельная задача.
## Ресурсы и холодный старт
Замеры собраны скриптом `scripts/desktop-perf.py` (macOS arm64, Mac на M-серии,
инстанс `gl.mhspx.su` через внешний прокси; подробности — `docs/PERF.md`):
| Метрика | Значение | Бюджет (docs/client-tauri.md §1) |
|---|---|---|
| Холодный старт (`open` → процесс → строка «загружена страница») | 773–898 мс (три прогона) | ≤ 2 с |
| Из них: запуск процесса | 74–78 мс | — |
| Из них: загрузка страницы клиента | 696–821 мс | — |
| RAM, клиент открыт и простаивает (RSS: обёртка + 4 процесса WebKit) | 254 МБ (117 + 137) | ≤ 350 МБ |
| RAM, окно свёрнуто в трей (`--minimized`) | 256 МБ | ≤ 350 МБ |
| Физический след (Activity Monitor) | 106 МБ | — |
Наблюдения:
- свёрнутое в трей окно памяти не освобождает: webview с загруженным клиентом
продолжает жить (так работают WKWebView и трей-режим) — экономия была бы
только при выгрузке страницы, а это потеря мгновенного возврата в клиент;
- первый запуск сразу после сборки бандла (холодный кэш файловой системы и
страницы) дал 1,26 с — тоже в бюджете;
- почти вся память — WebKit (страница клиента), сама обёртка занимает ~117 МБ.
Повторить замер:
```bash
make desktop-build-app # свежий .app (+ артефакты обновления)
python3 scripts/desktop-perf.py --runs 3 # 3 холодных старта + простой в трее
```
## Что не сделано (открытые пункты Фазы 6)
- **Автообновление проверено локально «поверх старой версии»** (2026-09-26):
0.1.0 → манифест 0.1.1 → скачивание → проверка подписи → установка →
перезапуск на 0.1.1, подробности и команды — «Локальная проверка» выше.
Открытым остаётся публикация реального релиза на стенде (номер версии
поднимает оператор) и то, что локальная проверка шла по http с временным
флагом `dangerousInsecureTransportProtocol` — на стенде канал https.
- **Аватары в уведомлениях** (thumb/буква) и группировка — сейчас иконка приложения.
- **Захват экрана и микрофон**: разрешения macOS выдаёт webview, отдельных
экранов-инструкций у обёртки нет.
- **Подпись и нотаризация macOS — сознательно не делаются** (решение владельца
26.09.2026): приложение распространяется неподписанным `.dmg`, карантин
снимает конечный пользователь (инструкция ниже). EV/Trusted Signing для
Windows — тем же решением не делаем.
- **`.dmg`**: собирается из обычного терминала (Finder/AppleScript для раскладки
окна); в песочнице агента `hdiutil create` и AppleScript запрещены, но образ
**без оформления окна** собирается флагом `--sandbox-safe` к `bundle_dmg.sh`
(проверено 2026-09-26, см. ниже).
- **Windows/Linux-бандлы**: конфигурация кросс-платформенная, но собирались и
проверялись только macOS-arm64. Сборки под другие ОС **отложены**: репозиторий
живёт в Gitea на мини-ПК, а `windows-latest`/`ubuntu-24.04`-раннеры есть у
GitHub Actions, поэтому владелец зеркалит проект в GitHub позже и запускает
сборку там. Локальная часть готова и проверена: `make desktop-check`,
`scripts/desktop-release.sh --artifact … --target windows|linux` (манифест
обновления для каждой платформы складывается в общий файл версии).
## Что осталось оператору (точные команды)
### macOS: `.dmg`
`bundle_dmg.sh` создаёт образ через `hdiutil create` и раскладывает иконки через
AppleScript/Finder. Из песочницы агента и из headless-сессии обе операции
запрещены (`hdiutil create` → «Операция не разрешена», AppleScript → -10004).
Из обычного терминала на macOS:
```bash
cd /Volumes/Samsung/projects/glchat
make desktop-build # .app + .dmg + артефакты обновления
ls -lh desktop/src-tauri/target/release/bundle/dmg/
```
Если терминала с графической сессией нет (CI, headless), тот же образ
собирается без оформления окна — тем же скриптом Tauri с флагом
`--sandbox-safe` (он пропускает AppleScript и `hdiutil create`):
```bash
cd /Volumes/Samsung/projects/glchat
make desktop-build-app # .app и артефакты обновления
rm -rf /tmp/dmg-stage && mkdir -p /tmp/dmg-stage
cp -R desktop/src-tauri/target/release/bundle/macos/glchat.app /tmp/dmg-stage/
cd desktop/src-tauri/target/release/bundle/dmg
bash bundle_dmg.sh --volname glchat --volicon icon.icns \
--icon glchat.app 180 170 --app-drop-link 480 170 --hide-extension glchat.app \
--sandbox-safe glchat_0.1.0_aarch64.dmg /tmp/dmg-stage
hdiutil verify glchat_0.1.0_aarch64.dmg
```
Такой `.dmg` монтируется и содержит `glchat.app` и ссылку `Applications`, но без
красивой раскладки иконок — для локальных проверок и CI этого достаточно, для
публикации лучше собрать обычным `make desktop-build`.
Артефакты без `.dmg` (для автообновления) собираются где угодно:
`make desktop-build-app`.
### macOS: подпись Developer ID и нотаризация — сознательно не делаем
**Решение владельца (2026-09-26): подпись Developer ID и нотаризацию не
делаем** — это платный аккаунт Apple Developer, а приложение распространяется
внутри своего круга. Распространяется **неподписанный** `.dmg`; действия при
первом запуске выполняет конечный пользователь (ниже). Подпись артефакта
обновления (ed25519, ключ инстанса) от этого не зависит и проверяется всегда —
её отсутствие или порча артефакта установку отменяют.
Что делает конечный пользователь (короткая инструкция, её же можно передать
вместе с файлом):
1. Открыть `.dmg` и перетащить `glchat` в `Applications`.
2. Первый запуск — **правый клик по приложению → «Открыть» → «Открыть»** в
диалоге «разработчик не может быть проверен». Дальше приложение запускается
обычным двойным кликом.
3. Если macOS не показывает кнопку «Открыть» (карантин): в терминале
`xattr -dr com.apple.quarantine /Applications/glchat.app`, либо «Системные
настройки → Конфиденциальность и безопасность → Всё равно открыть».
Текущий установщик релиза 0.1.1:
`desktop/src-tauri/target/release/bundle/dmg/glchat_0.1.1_aarch64.dmg`
(3,1 МБ, sha256 `7f93609164dc5fdfe6255b88eaf909c02ba819a7e4fb79951cc50b142da825c0`).
Собирается `make desktop-build` (или `bundle_dmg.sh --sandbox-safe` в headless).
Если однажды понадобится подпись, команды остаются прежними (переменные читает
сам Tauri, в репозиторий они не попадают):
```bash
security find-identity -v -p codesigning # проверить наличие сертификата
export APPLE_SIGNING_IDENTITY="Developer ID Application: … (TEAMID)"
export APPLE_ID="…@example.com"
export APPLE_PASSWORD="app-specific-password"
export APPLE_TEAM_ID="TEAMID"
make desktop-build # Tauri подпишет и отправит на нотаризацию
xcrun stapler validate desktop/src-tauri/target/release/bundle/macos/glchat.app
spctl --assess --type execute --verbose=4 desktop/src-tauri/target/release/bundle/macos/glchat.app
```
Проверка «не подписано»: `codesign -dv --verbose=4 <app>` покажет `Signature=adhoc`.
### Windows (x64): MSI/NSIS + подпись
Сборка идёт на Windows-машине или в CI (`windows-latest`), Rust + Node 22:
```powershell
npx @tauri-apps/cli@^2 build --bundles nsis,msi
# подпись (EV/Trusted Signing или signtool с сертификатом):
signtool sign /fd SHA256 /tr http://timestamp.digicert.com /td SHA256 `
/f cert.pfx /p $env:CERT_PASSWORD `
desktop\src-tauri\target\release\bundle\nsis\glchat_0.1.0_x64-setup.exe
```
Артефакты обновления (`*.nsis.zip`/`*.msi` + `.sig`) публикуются тем же
скриптом: `bash scripts/desktop-release.sh --artifact <файл> --target windows --arch x86_64`.
### Linux (AppImage + deb)
Сборка на Ubuntu 24.04/26.04 (нужны `libwebkit2gtk-4.1-dev`, `librsvg2-dev`,
`patchelf`, `libssl-dev`, `build-essential`):
```bash
sudo apt-get install -y libwebkit2gtk-4.1-dev librsvg2-dev patchelf libssl-dev build-essential
npx @tauri-apps/cli@^2 build --bundles appimage,deb
bash scripts/desktop-release.sh --artifact desktop/src-tauri/target/release/bundle/appimage/*.AppImage.tar.gz \
--target linux --arch x86_64
```
На Wayland нативная тряска окна при несохранённых изменениях не работает
(композитор запрещает `set_position`) — остаётся CSS-тряска контента; это
ожидаемое поведение, а не дефект.
### Ключ подписи обновлений
Приватный ключ — только в CI-секретах (`TAURI_SIGNING_PRIVATE_KEY`,
`TAURI_SIGNING_PRIVATE_KEY_PASSWORD`); локально — `build/desktop-keys/`
(каталог в `.gitignore`). Публичный ключ уже вшит в сборку и меняется только
вместе с ключом: старые сборки отвергнут манифест, подписанный новым ключом.
+4
View File
@@ -0,0 +1,4 @@
# Generated by Cargo
# will have compiled files and executables
/target/
/gen/schemas
+6098
View File
File diff suppressed because it is too large Load Diff
+41
View File
@@ -0,0 +1,41 @@
[package]
name = "glchat-desktop"
version = "0.1.2"
description = "glchat — desktop-обёртка вокруг веб-клиента (Tauri 2)"
authors = ["glchat"]
license = "AGPL-3.0-or-later"
repository = "https://gt.mhspx.su/grendervill/glchat"
edition = "2021"
rust-version = "1.77.2"
[lib]
name = "glchat_desktop_lib"
crate-type = ["staticlib", "cdylib", "rlib"]
[build-dependencies]
tauri-build = { version = "2", features = [] }
[dependencies]
tauri = { version = "2", features = ["tray-icon", "image-png"] }
tauri-plugin-autostart = "2"
tauri-plugin-deep-link = "2"
tauri-plugin-dialog = "2"
tauri-plugin-global-shortcut = "2"
tauri-plugin-log = "2"
tauri-plugin-notification = "2"
tauri-plugin-opener = "2"
tauri-plugin-single-instance = "2"
tauri-plugin-store = "2"
tauri-plugin-updater = "2"
tauri-plugin-window-state = "2"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
log = "0.4"
url = "2"
[profile.release]
codegen-units = 1
lto = true
opt-level = "s"
panic = "abort"
strip = true
+3
View File
@@ -0,0 +1,3 @@
fn main() {
tauri_build::build()
}
@@ -0,0 +1,10 @@
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "instance-page",
"description": "Веб-клиент инстанса. IPC разрешён только для домена инстанса (docs/client-tauri.md §6): команды обёртки — минимальные и типизированные, плагины напрямую странице не доступны.",
"windows": ["main"],
"remote": {
"urls": ["https://gl.mhspx.su"]
},
"permissions": ["core:default"]
}
@@ -0,0 +1,7 @@
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "local-settings",
"description": "Локальная страница настроек обёртки (tauri://localhost) в главном окне: команды обёртки, работа с окном и хранилищем.",
"windows": ["main"],
"permissions": ["core:default", "store:default", "dialog:default", "opener:default"]
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 9.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

+25
View File
@@ -0,0 +1,25 @@
//! Автозапуск приложения (docs/client-tauri.md §5).
//!
//! Запуск идёт с аргументом `--minimized`: приложение поднимается свёрнутым в
//! трей и не перебивает работу пользователя окном.
use tauri::{AppHandle, Runtime};
use tauri_plugin_autostart::ManagerExt;
/// Аргумент командной строки для запуска свёрнутым в трей.
pub const MINIMIZED_ARG: &str = "--minimized";
/// Приводит системный автозапуск в соответствие с настройкой.
pub fn apply<R: Runtime>(app: &AppHandle<R>, enabled: bool) -> Result<(), String> {
let manager = app.autolaunch();
let current = manager.is_enabled().unwrap_or(false);
if current == enabled {
return Ok(());
}
let result = if enabled {
manager.enable()
} else {
manager.disable()
};
result.map_err(|error| format!("не удалось изменить автозапуск: {error}"))
}
+74
View File
@@ -0,0 +1,74 @@
//! Мост «Rust → веб-клиент».
//!
//! Обратное направление (страница → обёртка) идёт через типизированные команды
//! (`commands.rs`), а события платформы передаются вызовом единственной функции
//! `window.__glchatDesktop.receive(...)` в самой странице. Отдельного канала
//! событий нет намеренно: сообщений мало, а типы у них общие с `web/src/lib/platform.ts`.
use serde::Serialize;
use tauri::{AppHandle, Manager, Runtime};
use crate::settings::Settings;
use crate::state::AppState;
/// Метка главного окна (единственное окно приложения).
pub const WEB_WINDOW: &str = "main";
/// Сообщение веб-клиенту: то, что платформа сообщает странице.
#[derive(Debug, Clone, Serialize)]
#[serde(tag = "type", rename_all = "camelCase")]
pub enum WebMessage {
/// Нажат или отпущен глобальный push-to-talk.
Ptt { active: bool },
/// Переключить микрофон (глобальный шорткат).
ToggleMic,
/// Переключить «не слышу» (глобальный шорткат).
ToggleDeafen,
/// Открыть deep link: приглашение или сервер с комнатой.
DeepLink { url: String },
/// Настройки обёртки изменились (обновлена страница настроек).
Settings { settings: Settings },
/// Доступно обновление — клиент показывает уведомление в интерфейсе.
UpdateAvailable { version: String },
/// Обёртка перехватила закрытие окна: в клиенте есть несохранённые
/// изменения, он показывает тряску и диалог «Сохранить / Выйти без
/// сохранения» (docs/client-tauri.md §5).
CloseRequested,
/// Открыть палитру команд: глобальный Ctrl/Cmd+K при неактивном окне.
OpenPalette,
}
/// Отправляет сообщение в веб-клиент.
///
/// Если клиент ещё не загрузился, deep link ставится в очередь и будет отдан
/// после `desktop_ready` (docs/client-tauri.md §5). Остальные сообщения в этот
/// момент терять не страшно: они относятся к активному взаимодействию.
pub fn send<R: Runtime>(app: &AppHandle<R>, message: &WebMessage) -> bool {
let state = app.state::<AppState>();
if !state.web_ready() {
if let WebMessage::DeepLink { url } = message {
state.push_deep_link(url.clone());
}
return false;
}
dispatch(app, message)
}
/// Немедленная доставка без проверки готовности клиента.
pub fn dispatch<R: Runtime>(app: &AppHandle<R>, message: &WebMessage) -> bool {
let Some(window) = app.get_webview_window(WEB_WINDOW) else {
return false;
};
let Ok(payload) = serde_json::to_string(message) else {
log::warn!("не удалось сериализовать сообщение веб-клиенту");
return false;
};
let script = format!("window.__glchatDesktop && window.__glchatDesktop.receive({payload});");
match window.eval(&script) {
Ok(()) => true,
Err(error) => {
log::debug!("веб-клиент не принял сообщение: {error}");
false
}
}
}
+143
View File
@@ -0,0 +1,143 @@
//! Команды обёртки: типизированный мост «веб-клиент → Rust» (docs/client-tauri.md §2).
//!
//! Команд ровно столько, сколько нужно странице: прочитать и сохранить
//! настройки, сообщить о готовности, показать уведомление, обновить счётчик
//! непрочитанных, открыть настройки/клиент и проверить обновления. Никакой
//! бизнес-логики здесь нет — только платформенные действия.
use tauri::{AppHandle, Manager, Runtime};
use crate::bridge::{self, WebMessage};
use crate::settings::Settings;
use crate::state::AppState;
use crate::{autostart, instance, notifications, shortcuts, updates, window};
/// Текущие настройки обёртки.
#[tauri::command]
pub fn desktop_settings<R: Runtime>(app: AppHandle<R>) -> Settings {
app.state::<AppState>().settings()
}
/// Версия приложения — показывается на странице настроек.
#[tauri::command]
pub fn desktop_version<R: Runtime>(app: AppHandle<R>) -> String {
app.package_info().version.to_string()
}
/// Сохраняет настройки и применяет их: автозапуск, шорткаты, адрес инстанса.
#[tauri::command]
pub fn desktop_save_settings<R: Runtime>(
app: AppHandle<R>,
settings: Settings,
) -> Result<Settings, String> {
// Адрес проверяем до применения, чтобы не оставить обёртку без клиента.
let url = instance::normalize(&settings.instance_url)?;
let mut next = settings;
next.instance_url = instance::display(&url);
let previous = app.state::<AppState>().settings();
// Шорткаты применяем первыми: если комбинация занята, настройки не сохраняем.
if next.ptt_shortcut != previous.ptt_shortcut
|| next.mute_shortcut != previous.mute_shortcut
|| next.deafen_shortcut != previous.deafen_shortcut
{
if let Err(error) = shortcuts::apply(&app, &next) {
let _ = shortcuts::apply(&app, &previous);
return Err(error);
}
}
if let Err(error) = autostart::apply(&app, next.autostart) {
log::warn!("{error}");
}
app.state::<AppState>().set_settings(next.clone());
crate::settings::save(&app, &next)?;
crate::tray::refresh(&app);
announce_settings(&app, &next);
if next.instance_url != previous.instance_url {
window::open_instance_page(&app)?;
}
Ok(next)
}
/// Веб-клиент загрузился: отдаём накопленные deep link.
#[tauri::command]
pub fn desktop_ready<R: Runtime>(app: AppHandle<R>) {
let pending = app.state::<AppState>().mark_web_ready();
for url in pending {
bridge::dispatch(&app, &WebMessage::DeepLink { url });
}
}
/// Показывает системное уведомление.
#[tauri::command]
pub fn desktop_notify<R: Runtime>(
app: AppHandle<R>,
payload: notifications::NotifyPayload,
) -> Result<(), String> {
notifications::show(&app, &payload)
}
/// Обновляет счётчик непрочитанных в доке и трее.
#[tauri::command]
pub fn desktop_set_badge<R: Runtime>(app: AppHandle<R>, count: u32) {
notifications::set_badge(&app, count);
}
/// Веб-клиент сообщает, есть ли несохранённые изменения: от этого зависит
/// перехват закрытия окна (docs/client-tauri.md §5).
#[tauri::command]
pub fn desktop_set_dirty<R: Runtime>(app: AppHandle<R>, dirty: bool) {
app.state::<AppState>().set_dirty(dirty);
}
/// Закрывает приложение по решению пользователя в диалоге клиента
/// («Сохранить» после успешного сохранения или «Выйти без сохранения»).
#[tauri::command]
pub fn desktop_close_window<R: Runtime>(app: AppHandle<R>) {
log::info!("закрываем приложение по решению пользователя");
app.exit(0);
}
/// Открывает локальную страницу настроек обёртки.
#[tauri::command]
pub fn desktop_open_settings<R: Runtime>(app: AppHandle<R>) -> Result<(), String> {
window::open_settings_page(&app)
}
/// Возвращает окно к клиенту инстанса.
#[tauri::command]
pub fn desktop_open_instance<R: Runtime>(app: AppHandle<R>) -> Result<(), String> {
window::open_instance_page(&app)
}
/// Перезагружает клиент инстанса.
#[tauri::command]
pub fn desktop_reload<R: Runtime>(app: AppHandle<R>) -> Result<(), String> {
window::reload_instance(&app)
}
/// Проверяет обновления (кнопка на странице настроек).
#[tauri::command]
pub async fn desktop_check_updates<R: Runtime>(app: AppHandle<R>) {
updates::check(app, true, false).await;
}
/// Открывает ссылку в системном браузере.
#[tauri::command]
pub fn desktop_open_external<R: Runtime>(app: AppHandle<R>, url: String) -> Result<(), String> {
let parsed = instance::normalize(&url)?;
tauri_plugin_opener::OpenerExt::opener(&app)
.open_url(parsed.as_str(), None::<&str>)
.map_err(|error| format!("не удалось открыть ссылку: {error}"))
}
/// Сообщает веб-клиенту, что настройки обёртки изменились.
pub fn announce_settings<R: Runtime>(app: &AppHandle<R>, settings: &Settings) {
bridge::send(
app,
&WebMessage::Settings {
settings: settings.clone(),
},
);
}
+57
View File
@@ -0,0 +1,57 @@
//! Deep links `glchat://…` (docs/client-tauri.md §5).
//!
//! Поддерживаются `glchat://invite/<code>` и `glchat://guild/<id>/channel/<id>`.
//! Ссылка приходит и при холодном старте (приложение ещё поднимается), и при
//! тёплом: в первом случае она ждёт в очереди до `desktop_ready` веб-клиента.
use tauri::{AppHandle, Runtime};
use tauri_plugin_deep_link::DeepLinkExt;
use crate::bridge::{self, WebMessage};
/// Схема deep link, зарегистрированная в `tauri.conf.json`.
pub const SCHEME: &str = "glchat";
/// Подписывается на ссылки, пришедшие из системы.
pub fn install<R: Runtime>(app: &AppHandle<R>) {
let handle = app.clone();
app.deep_link().on_open_url(move |event| {
for url in event.urls() {
log::info!("deep link: {url}");
bridge::send(
&handle,
&WebMessage::DeepLink {
url: url.to_string(),
},
);
}
});
// В разработке и в AppImage схема может быть не прописана в системе:
// регистрируем её на лету (в собранном .app/установщике это делает бандл).
if let Err(error) = app.deep_link().register_all() {
log::debug!("схема {SCHEME}:// не зарегистрирована автоматически: {error}");
}
// Ссылка могла быть передана аргументом командной строки (Windows/Linux).
if let Ok(Some(urls)) = app.deep_link().get_current() {
for url in urls {
bridge::send(
app,
&WebMessage::DeepLink {
url: url.to_string(),
},
);
}
}
}
/// Достаёт deep link из аргументов повторного запуска приложения.
///
/// Второй экземпляр не поднимается (single-instance): его аргументы приходят
/// первому, и ссылку нужно доставить в уже открытый клиент.
pub fn from_args(args: &[String]) -> Option<String> {
args.iter()
.find(|arg| arg.starts_with(&format!("{SCHEME}://")))
.cloned()
}
+63
View File
@@ -0,0 +1,63 @@
//! Адрес инстанса и границы навигации.
//!
//! Desktop-обёртка загружает тот же клиент, что и браузер, но только с одного
//! адреса: переходы на сторонние сайты уходят в системный браузер
//! (docs/client-tauri.md §6).
use url::Url;
/// Приводит введённый пользователем адрес к абсолютному URL.
///
/// Допускаются `http` и `https`; схема по умолчанию — `https`, чтобы в поле
/// настроек можно было написать просто `gl.mhspx.su`.
pub fn normalize(input: &str) -> Result<Url, String> {
let trimmed = input.trim();
if trimmed.is_empty() {
return Err("адрес инстанса не заполнен".to_string());
}
let candidate = if trimmed.contains("://") {
trimmed.to_string()
} else {
format!("https://{trimmed}")
};
let url = Url::parse(&candidate).map_err(|error| format!("некорректный адрес: {error}"))?;
match url.scheme() {
"http" | "https" => {}
other => {
return Err(format!(
"поддерживаются только http и https, а не «{other}»"
))
}
}
if url.host_str().unwrap_or_default().is_empty() {
return Err("в адресе нет имени хоста".to_string());
}
Ok(url)
}
/// Строка адреса для загрузки в окно: без завершающего слэша.
pub fn display(url: &Url) -> String {
url.as_str().trim_end_matches('/').to_string()
}
/// Хост адреса — граница, внутри которой разрешена навигация webview.
pub fn host(url: &Url) -> String {
url.host_str().unwrap_or_default().to_ascii_lowercase()
}
/// Разрешена ли навигация внутри окна.
///
/// Свой инстанс и локальные страницы обёртки (`tauri://localhost`) — да,
/// всё остальное открывается в системном браузере.
pub fn navigation_allowed(target: &Url, instance_host: &str) -> bool {
match target.scheme() {
// Локальные ресурсы обёртки: страница настроек.
"tauri" | "asset" => true,
"http" | "https" => {
let host = target.host_str().unwrap_or_default().to_ascii_lowercase();
// На Windows/Linux локальная страница отдаётся как http://tauri.localhost.
host == "tauri.localhost" || (!instance_host.is_empty() && host == instance_host)
}
_ => false,
}
}
+381
View File
@@ -0,0 +1,381 @@
//! glchat desktop — обёртка Tauri 2 вокруг веб-клиента (docs/client-tauri.md).
//!
//! Принцип: тот же веб-код, что и в браузере, плюс системная интеграция —
//! трей, нативные уведомления, глобальный push-to-talk, автозапуск,
//! автообновление и deep links. Бизнес-логики в Rust нет: обёртка знает только
//! адрес инстанса, свои переключатели и границы навигации.
mod autostart;
mod bridge;
mod commands;
mod deeplink;
mod instance;
mod notifications;
mod settings;
mod shortcuts;
mod state;
mod tray;
mod updates;
mod window;
use std::path::Path;
use std::time::Duration;
use tauri::{AppHandle, Manager, WindowEvent};
use tauri_plugin_autostart::MacosLauncher;
use tauri_plugin_dialog::{DialogExt, MessageDialogButtons};
use state::AppState;
/// Задержка перед фоновой проверкой обновлений: окно должно успеть открыться.
const UPDATE_CHECK_DELAY: Duration = Duration::from_secs(30);
/// Задержка перед вопросом об автозапуске при первом запуске.
const AUTOSTART_PROMPT_DELAY: Duration = Duration::from_secs(3);
/// Аргументы командной строки обёртки.
#[derive(Debug, Default)]
struct Cli {
/// Переопределение адреса инстанса (`--instance https://…`).
instance: Option<String>,
/// Запуск свёрнутым в трей (используется автозапуском).
minimized: bool,
/// Сразу открыть страницу настроек обёртки.
settings: bool,
/// Принудительно включить или выключить автозапуск (установка скриптом).
autostart: Option<bool>,
/// Проверить обновление сразу при старте и установить его без диалога
/// (тихое обновление управляемых машин; по умолчанию выключено).
update_now: bool,
}
impl Cli {
fn parse() -> Self {
Self::from_args(std::env::args().skip(1))
}
/// Разбор аргументов отделён от окружения, чтобы его можно было тестировать.
fn from_args<I: IntoIterator<Item = String>>(args: I) -> Self {
let mut cli = Self::default();
let mut args = args.into_iter();
while let Some(arg) = args.next() {
match arg.as_str() {
"--instance" => cli.instance = args.next(),
"--minimized" => cli.minimized = true,
"--settings" => cli.settings = true,
"--update-now" => cli.update_now = true,
"--autostart" => cli.autostart = args.next().as_deref().and_then(parse_switch),
other => {
if let Some(value) = other.strip_prefix("--instance=") {
cli.instance = Some(value.to_string());
} else if let Some(value) = other.strip_prefix("--autostart=") {
cli.autostart = parse_switch(value);
}
}
}
}
cli
}
}
/// Разбирает `on/off` (а также `true/false`, `1/0`) из аргументов командной строки.
fn parse_switch(value: &str) -> Option<bool> {
match value.trim().to_ascii_lowercase().as_str() {
"on" | "true" | "1" | "yes" => Some(true),
"off" | "false" | "0" | "no" => Some(false),
_ => None,
}
}
/// Точка входа приложения.
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
let cli = Cli::parse();
tauri::Builder::default()
// Single-instance регистрируется первым: повторный запуск передаёт
// аргументы (в том числе deep link) первому экземпляру и завершается.
.plugin(tauri_plugin_single_instance::init(|app, argv, _cwd| {
window::show_main(app);
if let Some(url) = deeplink::from_args(&argv) {
bridge::send(app, &bridge::WebMessage::DeepLink { url });
}
}))
.plugin(tauri_plugin_store::Builder::new().build())
.plugin(tauri_plugin_notification::init())
.plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_opener::init())
.plugin(tauri_plugin_window_state::Builder::default().build())
.plugin(tauri_plugin_updater::Builder::new().build())
.plugin(tauri_plugin_deep_link::init())
.plugin(tauri_plugin_autostart::init(
MacosLauncher::LaunchAgent,
Some(vec![autostart::MINIMIZED_ARG]),
))
.plugin(
tauri_plugin_global_shortcut::Builder::new()
.with_handler(shortcuts::on_shortcut)
.build(),
)
.invoke_handler(tauri::generate_handler![
commands::desktop_settings,
commands::desktop_save_settings,
commands::desktop_version,
commands::desktop_ready,
commands::desktop_notify,
commands::desktop_set_badge,
commands::desktop_set_dirty,
commands::desktop_close_window,
commands::desktop_open_settings,
commands::desktop_open_instance,
commands::desktop_reload,
commands::desktop_check_updates,
commands::desktop_open_external,
])
.setup(move |app| {
setup(app.handle(), &cli);
Ok(())
})
.on_window_event(|window, event| {
let app = window.app_handle();
match event {
// Закрытие окна: либо сворачиваемся в трей, либо выходим целиком
// (docs/client-tauri.md §2; открытый вопрос §9.2 решён настройкой).
WindowEvent::CloseRequested { api, .. } => {
if app.state::<AppState>().settings().close_to_tray {
api.prevent_close();
if let Err(error) = window.hide() {
log::warn!("не удалось свернуть окно в трей: {error}");
}
} else if window::request_close_confirmation(app) {
// В веб-клиенте есть несохранённые изменения: закрытие
// перехватываем, окно покачиваем, а решение принимает
// пользователь в диалоге клиента — «Сохранить / Выйти
// без сохранения» (docs/client-tauri.md §5).
api.prevent_close();
} else {
app.exit(0);
}
}
// Окно получило фокус: если только что было показано уведомление,
// считаем активацию кликом по нему и открываем комнату.
WindowEvent::Focused(true) => {
notifications::open_pending(app);
}
_ => {}
}
})
.build(tauri::generate_context!())
.expect("не удалось запустить glchat desktop")
.run(|app, event| {
// macOS: applicationShouldHandleReopen — клик по иконке в доке или
// активация приложения без видимых окон (например, окно свёрнуто в
// трей). Тот же путь использует клик по уведомлению.
#[cfg(target_os = "macos")]
if let tauri::RunEvent::Reopen { .. } = event {
window::show_main(app);
notifications::open_pending(app);
}
#[cfg(not(target_os = "macos"))]
let _ = (app, event);
});
}
/// Подключает логирование: файл в каталоге логов приложения и stdout.
///
/// Если каталог логов недоступен на запись (например, приложение запущено из
/// песочницы или домашний каталог только для чтения), логи остаются только в
/// stdout: падать из-за прав на каталог приложение не должно.
fn install_logging(app: &AppHandle) {
let mut builder = tauri_plugin_log::Builder::default().level(log::LevelFilter::Info);
match app.path().app_log_dir() {
Ok(dir) if directory_is_writable(&dir) => {
builder = builder.target(tauri_plugin_log::Target::new(
tauri_plugin_log::TargetKind::LogDir { file_name: None },
));
}
Ok(dir) => eprintln!("каталог логов {} недоступен на запись", dir.display()),
Err(error) => eprintln!("не удалось определить каталог логов: {error}"),
}
if let Err(error) = app.plugin(builder.build()) {
eprintln!("не удалось подключить логирование: {error}");
}
}
/// Проверяет, можно ли писать в каталог (создаёт и удаляет пробный файл).
fn directory_is_writable(dir: &Path) -> bool {
if std::fs::create_dir_all(dir).is_err() {
return false;
}
let probe = dir.join(".glchat-write-probe");
match std::fs::write(&probe, b"") {
Ok(()) => {
let _ = std::fs::remove_file(&probe);
true
}
Err(_) => false,
}
}
/// Начальная настройка: настройки, трей, окно, шорткаты, deep links.
fn setup(app: &tauri::AppHandle, cli: &Cli) {
install_logging(app);
let mut current = settings::load(app);
if let Some(instance) = &cli.instance {
match instance::normalize(instance) {
Ok(url) => current.instance_url = instance::display(&url),
Err(error) => log::warn!("адрес из командной строки не принят: {error}"),
}
}
// Аргумент `--autostart` перекрывает сохранённое значение и снимает
// необходимость спрашивать пользователя при первом запуске.
if let Some(autostart) = cli.autostart {
current.autostart = autostart;
current.autostart_prompted = true;
}
app.manage(AppState::default());
app.state::<AppState>().set_settings(current.clone());
if let Err(error) = settings::save(app, &current) {
log::warn!("не удалось сохранить настройки при старте: {error}");
}
if let Err(error) = autostart::apply(app, current.autostart) {
log::warn!("{error}");
}
if let Err(error) = tray::build(app) {
log::error!("{error}");
}
if let Err(error) = window::create_main(app, &current, cli.minimized) {
log::error!("{error}");
}
if let Err(error) = shortcuts::apply(app, &current) {
log::warn!("{error}");
}
deeplink::install(app);
// Версия в журнале: после автообновления это единственный след того, какая
// сборка реально запустилась (проверка обновления сама версию не печатает).
log::info!(
"обёртка запущена: версия={}, адрес={}, окно={}, автозапуск={}",
app.package_info().version,
current.instance_url,
if cli.minimized {
"свёрнуто"
} else {
"видимо"
},
current.autostart
);
if cli.settings {
if let Err(error) = window::open_settings_page(app) {
log::warn!("{error}");
}
}
spawn_update_check(app.clone(), cli.update_now);
spawn_autostart_prompt(app.clone());
}
/// Проверка обновлений при старте.
///
/// Обычный режим — фоновая проверка через [`UPDATE_CHECK_DELAY`], найденное
/// обновление ставится только после подтверждения пользователя. С флагом
/// `--update-now` проверка идёт сразу, а установка — без диалога: это режим
/// управляемых машин, где обновление ставит оператор, а не пользователь
/// (desktop/README.md, «Автообновление»). Подпись проверяется в обоих режимах:
/// флаг убирает только вопрос, а не проверку артефакта.
fn spawn_update_check(app: tauri::AppHandle, update_now: bool) {
std::thread::spawn(move || {
if update_now {
log::info!("тихое обновление по флагу --update-now: проверка сразу при старте");
} else {
std::thread::sleep(UPDATE_CHECK_DELAY);
}
tauri::async_runtime::spawn(async move {
updates::check(app, false, update_now).await;
});
});
}
/// Вопрос об автозапуске при первом запуске (docs/client-tauri.md §5).
fn spawn_autostart_prompt(app: tauri::AppHandle) {
std::thread::spawn(move || {
std::thread::sleep(AUTOSTART_PROMPT_DELAY);
if app.state::<AppState>().settings().autostart_prompted {
return;
}
let handle = app.clone();
app.dialog()
.message(
"Запускать glchat при входе в систему? Приложение будет стартовать свёрнутым в трей.",
)
.title("Автозапуск glchat")
.buttons(MessageDialogButtons::YesNo)
.show(move |confirmed| {
log::info!("ответ на вопрос об автозапуске: {confirmed}");
let mut next = handle.state::<AppState>().settings();
next.autostart = confirmed;
next.autostart_prompted = true;
handle.state::<AppState>().set_settings(next.clone());
if let Err(error) = autostart::apply(&handle, next.autostart) {
log::warn!("{error}");
}
if let Err(error) = settings::save(&handle, &next) {
log::warn!("не удалось сохранить настройки: {error}");
}
tray::refresh(&handle);
});
});
}
#[cfg(test)]
mod tests {
use super::*;
fn parse(args: &[&str]) -> Cli {
Cli::from_args(args.iter().map(|value| value.to_string()))
}
#[test]
fn разбирает_аргументы_запуска() {
let cli = parse(&[
"--instance",
"https://example.com",
"--minimized",
"--settings",
]);
assert_eq!(cli.instance.as_deref(), Some("https://example.com"));
assert!(cli.minimized);
assert!(cli.settings);
assert_eq!(cli.autostart, None);
// Тихая установка обновления включается только явным флагом.
assert!(!cli.update_now);
assert!(parse(&["--update-now"]).update_now);
}
#[test]
fn разбирает_аргументы_со_значением_через_равно() {
let cli = parse(&["--instance=gl.example.org", "--autostart=off"]);
assert_eq!(cli.instance.as_deref(), Some("gl.example.org"));
assert_eq!(cli.autostart, Some(false));
}
#[test]
fn разбирает_переключатель_автозапуска() {
assert_eq!(parse(&["--autostart", "on"]).autostart, Some(true));
assert_eq!(parse(&["--autostart", "off"]).autostart, Some(false));
assert_eq!(parse(&["--autostart", "мусор"]).autostart, None);
// Аргумент без значения не должен ничего ломать.
assert_eq!(parse(&["--autostart"]).autostart, None);
}
#[test]
fn игнорирует_неизвестные_аргументы() {
let cli = parse(&["--unknown", "value", "мусор"]);
assert_eq!(cli.instance, None);
assert!(!cli.minimized);
assert!(!cli.settings);
}
}
+6
View File
@@ -0,0 +1,6 @@
// Отключает лишнее консольное окно на Windows в релизной сборке.
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
fn main() {
glchat_desktop_lib::run();
}
+172
View File
@@ -0,0 +1,172 @@
//! Нативные уведомления и счётчик непрочитанных (docs/client-tauri.md §5).
//!
//! Решение о том, показывать ли уведомление, принимает веб-клиент (он знает про
//! упоминания, личные сообщения и настройки пользователя), а обёртка только
//! показывает системное уведомление и обновляет бейдж.
//!
//! Клик по уведомлению: `tauri-plugin-notification` на desktop не даёт колбэка
//! действия, поэтому используется лучшее доступное поведение — обёртка
//! запоминает уведомление, показанное при неактивном окне, и когда приложение
//! активируется (окно получило фокус, macOS прислала `Reopen`), открывает канал
//! из этого уведомления. Ограничение честно описано в `desktop/README.md`:
//! отличить клик по уведомлению от обычного возврата в приложение ОС не даёт,
//! поэтому переход делается только для «свежего» уведомления (см.
//! [`ACTIVATION_WINDOW`]).
use std::time::Duration;
use serde::Deserialize;
use tauri::{AppHandle, Manager, Runtime};
use tauri_plugin_notification::NotificationExt;
use crate::bridge::{self, WebMessage, WEB_WINDOW};
use crate::state::AppState;
/// Сколько после показа уведомления активация приложения считается кликом по нему.
const ACTIVATION_WINDOW: Duration = Duration::from_secs(10);
/// Уведомление, пришедшее из веб-клиента.
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct NotifyPayload {
/// Заголовок: имя автора или название сервера.
pub title: String,
/// Текст: начало сообщения.
pub body: String,
/// Сервер комнаты: нужен для перехода по клику (`None` — личная беседа).
#[serde(default)]
pub guild_id: Option<String>,
/// Комната, которую открывает клик по уведомлению.
#[serde(default)]
pub channel_id: Option<String>,
}
/// Показывает системное уведомление, если они включены в настройках обёртки.
pub fn show<R: Runtime>(app: &AppHandle<R>, payload: &NotifyPayload) -> Result<(), String> {
if !app.state::<AppState>().settings().notifications {
return Ok(());
}
// В лог не попадают ни текст сообщения, ни имя автора (docs/client-tauri.md §6).
log::debug!(
"показываем уведомление (сервер {:?}, комната {:?})",
payload.guild_id,
payload.channel_id
);
app.notification()
.builder()
.title(payload.title.clone())
.body(payload.body.clone())
.show()
.map_err(|error| format!("не удалось показать уведомление: {error}"))?;
// Запоминаем уведомление только для неактивного окна: если окно в фокусе,
// пользователь и так видит клиента, и переход по «активации» был бы ложным.
if let Some(channel_id) = payload.channel_id.clone() {
if !window_focused(app) {
app.state::<AppState>()
.remember_notification(payload.guild_id.clone(), channel_id);
}
}
Ok(())
}
/// Открывает канал последнего уведомления при активации приложения.
///
/// Возвращает `true`, если переход был отправлен в веб-клиент.
pub fn open_pending<R: Runtime>(app: &AppHandle<R>) -> bool {
let Some(pending) = app.state::<AppState>().take_notification(ACTIVATION_WINDOW) else {
return false;
};
let Some(url) = channel_link(pending.guild_id.as_deref(), &pending.channel_id) else {
log::warn!("уведомление пришло с непригодным идентификатором комнаты");
return false;
};
log::info!("активация приложения после уведомления: открываем комнату");
// Окно могло быть свёрнуто в трей: показываем его до перехода.
crate::window::show_main(app);
bridge::send(app, &WebMessage::DeepLink { url })
}
/// Ссылка на комнату из уведомления: сервер с комнатой или личная беседа.
///
/// Форма ссылок — как у внешних deep links (docs/client-tauri.md §5); разбор
/// на стороне клиента общий, поэтому переход работает и для личных бесед.
fn channel_link(guild_id: Option<&str>, channel_id: &str) -> Option<String> {
if !is_safe_id(channel_id) {
return None;
}
Some(match guild_id {
Some(guild) if is_safe_id(guild) => format!("glchat://guild/{guild}/channel/{channel_id}"),
_ => format!("glchat://dm/{channel_id}"),
})
}
/// Идентификаторы приходят из веб-клиента: в ссылку попадают только «снежинки»
/// (цифры), чтобы из чужого текста нельзя было собрать произвольный deep link.
fn is_safe_id(value: &str) -> bool {
!value.is_empty() && value.len() <= 32 && value.bytes().all(|byte| byte.is_ascii_digit())
}
/// В фокусе ли главное окно (нет окна — считаем, что нет).
fn window_focused<R: Runtime>(app: &AppHandle<R>) -> bool {
app.get_webview_window(WEB_WINDOW)
.and_then(|window| window.is_focused().ok())
.unwrap_or(false)
}
/// Обновляет число непрочитанных: значок приложения и подпись у иконки в трее.
pub fn set_badge<R: Runtime>(app: &AppHandle<R>, count: u32) {
let count = app.state::<AppState>().set_badge(count);
if let Some(window) = app.get_webview_window(WEB_WINDOW) {
// На части платформ бейдж не поддерживается — это не ошибка.
let _ = window.set_badge_count(if count == 0 {
None
} else {
Some(i64::from(count))
});
}
if let Some(tray) = app.tray_by_id(crate::tray::TRAY_ID) {
let _ = tray.set_title(if count == 0 {
None
} else {
Some(count.to_string())
});
let _ = tray.set_tooltip(Some(if count == 0 {
"glchat".to_string()
} else {
format!("glchat — непрочитанных: {count}")
}));
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn строит_ссылку_на_комнату_сервера() {
assert_eq!(
channel_link(Some("42"), "77").as_deref(),
Some("glchat://guild/42/channel/77")
);
}
#[test]
fn строит_ссылку_на_личную_беседу() {
assert_eq!(channel_link(None, "77").as_deref(), Some("glchat://dm/77"));
}
#[test]
fn отвергает_непригодные_идентификаторы() {
// Пустой, нечисловой и слишком длинный идентификатор в ссылку не попадают.
assert_eq!(channel_link(Some("42"), ""), None);
assert_eq!(channel_link(Some("42"), "77/../../etc"), None);
assert_eq!(channel_link(Some("42"), "канал"), None);
assert_eq!(channel_link(None, &"7".repeat(40)), None);
// Плохой сервер не мешает открыть личную беседу по тому же каналу.
assert_eq!(
channel_link(Some("сервер"), "77").as_deref(),
Some("glchat://dm/77")
);
}
}
+94
View File
@@ -0,0 +1,94 @@
//! Настройки desktop-обёртки.
//!
//! Лежат в `tauri-plugin-store` (`settings.json` в каталоге конфигурации
//! приложения). Обёртка хранит только то, что относится к платформе: адрес
//! инстанса и переключатели поведения. Сессия целиком живёт в cookie webview
//! (docs/client-tauri.md §2): токенов и паролей здесь нет.
use serde::{Deserialize, Serialize};
use tauri::{AppHandle, Runtime};
use tauri_plugin_store::StoreExt;
/// Файл хранилища в каталоге конфигурации приложения.
const STORE_FILE: &str = "settings.json";
/// Ключ, под которым лежит объект настроек.
const STORE_KEY: &str = "settings";
/// Адрес стенда по умолчанию: тот же клиент, что открывается в браузере.
pub const DEFAULT_INSTANCE: &str = "https://gl.mhspx.su";
/// Комбинация push-to-talk по умолчанию.
///
/// Голых клавиш (как `V` в браузерном PTT) в глобальном шорткате быть не может:
/// системный перехват занял бы клавишу у всех приложений, поэтому по умолчанию
/// комбинация с модификаторами, а пустая строка означает «выключено».
pub const DEFAULT_PTT_SHORTCUT: &str = "CommandOrControl+Shift+Space";
/// Комбинация «выключить/включить микрофон» по умолчанию.
pub const DEFAULT_MUTE_SHORTCUT: &str = "CommandOrControl+Shift+M";
/// Комбинация «не слышу» (deafen) по умолчанию.
pub const DEFAULT_DEAFEN_SHORTCUT: &str = "CommandOrControl+Shift+D";
/// Настройки обёртки. Поля в camelCase — их читает и пишет страница настроек.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default, rename_all = "camelCase")]
pub struct Settings {
/// Адрес инстанса, который загружается в главное окно.
pub instance_url: String,
/// Закрытие окна сворачивает приложение в трей.
pub close_to_tray: bool,
/// Запускать приложение при входе в систему (свёрнутым в трей).
pub autostart: bool,
/// Пользователю уже задавали вопрос про автозапуск при первом запуске.
pub autostart_prompted: bool,
/// Глобальный push-to-talk (пустая строка — выключен).
pub ptt_shortcut: String,
/// Глобальное переключение микрофона (пустая строка — выключено).
pub mute_shortcut: String,
/// Глобальное переключение «не слышу» (пустая строка — выключено).
pub deafen_shortcut: String,
/// Показывать нативные уведомления об упоминаниях и личных сообщениях.
pub notifications: bool,
}
impl Default for Settings {
fn default() -> Self {
Self {
instance_url: DEFAULT_INSTANCE.to_string(),
close_to_tray: true,
autostart: false,
autostart_prompted: false,
ptt_shortcut: DEFAULT_PTT_SHORTCUT.to_string(),
mute_shortcut: DEFAULT_MUTE_SHORTCUT.to_string(),
deafen_shortcut: DEFAULT_DEAFEN_SHORTCUT.to_string(),
notifications: true,
}
}
}
/// Читает настройки из хранилища; при отсутствии или порче — значения по умолчанию.
pub fn load<R: Runtime>(app: &AppHandle<R>) -> Settings {
let Ok(store) = app.store(STORE_FILE) else {
log::warn!("не удалось открыть хранилище настроек, используются значения по умолчанию");
return Settings::default();
};
let Some(value) = store.get(STORE_KEY) else {
return Settings::default();
};
match serde_json::from_value::<Settings>(value) {
Ok(settings) => settings,
Err(error) => {
log::warn!("настройки в хранилище повреждены ({error}), берём значения по умолчанию");
Settings::default()
}
}
}
/// Сохраняет настройки в хранилище.
pub fn save<R: Runtime>(app: &AppHandle<R>, settings: &Settings) -> Result<(), String> {
let store = app.store(STORE_FILE).map_err(|error| error.to_string())?;
let value = serde_json::to_value(settings).map_err(|error| error.to_string())?;
store.set(STORE_KEY, value);
store.save().map_err(|error| error.to_string())
}
+97
View File
@@ -0,0 +1,97 @@
//! Глобальные горячие клавиши (docs/client-tauri.md §5).
//!
//! Push-to-talk работает при неактивном окне: системный перехват ловит нажатие
//! и отпускание выбранной комбинации и передаёт состояние в веб-клиент, а тот
//! включает микрофон (в самом клиенте должен быть выбран режим «Push-to-talk»).
//! Дополнительно доступны переключение микрофона и «не слышу».
use tauri::{AppHandle, Manager, Runtime};
use tauri_plugin_global_shortcut::{GlobalShortcutExt, Shortcut, ShortcutState};
use crate::bridge::{self, WebMessage};
use crate::settings::Settings;
use crate::state::AppState;
/// Палитра команд (docs/client-tauri.md §5): комбинация не настраивается —
/// это системный жест «быстрый переход», одинаковый во всех сборках.
const PALETTE_SHORTCUT: &str = "CommandOrControl+K";
/// Обработчик нажатий: плагин вызывает его для любой зарегистрированной комбинации.
pub fn on_shortcut<R: Runtime>(
app: &AppHandle<R>,
shortcut: &Shortcut,
event: tauri_plugin_global_shortcut::ShortcutEvent,
) {
let settings = app.state::<AppState>().settings();
let pressed = event.state == ShortcutState::Pressed;
let ptt = parse(&settings.ptt_shortcut);
if ptt.as_ref() == Some(shortcut) {
bridge::send(app, &WebMessage::Ptt { active: pressed });
return;
}
// Остальные шорткаты срабатывают по нажатию, чтобы не дублировать действие.
if !pressed {
return;
}
if parse(&settings.mute_shortcut).as_ref() == Some(shortcut) {
bridge::send(app, &WebMessage::ToggleMic);
return;
}
if parse(&settings.deafen_shortcut).as_ref() == Some(shortcut) {
bridge::send(app, &WebMessage::ToggleDeafen);
return;
}
// Быстрый переход: окно поднимаем и просим клиент открыть палитру команд.
if parse(PALETTE_SHORTCUT).as_ref() == Some(shortcut) {
crate::window::show_main(app);
bridge::send(app, &WebMessage::OpenPalette);
}
}
/// Перерегистрирует комбинации после изменения настроек.
pub fn apply<R: Runtime>(app: &AppHandle<R>, settings: &Settings) -> Result<(), String> {
let shortcuts = app.global_shortcut();
shortcuts
.unregister_all()
.map_err(|error| format!("не удалось снять старые шорткаты: {error}"))?;
// Палитра команд регистрируется всегда и последней: если пользователь занял
// её комбинацию своим действием, второй раз её регистрировать нельзя —
// сработает то действие, чья проверка в `on_shortcut` стоит раньше.
let wanted = [
settings.ptt_shortcut.as_str(),
settings.mute_shortcut.as_str(),
settings.deafen_shortcut.as_str(),
PALETTE_SHORTCUT,
];
// Повторы отсекаются по разобранной комбинации: «Cmd+K» и «Command+K» —
// одна и та же клавиша, и вторая регистрация вернула бы ошибку.
let mut registered: Vec<Shortcut> = Vec::new();
for spec in wanted {
let trimmed = spec.trim();
if trimmed.is_empty() {
continue;
}
let Some(parsed) = parse(trimmed) else {
continue;
};
if registered.contains(&parsed) {
continue;
}
shortcuts
.register(trimmed)
.map_err(|error| format!("комбинация «{trimmed}» не зарегистрирована: {error}"))?;
registered.push(parsed);
}
Ok(())
}
/// Разбирает строку комбинации; пустая строка и мусор дают `None`.
fn parse(spec: &str) -> Option<Shortcut> {
let trimmed = spec.trim();
if trimmed.is_empty() {
return None;
}
trimmed.parse::<Shortcut>().ok()
}
+123
View File
@@ -0,0 +1,123 @@
//! Общее состояние процесса: настройки, готовность веб-клиента и очередь
//! сообщений, которые нужно передать в страницу.
use std::sync::Mutex;
use std::time::{Duration, Instant};
use crate::settings::Settings;
/// Уведомление, ожидающее активации приложения.
///
/// Клик по системному уведомлению на desktop не даёт колбэка
/// (`tauri-plugin-notification` его не предоставляет), поэтому обёртка
/// запоминает последнее уведомление, показанное при неактивном окне, и
/// открывает его канал, когда приложение активируется (см.
/// [`crate::notifications::open_pending`]).
#[derive(Debug, Clone)]
pub struct PendingNotification {
/// Сервер комнаты: `None` — личная беседа.
pub guild_id: Option<String>,
/// Комната, которую нужно открыть.
pub channel_id: String,
/// Момент показа уведомления: активация считается кликом, только если
/// прошло немного времени.
pub at: Instant,
}
/// Состояние, доступное командам, трею и обработчикам событий.
#[derive(Default)]
pub struct AppState {
inner: Mutex<Inner>,
}
#[derive(Default)]
struct Inner {
settings: Settings,
/// Веб-клиент сообщил, что загрузился и готов принимать события.
web_ready: bool,
/// Ссылки, пришедшие до готовности клиента (холодный старт deep link).
pending_deep_links: Vec<String>,
/// Последнее переданное в трей/док число непрочитанных.
badge: u32,
/// Уведомление, по которому ждём активации приложения.
pending_notification: Option<PendingNotification>,
/// В веб-клиенте есть несохранённые изменения (docs/client-tauri.md §5):
/// закрытие окна перехватывается и превращается в диалог «Сохранить /
/// Выйти без сохранения».
dirty: bool,
}
impl AppState {
/// Текущие настройки (копия).
pub fn settings(&self) -> Settings {
self.lock().settings.clone()
}
/// Заменяет настройки в памяти (запись в хранилище — отдельно).
pub fn set_settings(&self, settings: Settings) {
self.lock().settings = settings;
}
/// Отмечает веб-клиент готовым и отдаёт накопленные deep link.
pub fn mark_web_ready(&self) -> Vec<String> {
let mut inner = self.lock();
inner.web_ready = true;
std::mem::take(&mut inner.pending_deep_links)
}
/// Готов ли веб-клиент принимать сообщения.
pub fn web_ready(&self) -> bool {
self.lock().web_ready
}
/// Ставит deep link в очередь (если клиент ещё не готов).
pub fn push_deep_link(&self, url: String) {
let mut inner = self.lock();
if inner.pending_deep_links.len() < 16 {
inner.pending_deep_links.push(url);
}
}
/// Запоминает число непрочитанных и возвращает его.
pub fn set_badge(&self, count: u32) -> u32 {
let mut inner = self.lock();
inner.badge = count;
inner.badge
}
/// Запоминает уведомление, показанное при неактивном окне.
pub fn remember_notification(&self, guild_id: Option<String>, channel_id: String) {
self.lock().pending_notification = Some(PendingNotification {
guild_id,
channel_id,
at: Instant::now(),
});
}
/// Забирает уведомление, если с момента показа прошло не больше `max_age`.
///
/// Устаревшее уведомление отбрасывается: активация приложения через
/// несколько минут — это уже не клик по уведомлению.
pub fn take_notification(&self, max_age: Duration) -> Option<PendingNotification> {
match self.lock().pending_notification.take() {
Some(pending) if pending.at.elapsed() <= max_age => Some(pending),
_ => None,
}
}
/// Сообщает обёртке, есть ли в веб-клиенте несохранённые изменения.
pub fn set_dirty(&self, dirty: bool) {
self.lock().dirty = dirty;
}
/// Есть ли несохранённые изменения (нужно при перехвате закрытия окна).
pub fn dirty(&self) -> bool {
self.lock().dirty
}
fn lock(&self) -> std::sync::MutexGuard<'_, Inner> {
// Отравленный мьютекс не должен ронять приложение: состояние —
// это только настройки и очередь событий.
self.inner.lock().unwrap_or_else(|error| error.into_inner())
}
}
+163
View File
@@ -0,0 +1,163 @@
//! Иконка в трее: индикатор непрочитанных и меню управления обёрткой
//! (docs/client-tauri.md §2 — «открыть, mute, статус, выйти»).
use tauri::menu::{CheckMenuItem, Menu, MenuItem, PredefinedMenuItem};
use tauri::tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent};
use tauri::{AppHandle, Manager, Runtime};
use crate::state::AppState;
use crate::{autostart, commands, settings, shortcuts, updates, window};
/// Идентификатор иконки в трее.
pub const TRAY_ID: &str = "main";
/// Создаёт иконку в трее с меню по текущим настройкам.
pub fn build<R: Runtime>(app: &AppHandle<R>) -> Result<(), String> {
let icon = app
.default_window_icon()
.cloned()
.ok_or_else(|| "в сборке нет иконки приложения".to_string())?;
TrayIconBuilder::with_id(TRAY_ID)
.icon(icon)
.icon_as_template(true)
.tooltip("glchat")
.menu(&menu(app)?)
.show_menu_on_left_click(false)
.on_menu_event(on_menu_event)
.on_tray_icon_event(|tray, event| {
// Левый клик по иконке — показать окно, меню остаётся на правом.
if let TrayIconEvent::Click {
button: MouseButton::Left,
button_state: MouseButtonState::Up,
..
} = event
{
window::show_main(tray.app_handle());
}
})
.build(app)
.map_err(|error| format!("не удалось создать иконку в трее: {error}"))?;
Ok(())
}
/// Пересобирает меню: галочки должны совпадать с текущими настройками.
pub fn refresh<R: Runtime>(app: &AppHandle<R>) {
let Some(tray) = app.tray_by_id(TRAY_ID) else {
return;
};
match menu(app) {
Ok(menu) => {
if let Err(error) = tray.set_menu(Some(menu)) {
log::warn!("не удалось обновить меню трея: {error}");
}
}
Err(error) => log::warn!("не удалось собрать меню трея: {error}"),
}
}
/// Собирает меню трея по текущим настройкам.
fn menu<R: Runtime>(app: &AppHandle<R>) -> Result<Menu<R>, String> {
let current = app.state::<AppState>().settings();
let item = |id: &str, title: &str| -> Result<MenuItem<R>, String> {
MenuItem::with_id(app, id, title, true, None::<&str>)
.map_err(|error| format!("пункт меню «{title}»: {error}"))
};
let check = |id: &str, title: &str, checked: bool| -> Result<CheckMenuItem<R>, String> {
CheckMenuItem::with_id(app, id, title, true, checked, None::<&str>)
.map_err(|error| format!("пункт меню «{title}»: {error}"))
};
let open = item("open", "Открыть glchat")?;
let client = item("open-settings", "Настройки обёртки…")?;
let first_sep = PredefinedMenuItem::separator(app).map_err(|error| error.to_string())?;
let autostart_item = check("autostart", "Запускать при входе", current.autostart)?;
let close_to_tray = check("close-to-tray", "Закрывать в трей", current.close_to_tray)?;
let notifications = check("notifications", "Уведомления", current.notifications)?;
let update_item = item("updates", "Проверить обновления…")?;
let second_sep = PredefinedMenuItem::separator(app).map_err(|error| error.to_string())?;
let quit = item("quit", "Выход")?;
Menu::with_items(
app,
&[
&open,
&client,
&first_sep,
&autostart_item,
&close_to_tray,
&notifications,
&update_item,
&second_sep,
&quit,
],
)
.map_err(|error| format!("не удалось собрать меню трея: {error}"))
}
/// Обработчик пунктов меню трея.
fn on_menu_event<R: Runtime>(app: &AppHandle<R>, event: tauri::menu::MenuEvent) {
match event.id().as_ref() {
"open" => window::show_main(app),
"open-settings" => {
if let Err(error) = window::open_settings_page(app) {
log::warn!("{error}");
}
}
"autostart" => {
let mut current = app.state::<AppState>().settings();
current.autostart = !current.autostart;
persist(app, current);
}
"close-to-tray" => {
let mut current = app.state::<AppState>().settings();
current.close_to_tray = !current.close_to_tray;
persist(app, current);
}
"notifications" => {
let mut current = app.state::<AppState>().settings();
current.notifications = !current.notifications;
persist(app, current);
}
"updates" => {
let handle = app.clone();
tauri::async_runtime::spawn(async move {
updates::check(handle, true, false).await;
});
}
// Выход из трея — такой же выход, как закрытие окна: если в клиенте
// есть несохранённые правки, спрашиваем пользователя, а не теряем их.
"quit" => {
if !window::request_close_confirmation(app) {
app.exit(0);
}
}
other => log::debug!("неизвестный пункт меню трея: {other}"),
}
}
/// Применяет и сохраняет настройки, меняя только один переключатель.
fn persist<R: Runtime>(app: &AppHandle<R>, next: settings::Settings) {
let previous = app.state::<AppState>().settings();
app.state::<AppState>().set_settings(next.clone());
if let Err(error) = autostart::apply(app, next.autostart) {
log::warn!("{error}");
}
if next.ptt_shortcut != previous.ptt_shortcut
|| next.mute_shortcut != previous.mute_shortcut
|| next.deafen_shortcut != previous.deafen_shortcut
{
if let Err(error) = shortcuts::apply(app, &next) {
log::warn!("{error}");
}
}
if let Err(error) = settings::save(app, &next) {
log::warn!("не удалось сохранить настройки: {error}");
}
refresh(app);
commands::announce_settings(app, &next);
if next.instance_url != previous.instance_url {
if let Err(error) = window::open_instance_page(app) {
log::warn!("{error}");
}
}
}
+234
View File
@@ -0,0 +1,234 @@
//! Автообновление (docs/client-tauri.md §4).
//!
//! Манифест обновлений отдаёт сам инстанс: `GET /updates/{target}/{arch}/{current_version}`
//! (статика через Caddy). Артефакты подписаны ключом ed25519, подпись
//! проверяется до установки — публичный ключ задаётся в `UPDATER_PUBKEY`.
//!
//! Пока ключ не заведён (сборка без релизного контура), проверка обновлений
//! честно сообщает, что канал не настроен, вместо «суррогатного» обновления.
use tauri::{AppHandle, Manager, Runtime};
use tauri_plugin_dialog::{DialogExt, MessageDialogButtons};
use tauri_plugin_updater::UpdaterExt;
use crate::bridge::{self, WebMessage};
use crate::instance;
use crate::state::AppState;
/// Публичный ключ подписи обновлений (ed25519, формат minisign).
///
/// Приватный ключ в репозиторий не попадает: он живёт в CI-секретах
/// (`TAURI_SIGNING_PRIVATE_KEY`), а локально — в `build/desktop-keys/`
/// (каталог `build/` в .gitignore). Подпись обязательна: манифест без неё
/// плагин отклоняет, downgrade запрещён (docs/client-tauri.md §4, §6).
pub const UPDATER_PUBKEY: &str = "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDhCQkQ1NjkwN0EyQzZBOEEKUldTS2FpeDZrRmE5aXhqSThPWXA3am5YMnB5dmVWZWFEcVY4NGI3cG8yOTU1MjdtMW9sR09BNGIK";
/// Путь манифеста обновлений на инстансе.
const UPDATE_PATH: &str = "/updates/{{target}}/{{arch}}/{{current_version}}";
/// Проверяет обновления. `interactive` — вызов из меню трея или настроек,
/// тогда пользователь видит результат в диалоге; фоновая проверка молчит.
/// `auto_install` — тихий режим (`--update-now`): найденное обновление ставится
/// без вопроса, диалогов нет вовсе.
pub async fn check<R: Runtime>(app: AppHandle<R>, interactive: bool, auto_install: bool) {
if UPDATER_PUBKEY.is_empty() {
log::info!("автообновление не настроено: в сборке нет публичного ключа подписи");
if interactive {
app.dialog()
.message(
"В этой сборке нет публичного ключа подписи обновлений, поэтому проверка \
недоступна. Ключ и манифест обновлений появятся вместе с релизным контуром \
(docs/client-tauri.md §4).",
)
.title("Обновления не настроены")
.buttons(MessageDialogButtons::Ok)
.show(|_| {});
}
return;
}
let settings = app.state::<AppState>().settings();
let endpoint = match instance::normalize(&settings.instance_url) {
Ok(url) => format!("{}{UPDATE_PATH}", instance::display(&url)),
Err(error) => {
log::warn!("не удалось определить адрес инстанса для обновлений: {error}");
return;
}
};
let Ok(endpoint) = url::Url::parse(&endpoint) else {
log::warn!("некорректный адрес манифеста обновлений: {endpoint}");
return;
};
let updater = match app
.updater_builder()
.endpoints(vec![endpoint])
.and_then(|builder| builder.pubkey(UPDATER_PUBKEY).build())
{
Ok(updater) => updater,
Err(error) => {
log::warn!("не удалось собрать клиент обновлений: {error}");
return;
}
};
match updater.check().await {
Ok(Some(update)) => {
let version = update.version.clone();
log::info!("доступно обновление {version}");
// Обновление показываем в интерфейсе, установку спрашиваем отдельно:
// во время звонка перезапуск приложения недопустим без согласия.
bridge::send(
&app,
&WebMessage::UpdateAvailable {
version: version.clone(),
},
);
prompt_install(app, update, version, auto_install).await;
}
Ok(None) => {
log::info!("обновлений нет");
if interactive {
app.dialog()
.message("Установлена последняя версия.")
.title("Обновления")
.buttons(MessageDialogButtons::Ok)
.show(|_| {});
}
}
Err(error) => {
log::warn!("проверка обновлений не удалась: {error}");
if interactive {
let text = format!("Не удалось проверить обновления: {error}");
app.dialog()
.message(text)
.title("Обновления")
.buttons(MessageDialogButtons::Ok)
.show(|_| {});
}
}
}
}
/// Спрашивает согласие и устанавливает обновление (в тихом режиме — сразу).
async fn prompt_install<R: Runtime>(
app: AppHandle<R>,
update: tauri_plugin_updater::Update,
version: String,
auto_install: bool,
) {
let handle = app.clone();
if auto_install {
// Тихая установка: подпись артефакта проверяет плагин до распаковки —
// флаг убирает вопрос пользователю, но не проверку релиза.
log::info!(
"тихое обновление по флагу: {} → {version}",
app.package_info().version
);
} else {
let question = format!(
"Доступна версия {version}. Скачать и установить сейчас? Приложение будет перезапущено, \
активный голосовой звонок прервётся."
);
let (tx, rx) = std::sync::mpsc::channel::<bool>();
app.dialog()
.message(question)
.title("Обновление glchat")
.buttons(MessageDialogButtons::YesNo)
.show(move |confirmed| {
let _ = tx.send(confirmed);
});
// Диалог асинхронный: ждём ответ, не блокируя основной поток.
let confirmed = tauri::async_runtime::spawn_blocking(move || rx.recv().unwrap_or(false))
.await
.unwrap_or(false);
if !confirmed {
return;
}
}
match update
.download_and_install(|_chunk, _total| {}, || {})
.await
{
Ok(()) => {
log::info!("обновление {version} установлено, перезапуск");
handle.restart();
}
Err(error) => {
log::error!("установка обновления не удалась: {error}");
if !auto_install {
app.dialog()
.message(format!(
"Обновление не установлено: {error}. Скачать установщик вручную можно на \
странице инстанса."
))
.title("Обновление glchat")
.buttons(MessageDialogButtons::Ok)
.show(|_| {});
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
/// Манифест ровно в том виде, в каком его раскладывает
/// `scripts/desktop-release.sh` (проверяется вместе с серверной частью:
/// internal/server/updates.go).
const MANIFEST: &str = r#"{
"version": "0.2.0",
"notes": "Релиз glchat 0.2.0",
"pub_date": "2026-09-22T12:00:00Z",
"platforms": {
"darwin-aarch64": {
"signature": "dW50cnVzdGVkIGNvbW1lbnQ6IHNpZ25hdHVyZQo=",
"url": "https://gl.mhspx.su/updates/darwin/aarch64/glchat.app.tar.gz"
}
}
}"#;
/// Разбор манифеста идёт тем же типом, что использует плагин обновлений:
/// тест ловит расхождение формата (например, дату не в RFC 3339), из-за
/// которого обновление молча не приходило бы.
#[test]
fn манифест_разбирается_плагином_обновлений() {
let release: tauri_plugin_updater::RemoteRelease =
serde_json::from_str(MANIFEST).expect("манифест должен разбираться плагином");
assert_eq!(release.version.to_string(), "0.2.0");
assert!(release.pub_date.is_some(), "дата публикации не разобрана");
assert_eq!(
release
.download_url("darwin-aarch64")
.expect("ссылка на артефакт")
.as_str(),
"https://gl.mhspx.su/updates/darwin/aarch64/glchat.app.tar.gz"
);
assert_eq!(
release
.signature("darwin-aarch64")
.expect("подпись платформы"),
"dW50cnVzdGVkIGNvbW1lbnQ6IHNpZ25hdHVyZQo="
);
// Другая платформа в манифесте отсутствует — плагин сообщит об этом.
assert!(release.signature("windows-x86_64").is_err());
}
/// Адрес манифеста собирается по адресу инстанса и не содержит двойных слэшей.
#[test]
fn адрес_манифеста_собирается_из_адреса_инстанса() {
let url = instance::normalize("https://gl.mhspx.su/").expect("адрес инстанса");
let endpoint = format!("{}{UPDATE_PATH}", instance::display(&url));
assert_eq!(
endpoint,
"https://gl.mhspx.su/updates/{{target}}/{{arch}}/{{current_version}}"
);
assert!(
!UPDATER_PUBKEY.is_empty(),
"публичный ключ подписи должен быть вшит в сборку"
);
}
}
+213
View File
@@ -0,0 +1,213 @@
//! Главное окно приложения.
//!
//! Окно одно (docs/client-tauri.md §2): в нём либо веб-клиент инстанса, либо
//! локальная страница настроек обёртки. Вторых окон не заводим, повторный
//! запуск приложения фокусирует уже открытое окно.
use tauri::webview::PageLoadEvent;
use tauri::{AppHandle, Manager, Runtime, WebviewUrl, WebviewWindow, WebviewWindowBuilder};
use tauri_plugin_opener::OpenerExt;
use tauri_plugin_window_state::{StateFlags, WindowExt};
use crate::bridge::{self, WebMessage, WEB_WINDOW};
use crate::instance;
use crate::settings::Settings;
use crate::state::AppState;
/// Создаёт главное окно с клиентом инстанса.
pub fn create_main<R: Runtime>(
app: &AppHandle<R>,
settings: &Settings,
minimized: bool,
) -> Result<WebviewWindow<R>, String> {
let url = instance::normalize(&settings.instance_url)?;
let handle = app.clone();
let window = WebviewWindowBuilder::new(app, WEB_WINDOW, WebviewUrl::External(url))
.title("glchat")
.inner_size(1280.0, 800.0)
.min_inner_size(900.0, 600.0)
.visible(!minimized)
.on_navigation(move |target| navigation_guard(&handle, target))
.on_page_load(|_window, payload| {
// В логе видно, что именно открыто в окне: это первое, что нужно
// при разборе «приложение открылось, но клиента нет».
if payload.event() == PageLoadEvent::Finished {
log::info!("загружена страница: {}", payload.url());
}
})
.build()
.map_err(|error| format!("не удалось создать окно: {error}"))?;
// Геометрия окна переживает перезапуск (tauri-plugin-window-state).
let _ = window.restore_state(StateFlags::all());
Ok(window)
}
/// Показывает и фокусирует главное окно (повторный запуск, клик по трею).
pub fn show_main<R: Runtime>(app: &AppHandle<R>) {
if let Some(window) = app.get_webview_window(WEB_WINDOW) {
let _ = window.show();
let _ = window.unminimize();
let _ = window.set_focus();
}
}
/// Смещения нативной тряски окна: три колебания по 6 px и возврат в исходную
/// точку (docs/client-tauri.md §5 — «нативная тряска best-effort»).
const SHAKE_OFFSETS: [(i32, i32); 8] = [
(6, 0),
(-6, 0),
(6, 0),
(-6, 0),
(4, 0),
(-4, 0),
(2, 0),
(0, 0),
];
/// Покачивает окно при попытке закрыть приложение с несохранёнными изменениями.
///
/// Best-effort: на Wayland композитор запрещает программе двигать своё окно,
/// тогда остаётся только CSS-тряска контента в веб-клиенте. Движение идёт в
/// отдельном потоке с паузами, чтобы не блокировать обработчик события окна.
pub fn shake<R: Runtime>(app: &AppHandle<R>) {
let Some(window) = app.get_webview_window(WEB_WINDOW) else {
return;
};
let Ok(start) = window.outer_position() else {
return;
};
std::thread::spawn(move || {
for (dx, dy) in SHAKE_OFFSETS {
let position = tauri::PhysicalPosition::new(start.x + dx, start.y + dy);
if window.set_position(position).is_err() {
// Платформа не даёт двигать окно программно — молча выходим.
return;
}
std::thread::sleep(std::time::Duration::from_millis(20));
}
let _ = window.set_position(start);
});
}
/// Просит веб-клиент подтвердить выход, если есть несохранённые изменения.
///
/// Возвращает `true`, если выход отложен: клиент покажет тряску и диалог
/// «Сохранить / Выйти без сохранения», а окно останется открытым
/// (docs/client-tauri.md §5). Общий путь для закрытия окна и пункта «Выход» в
/// меню трея — иначе правки терялись бы при выходе из трея.
pub fn request_close_confirmation<R: Runtime>(app: &AppHandle<R>) -> bool {
if !app.state::<AppState>().dirty() {
return false;
}
log::info!("выход отложен: в клиенте есть несохранённые изменения");
show_main(app);
shake(app);
bridge::send(app, &WebMessage::CloseRequested);
true
}
/// Проверяет, загружен ли сейчас в окне клиент инстанса (а не страница настроек).
pub fn is_instance_page<R: Runtime>(app: &AppHandle<R>) -> bool {
let Some(window) = app.get_webview_window(WEB_WINDOW) else {
return false;
};
let Ok(current) = window.url() else {
return false;
};
let settings = app.state::<AppState>().settings();
let Ok(instance_url) = instance::normalize(&settings.instance_url) else {
return false;
};
instance::host(&current) == instance::host(&instance_url)
}
/// Открывает локальную страницу настроек обёртки.
pub fn open_settings_page<R: Runtime>(app: &AppHandle<R>) -> Result<(), String> {
let window = app
.get_webview_window(WEB_WINDOW)
.ok_or_else(|| "окно не создано".to_string())?;
window
.navigate(local_page_url("index.html"))
.map_err(|error| format!("не удалось открыть настройки: {error}"))?;
show_main(app);
Ok(())
}
/// Возвращает окно к клиенту инстанса.
pub fn open_instance_page<R: Runtime>(app: &AppHandle<R>) -> Result<(), String> {
let settings = app.state::<AppState>().settings();
let url = instance::normalize(&settings.instance_url)?;
let window = app
.get_webview_window(WEB_WINDOW)
.ok_or_else(|| "окно не создано".to_string())?;
window
.navigate(url)
.map_err(|error| format!("не удалось открыть клиент: {error}"))?;
show_main(app);
Ok(())
}
/// Адрес локальной страницы обёртки.
///
/// `WebviewUrl::App` резолвится Tauri во внутренний источник (`tauri://localhost`
/// на macOS/Linux, `http://tauri.localhost` на Windows), а `navigate` принимает
/// уже готовый URL — собираем его тем же правилом, что и сам Tauri.
fn local_page_url(path: &str) -> url::Url {
let base = if cfg!(any(windows, target_os = "android")) {
url::Url::parse("http://tauri.localhost").expect("базовый адрес локальной страницы")
} else {
url::Url::parse("tauri://localhost").expect("базовый адрес локальной страницы")
};
base.join(path).unwrap_or(base)
}
/// Перезагружает клиент инстанса (после смены адреса или по запросу).
pub fn reload_instance<R: Runtime>(app: &AppHandle<R>) -> Result<(), String> {
let window = app
.get_webview_window(WEB_WINDOW)
.ok_or_else(|| "окно не создано".to_string())?;
if is_instance_page(app) {
window
.eval("window.location.reload();")
.map_err(|error| error.to_string())?;
return Ok(());
}
open_instance_page(app)
}
/// Граница навигации: чужой адрес открывается в системном браузере.
fn navigation_guard<R: Runtime>(app: &AppHandle<R>, target: &url::Url) -> bool {
let settings = app.state::<AppState>().settings();
let host = instance::normalize(&settings.instance_url)
.map(|url| instance::host(&url))
.unwrap_or_default();
if instance::navigation_allowed(target, &host) {
return true;
}
log::info!("внешняя ссылка уходит в системный браузер: {target}");
let _ = app.opener().open_url(target.as_str(), None::<&str>);
false
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn тряска_возвращает_окно_на_место() {
// Смещения должны быть короткими и заканчиваться нулём: иначе окно
// «уезжает» от исходной позиции.
let (last_x, last_y) = SHAKE_OFFSETS[SHAKE_OFFSETS.len() - 1];
assert_eq!((last_x, last_y), (0, 0));
for (dx, dy) in SHAKE_OFFSETS {
assert!(
dx.abs() <= 8 && dy.abs() <= 8,
"слишком большое смещение: {dx},{dy}"
);
}
assert!(
SHAKE_OFFSETS.iter().any(|(dx, _)| *dx != 0),
"тряска без смещений ничего не показывает"
);
}
}
+53
View File
@@ -0,0 +1,53 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "glchat",
"version": "0.1.2",
"identifier": "su.mhspx.glchat",
"build": {
"frontendDist": "./ui"
},
"app": {
"withGlobalTauri": false,
"windows": [],
"security": {
"csp": "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self' ipc: http://ipc.localhost; object-src 'none'; base-uri 'self'; frame-ancestors 'none'"
}
},
"bundle": {
"active": true,
"targets": "all",
"createUpdaterArtifacts": true,
"category": "SocialNetworking",
"shortDescription": "glchat — self-hosted платформа общения",
"longDescription": "Desktop-клиент glchat: тот же веб-клиент в обёртке Tauri с нативными уведомлениями, треем, глобальным push-to-talk и автообновлением.",
"copyright": "glchat, AGPL-3.0-or-later",
"icon": [
"icons/32x32.png",
"icons/128x128.png",
"icons/128x128@2x.png",
"icons/icon.icns",
"icons/icon.ico"
],
"macOS": {
"minimumSystemVersion": "10.15"
}
},
"plugins": {
"updater": {
"endpoints": [
"https://gl.mhspx.su/updates/{{target}}/{{arch}}/{{current_version}}"
],
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDhCQkQ1NjkwN0EyQzZBOEEKUldTS2FpeDZrRmE5aXhqSThPWXA3am5YMnB5dmVWZWFEcVY4NGI3cG8yOTU1MjdtMW9sR09BNGIK",
"windows": {
"installMode": "passive"
}
},
"deep-link": {
"desktop": {
"schemes": [
"glchat"
]
}
}
}
}
+90
View File
@@ -0,0 +1,90 @@
<!doctype html>
<html lang="ru" data-theme="dark">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="color-scheme" content="dark light" />
<title>glchat — настройки приложения</title>
<link rel="stylesheet" href="settings.css" />
</head>
<body>
<main class="page">
<header class="head">
<h1>Настройки приложения</h1>
<p class="muted">
Это настройки desktop-обёртки. Настройки аккаунта, серверов и голоса живут внутри самого
клиента.
</p>
</header>
<section class="card">
<h2>Инстанс</h2>
<label class="field">
<span>Адрес клиента</span>
<input id="instance-url" type="text" spellcheck="false" placeholder="https://gl.mhspx.su" />
</label>
<p class="hint">
Тот же адрес, что открывается в браузере. Сторонние ссылки уходят в системный браузер, а
не открываются внутри окна.
</p>
<div class="row">
<button id="open-client" class="primary" type="button">Сохранить и открыть клиент</button>
<button id="reload-client" type="button">Перезагрузить клиент</button>
</div>
</section>
<section class="card">
<h2>Поведение</h2>
<label class="check">
<input id="autostart" type="checkbox" />
<span>Запускать при входе в систему (свёрнутым в трей)</span>
</label>
<label class="check">
<input id="close-to-tray" type="checkbox" />
<span>Закрытие окна сворачивает приложение в трей</span>
</label>
<label class="check">
<input id="notifications" type="checkbox" />
<span>Показывать системные уведомления об упоминаниях и личных сообщениях</span>
</label>
</section>
<section class="card">
<h2>Глобальные горячие клавиши</h2>
<p class="hint">
Работают, когда окно неактивно. Push-to-talk включает микрофон, пока клавиша удерживается:
в клиенте должен быть выбран режим микрофона «Push-to-talk» (Настройки → Голос).
</p>
<label class="field">
<span>Push-to-talk</span>
<select id="ptt-shortcut"></select>
</label>
<label class="field">
<span>Микрофон вкл/выкл</span>
<select id="mute-shortcut"></select>
</label>
<label class="field">
<span>Не слышу (deafen)</span>
<select id="deafen-shortcut"></select>
</label>
</section>
<section class="card">
<h2>Обновления</h2>
<p class="hint" id="updates-hint">
Обновления проверяются при запуске и вручную. Манифест отдаёт сам инстанс.
</p>
<div class="row">
<button id="check-updates" type="button">Проверить обновления</button>
<span class="muted" id="version"></span>
</div>
</section>
<footer class="foot">
<button id="save" class="primary" type="button">Сохранить</button>
<span id="status" class="status" role="status"></span>
</footer>
</main>
<script type="module" src="settings.js"></script>
</body>
</html>
+154
View File
@@ -0,0 +1,154 @@
:root {
--bg: #1a1b1e;
--panel: #232428;
--border: #33353b;
--fg: #e6e7ea;
--muted: #9a9ca3;
--accent: #4c5bd4;
--accent-fg: #ffffff;
}
@media (prefers-color-scheme: light) {
:root {
--bg: #f5f6f8;
--panel: #ffffff;
--border: #dcdee4;
--fg: #1c1d21;
--muted: #5f626b;
}
}
* {
box-sizing: border-box;
}
body {
margin: 0;
background: var(--bg);
color: var(--fg);
font:
14px/1.5 -apple-system,
"Segoe UI",
Roboto,
sans-serif;
}
.page {
max-width: 640px;
margin: 0 auto;
padding: 32px 24px 48px;
display: flex;
flex-direction: column;
gap: 20px;
}
.head h1 {
margin: 0 0 6px;
font-size: 22px;
}
.muted {
color: var(--muted);
}
.hint {
margin: 8px 0 0;
color: var(--muted);
font-size: 12.5px;
}
.card {
background: var(--panel);
border: 1px solid var(--border);
border-radius: 12px;
padding: 18px 20px;
}
.card h2 {
margin: 0 0 12px;
font-size: 15px;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--muted);
}
.field {
display: flex;
flex-direction: column;
gap: 6px;
margin-bottom: 12px;
}
.field > span {
font-weight: 500;
}
.check {
display: flex;
gap: 10px;
align-items: flex-start;
margin-bottom: 10px;
}
input[type='text'],
select {
background: var(--bg);
color: var(--fg);
border: 1px solid var(--border);
border-radius: 8px;
padding: 9px 11px;
font: inherit;
}
input[type='text']:focus,
select:focus {
outline: 2px solid var(--accent);
outline-offset: 1px;
}
.row {
display: flex;
gap: 10px;
align-items: center;
flex-wrap: wrap;
margin-top: 12px;
}
button {
background: var(--panel);
color: var(--fg);
border: 1px solid var(--border);
border-radius: 8px;
padding: 9px 14px;
font: inherit;
cursor: pointer;
}
button:hover {
border-color: var(--accent);
}
button.primary {
background: var(--accent);
border-color: var(--accent);
color: var(--accent-fg);
}
.foot {
display: flex;
align-items: center;
gap: 14px;
position: sticky;
bottom: 0;
padding: 14px 0 0;
background: linear-gradient(transparent, var(--bg) 40%);
}
.status {
color: var(--muted);
font-size: 13px;
}
.status.error {
color: #e06c75;
}
+165
View File
@@ -0,0 +1,165 @@
// Страница настроек desktop-обёртки.
//
// Локальная страница (tauri://localhost) внутри главного окна: команды обёртки
// вызываются через встроенный IPC Tauri. Ничего, кроме настроек платформы,
// здесь нет — настройки клиента живут в самом клиенте.
/** Вызов команды обёртки: тонкая обёртка над IPC Tauri v2. */
const invoke = (command, args) => window.__TAURI_INTERNALS__.invoke(command, args);
/** Варианты комбинаций: пустая строка — «выключено». */
const OFF = { value: '', label: 'Выключено' };
const PTT_OPTIONS = [
OFF,
{ value: 'CommandOrControl+Shift+Space', label: 'Cmd/Ctrl + Shift + Пробел' },
{ value: 'CommandOrControl+Shift+M', label: 'Cmd/Ctrl + Shift + M' },
{ value: 'CommandOrControl+Shift+V', label: 'Cmd/Ctrl + Shift + V' },
{ value: 'CommandOrControl+Shift+B', label: 'Cmd/Ctrl + Shift + B' },
{ value: 'Control+Shift+Space', label: 'Ctrl + Shift + Пробел' },
];
const MUTE_OPTIONS = [
OFF,
{ value: 'CommandOrControl+Shift+M', label: 'Cmd/Ctrl + Shift + M' },
{ value: 'CommandOrControl+Shift+U', label: 'Cmd/Ctrl + Shift + U' },
{ value: 'Control+Shift+M', label: 'Ctrl + Shift + M' },
];
const DEAFEN_OPTIONS = [
OFF,
{ value: 'CommandOrControl+Shift+D', label: 'Cmd/Ctrl + Shift + D' },
{ value: 'CommandOrControl+Shift+F', label: 'Cmd/Ctrl + Shift + F' },
{ value: 'Control+Shift+D', label: 'Ctrl + Shift + D' },
];
/** Поля формы и соответствующие настройки. */
const fields = {
instanceUrl: document.getElementById('instance-url'),
autostart: document.getElementById('autostart'),
closeToTray: document.getElementById('close-to-tray'),
notifications: document.getElementById('notifications'),
pttShortcut: document.getElementById('ptt-shortcut'),
muteShortcut: document.getElementById('mute-shortcut'),
deafenShortcut: document.getElementById('deafen-shortcut'),
};
const status = document.getElementById('status');
const version = document.getElementById('version');
const updatesHint = document.getElementById('updates-hint');
/** Заполняет выпадающий список комбинаций. */
function fillSelect(select, options, current) {
select.replaceChildren();
for (const option of options) {
const node = document.createElement('option');
node.value = option.value;
node.textContent = option.label;
select.append(node);
}
// Комбинация могла быть задана вручную (например, прежней версией) — не теряем её.
if (current && !options.some((option) => option.value === current)) {
const node = document.createElement('option');
node.value = current;
node.textContent = current;
select.append(node);
}
select.value = current ?? '';
}
/** Показывает состояние сохранения. */
function setStatus(text, isError = false) {
status.textContent = text;
status.classList.toggle('error', isError);
}
/** Переносит настройки в форму. */
function render(settings) {
fields.instanceUrl.value = settings.instanceUrl ?? '';
fields.autostart.checked = Boolean(settings.autostart);
fields.closeToTray.checked = Boolean(settings.closeToTray);
fields.notifications.checked = Boolean(settings.notifications);
fillSelect(fields.pttShortcut, PTT_OPTIONS, settings.pttShortcut);
fillSelect(fields.muteShortcut, MUTE_OPTIONS, settings.muteShortcut);
fillSelect(fields.deafenShortcut, DEAFEN_OPTIONS, settings.deafenShortcut);
}
/** Собирает настройки из формы. */
function collect() {
return {
instanceUrl: fields.instanceUrl.value.trim(),
autostart: fields.autostart.checked,
closeToTray: fields.closeToTray.checked,
notifications: fields.notifications.checked,
pttShortcut: fields.pttShortcut.value,
muteShortcut: fields.muteShortcut.value,
deafenShortcut: fields.deafenShortcut.value,
};
}
/** Сохраняет настройки и, если нужно, открывает клиент. */
async function save({ openClient = false } = {}) {
setStatus('Сохраняем…');
try {
const saved = await invoke('desktop_save_settings', { settings: collect() });
render(saved);
setStatus('Сохранено');
if (openClient) {
await invoke('desktop_open_instance');
}
} catch (error) {
setStatus(String(error), true);
}
}
document.getElementById('save').addEventListener('click', () => {
void save();
});
document.getElementById('open-client').addEventListener('click', () => {
void save({ openClient: true });
});
document.getElementById('reload-client').addEventListener('click', async () => {
await save();
try {
await invoke('desktop_reload');
} catch (error) {
setStatus(String(error), true);
}
});
document.getElementById('check-updates').addEventListener('click', async () => {
updatesHint.textContent = 'Проверяем обновления…';
try {
await invoke('desktop_check_updates');
updatesHint.textContent = 'Проверка завершена: результат показан в системном диалоге.';
} catch (error) {
updatesHint.textContent = String(error);
}
});
// Автосохранение переключателей и списков: отдельная кнопка нужна только для адреса.
for (const node of [
fields.autostart,
fields.closeToTray,
fields.notifications,
fields.pttShortcut,
fields.muteShortcut,
fields.deafenShortcut,
]) {
node.addEventListener('change', () => {
void save();
});
}
async function start() {
try {
render(await invoke('desktop_settings'));
version.textContent = `Версия обёртки: ${await invoke('desktop_version')}`;
} catch (error) {
setStatus(`Не удалось прочитать настройки: ${error}`, true);
}
}
void start();
+13 -1
View File
@@ -3,19 +3,31 @@ module glchat
go 1.26.0
require (
github.com/SherClockHolmes/webpush-go v1.4.0
github.com/coder/websocket v1.8.15
github.com/danielgtaylor/huma/v2 v2.39.1
github.com/fxamacker/cbor/v2 v2.9.4
github.com/go-chi/chi/v5 v5.3.2
github.com/go-webauthn/webauthn v0.18.2
github.com/mattn/go-sqlite3 v1.14.52
github.com/pquerna/otp v1.5.0
github.com/pressly/goose/v3 v3.28.0
golang.org/x/crypto v0.55.0
golang.org/x/crypto v0.57.0
golang.org/x/net v0.59.0
)
require (
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc // indirect
github.com/go-viper/mapstructure/v2 v2.5.0 // indirect
github.com/go-webauthn/x v0.3.1 // indirect
github.com/golang-jwt/jwt/v5 v5.3.1 // indirect
github.com/google/go-tpm v0.9.8 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/mfridman/interpolate v0.0.2 // indirect
github.com/philhofer/fwd v1.2.0 // indirect
github.com/sethvargo/go-retry v0.4.0 // indirect
github.com/tinylib/msgp v1.6.4 // indirect
github.com/x448/float16 v0.8.4 // indirect
go.uber.org/multierr v1.11.0 // indirect
golang.org/x/sync v0.22.0 // indirect
golang.org/x/sys v0.48.0 // indirect
+93 -2
View File
@@ -1,3 +1,5 @@
github.com/SherClockHolmes/webpush-go v1.4.0 h1:ocnzNKWN23T9nvHi6IfyrQjkIc0oJWv1B1pULsf9i3s=
github.com/SherClockHolmes/webpush-go v1.4.0/go.mod h1:XSq8pKX11vNV8MJEMwjrlTkxhAj1zKfxmyhdV7Pd6UA=
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc h1:biVzkmvwrH8WK8raXaxBx6fRVTlJILwEwQGL1I/ByEI=
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
github.com/coder/websocket v1.8.15 h1:6B2JPeOGlpff2Uz6vOEH1Vzpi0iUz20A+lPVhPHtNUA=
@@ -7,8 +9,24 @@ github.com/danielgtaylor/huma/v2 v2.39.1/go.mod h1:zcnQ38duIJ3VUHwFaBoZ6x8T+KN/m
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/fxamacker/cbor/v2 v2.9.4 h1:xwjVlxEMR3S605oUlgBjKLTTeGFciYPGYCtF/35LKGo=
github.com/fxamacker/cbor/v2 v2.9.4/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
github.com/go-chi/chi/v5 v5.3.2 h1:5YQkICvTCSZ25hoRsyJazN0scjzKGiu4VAUc7H1o1nY=
github.com/go-chi/chi/v5 v5.3.2/go.mod h1:R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutVsOCto=
github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro=
github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
github.com/go-webauthn/webauthn v0.18.2 h1:0BeftmEHU7i3Dv0VFwBtidy/ba37Vcdjvqst9EYu8Sk=
github.com/go-webauthn/webauthn v0.18.2/go.mod h1:hEXaOuLxvZ3zG9miZe3ehlyeVso9AtklXG+kTn36k+A=
github.com/go-webauthn/x v0.3.1 h1:1ff37z3XfmTTomkhlURgGizLIDyOvPgTt2t9nlzKLRo=
github.com/go-webauthn/x v0.3.1/go.mod h1:ZInxAynYXfBPvvm5gzKZ7geBlL23K71xASMgohHl/Rg=
github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo=
github.com/google/go-tpm v0.9.8/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY=
github.com/google/go-tpm-tools v0.3.13-0.20230620182252-4639ecce2aba h1:qJEJcuLzH5KDR0gKc0zcktin6KSAwL7+jWKBYceddTc=
github.com/google/go-tpm-tools v0.3.13-0.20230620182252-4639ecce2aba/go.mod h1:EFYHy8/1y2KfgTAsx7Luu7NGhoxtuVHnNo8jE7FikKc=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI=
@@ -19,6 +37,8 @@ github.com/mfridman/interpolate v0.0.2 h1:pnuTK7MQIxxFz1Gr+rjSIx9u7qVjf5VOoM/u6B
github.com/mfridman/interpolate v0.0.2/go.mod h1:p+7uk6oE07mpE/Ik1b8EckO0O4ZXiGAfshKBWLUM9Xg=
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM=
github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pquerna/otp v1.5.0 h1:NMMR+WrmaqXU4EzdGJEE1aUUI0AMRzsp96fFFWNPwxs=
github.com/pquerna/otp v1.5.0/go.mod h1:dkJfzwRKNiegxyNb54X/3fLwhCynbMspSyWKnvi1AEg=
@@ -32,16 +52,87 @@ github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE=
github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg=
github.com/tinylib/msgp v1.6.4 h1:mOwYbyYDLPj35mkA2BjjYejgJk9BuHxDdvRnb6v2ZcQ=
github.com/tinylib/msgp v1.6.4/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA=
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw=
go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg=
golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M=
golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M=
golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
golang.org/x/net v0.59.0 h1:5zfYln+w5XCxwrnMMJPufRgNoXEaGxl0wo5GqPXyues=
golang.org/x/net v0.59.0/go.mod h1:2DA/G1UfVbCpQPeWTmMPGY7Cs2PkBkwu743bVX5PIVg=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo=
golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
modernc.org/libc v1.75.6 h1:yKk8qo+Di4gkmvRboK8ocCqH22FiUCR6jRy2OwtCRus=
modernc.org/libc v1.75.6/go.mod h1:bO5o2ztHxBb2rjz0PgdHN0sSMw57CgxGFLZ3Qd/QpVQ=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
+478
View File
@@ -0,0 +1,478 @@
package auth
import (
"context"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"log/slog"
"strings"
"sync"
"time"
"github.com/go-webauthn/webauthn/protocol"
"github.com/go-webauthn/webauthn/webauthn"
"glchat/internal/config"
"glchat/internal/crypto"
"glchat/internal/store"
)
// Параметры passkeys (AGENT.md 7.1): несколько ключей на аккаунт, короткое
// окно церемонии, ограничение на число ключей.
const (
// passkeyCeremonyTTL — сколько живёт начатая церемония регистрации/входа.
passkeyCeremonyTTL = 5 * time.Minute
// maxPasskeysPerUser — предел числа ключей на аккаунт.
maxPasskeysPerUser = 10
// maxPasskeyNameLength — длина отображаемого имени ключа.
maxPasskeyNameLength = 64
)
var (
// ErrPasskeysDisabled — passkeys недоступны (домен инстанса не годится
// для RP ID): ручки отвечают понятной ошибкой, а не 500.
ErrPasskeysDisabled = errors.New("auth.passkeys_disabled")
// ErrPasskeyCeremony — церемония не найдена, истекла или уже использована.
ErrPasskeyCeremony = errors.New("auth.passkey_challenge_invalid")
// ErrPasskeyVerification — подпись или данные аутентификатора не прошли проверку.
ErrPasskeyVerification = errors.New("auth.passkey_verification_failed")
// ErrPasskeyUnknown — ключ не найден у этого аккаунта.
ErrPasskeyUnknown = errors.New("auth.passkey_unknown")
// ErrPasskeyNameInvalid — пустое или слишком длинное имя ключа.
ErrPasskeyNameInvalid = errors.New("auth.passkey_name_invalid")
// ErrPasskeyLimit — достигнут предел числа ключей на аккаунт.
ErrPasskeyLimit = errors.New("auth.passkey_limit")
)
// webAuthnUser — реализация webauthn.User поверх аккаунта glchat.
type webAuthnUser struct {
handle []byte
name string
displayName string
credentials []webauthn.Credential
}
func (u *webAuthnUser) WebAuthnID() []byte { return u.handle }
func (u *webAuthnUser) WebAuthnName() string { return u.name }
func (u *webAuthnUser) WebAuthnDisplayName() string { return u.displayName }
func (u *webAuthnUser) WebAuthnCredentials() []webauthn.Credential { return u.credentials }
// ceremony — начатая церемония WebAuthn: данные сессии библиотеки плюс
// привязка к аккаунту. Хранится в памяти процесса, живёт минуты и
// используется один раз (AGENT.md 9.1: инстанс — один процесс).
type ceremony struct {
session webauthn.SessionData
userID uint64
expiresAt time.Time
}
// ceremonyStore — церемонии WebAuthn с одноразовым использованием и TTL.
type ceremonyStore struct {
mu sync.Mutex
entries map[string]ceremony
now func() time.Time
}
func newCeremonyStore() *ceremonyStore {
return &ceremonyStore{entries: map[string]ceremony{}, now: func() time.Time { return time.Now().UTC() }}
}
func (c *ceremonyStore) put(id string, entry ceremony) {
c.mu.Lock()
defer c.mu.Unlock()
c.gcLocked()
c.entries[id] = entry
}
// take выдаёт церемонию и сразу удаляет её: повторная отправка того же
// challenge отклоняется (защита от replay).
func (c *ceremonyStore) take(id string) (ceremony, bool) {
c.mu.Lock()
defer c.mu.Unlock()
entry, ok := c.entries[id]
if !ok {
return ceremony{}, false
}
delete(c.entries, id)
if !entry.expiresAt.After(c.now()) {
return ceremony{}, false
}
return entry, true
}
func (c *ceremonyStore) gcLocked() {
if len(c.entries) < 64 {
return
}
now := c.now()
for id, entry := range c.entries {
if !entry.expiresAt.After(now) {
delete(c.entries, id)
}
}
}
// newWebAuthn собирает RP-конфигурацию из домена инстанса (AGENT.md 7.1:
// RP ID и Origin берутся из конфига домена). Непригодный домен (например,
// IP-адрес стенда) не роняет инстанс: passkeys просто выключены.
func newWebAuthn(cfg config.Config, logger loggerLike) *webauthn.WebAuthn {
rpID := strings.TrimSpace(cfg.Domain)
if rpID == "" {
return nil
}
instance, err := webauthn.New(&webauthn.Config{
RPID: rpID,
RPDisplayName: cfg.InstanceName,
RPOrigins: cfg.AllowedOrigins(),
})
if err != nil {
logger.Warn("passkeys are disabled: relying party configuration is invalid",
"domain", rpID, "error", err)
return nil
}
return instance
}
// loggerLike — минимум от *slog.Logger, нужный сборке RP-конфигурации.
type loggerLike interface {
Warn(msg string, args ...any)
}
// PasskeysEnabled сообщает, готовы ли passkeys на этом инстансе.
func (s *Service) PasskeysEnabled() bool { return s.webauthn != nil }
// PasskeyCeremonyTTL возвращает окно церемонии (для ответа клиенту).
func (s *Service) PasskeyCeremonyTTL() time.Duration { return passkeyCeremonyTTL }
// PasskeyRegistrationStart — результат начала регистрации ключа.
type PasskeyRegistrationStart struct {
Options *protocol.CredentialCreation
Ceremony string
ExpiresIn time.Duration
}
// PasskeyLoginStart — результат начала входа по ключу.
type PasskeyLoginStart struct {
Options *protocol.CredentialAssertion
Ceremony string
ExpiresIn time.Duration
}
// BeginPasskeyRegistration начинает регистрацию нового ключа для вошедшего
// пользователя: клиент получает options для navigator.credentials.create.
func (s *Service) BeginPasskeyRegistration(ctx context.Context, user *store.User) (*PasskeyRegistrationStart, error) {
if s.webauthn == nil {
return nil, ErrPasskeysDisabled
}
count, err := s.store.CountPasskeys(ctx, user.ID)
if err != nil {
return nil, fmt.Errorf("count passkeys: %w", err)
}
if count >= maxPasskeysPerUser {
return nil, ErrPasskeyLimit
}
waUser, err := s.webAuthnUser(ctx, user)
if err != nil {
return nil, err
}
// Регистрация только с «обнаруживаемым» ключом и обязательной проверкой
// пользователя: такие ключи годятся и для входа без ввода email.
options, session, err := s.webauthn.BeginRegistration(waUser,
webauthn.WithResidentKeyRequirement(protocol.ResidentKeyRequirementPreferred),
webauthn.WithAuthenticatorSelection(protocol.AuthenticatorSelection{
ResidentKey: protocol.ResidentKeyRequirementPreferred,
UserVerification: protocol.VerificationPreferred,
}),
webauthn.WithExclusions(webauthn.Credentials(waUser.credentials).CredentialDescriptors()),
)
if err != nil {
return nil, fmt.Errorf("begin passkey registration: %w", err)
}
ceremonyID, err := s.newCeremony(user.ID, *session)
if err != nil {
return nil, err
}
return &PasskeyRegistrationStart{Options: options, Ceremony: ceremonyID, ExpiresIn: passkeyCeremonyTTL}, nil
}
// FinishPasskeyRegistration проверяет ответ аутентификатора и сохраняет ключ.
func (s *Service) FinishPasskeyRegistration(ctx context.Context, user *store.User, ceremonyID, name string, body io.Reader) (*store.Passkey, error) {
if s.webauthn == nil {
return nil, ErrPasskeysDisabled
}
name = strings.TrimSpace(name)
if name == "" || len([]rune(name)) > maxPasskeyNameLength {
return nil, ErrPasskeyNameInvalid
}
parsed, err := protocol.ParseCredentialCreationResponseBody(body)
if err != nil {
return nil, fmt.Errorf("%w: %w", ErrPasskeyVerification, err)
}
entry, ok := s.ceremonies.take(ceremonyID)
if !ok || entry.userID != user.ID {
return nil, ErrPasskeyCeremony
}
waUser, err := s.webAuthnUser(ctx, user)
if err != nil {
return nil, err
}
credential, err := s.webauthn.CreateCredential(waUser, entry.session, parsed)
if err != nil {
return nil, fmt.Errorf("%w: %w", ErrPasskeyVerification, err)
}
if _, err := s.store.GetPasskeyByCredentialID(ctx, store.EncodeCredentialID(credential.ID)); err == nil {
// Тот же ключ уже зарегистрирован: не создаём дубль.
return nil, store.ErrConflict
} else if !errors.Is(err, store.ErrNotFound) {
return nil, err
}
encoded, err := json.Marshal(credential)
if err != nil {
return nil, fmt.Errorf("encode credential: %w", err)
}
transports := make([]string, 0, len(credential.Transport))
for _, transport := range credential.Transport {
transports = append(transports, string(transport))
}
passkey, err := s.store.CreatePasskey(ctx, store.CreatePasskeyParams{
UserID: user.ID,
CredentialID: store.EncodeCredentialID(credential.ID),
Name: name,
CredentialJSON: encoded,
SignCount: credential.Authenticator.SignCount,
Transports: strings.Join(transports, ","),
})
if err != nil {
return nil, err
}
_ = s.store.RecordSecurityEvent(ctx, &user.ID, "passkey_added", "", "", `{"name":`+quoteJSON(name)+`}`)
return passkey, nil
}
// RenamePasskey меняет имя ключа: чужой ключ недоступен (AGENT.md 7.1).
func (s *Service) RenamePasskey(ctx context.Context, userID, passkeyID uint64, name string) error {
name = strings.TrimSpace(name)
if name == "" || len([]rune(name)) > maxPasskeyNameLength {
return ErrPasskeyNameInvalid
}
return s.store.RenamePasskey(ctx, passkeyID, userID, name)
}
// DeletePasskey отзывает ключ доступа.
func (s *Service) DeletePasskey(ctx context.Context, userID, passkeyID uint64) error {
if err := s.store.DeletePasskey(ctx, passkeyID, userID); err != nil {
if errors.Is(err, store.ErrNotFound) {
return ErrPasskeyUnknown
}
return err
}
_ = s.store.RecordSecurityEvent(ctx, &userID, "passkey_removed", "", "", `{"passkey_id":"`+fmt.Sprint(passkeyID)+`"}`)
return nil
}
// ListPasskeys возвращает ключи пользователя.
func (s *Service) ListPasskeys(ctx context.Context, userID uint64) ([]store.Passkey, error) {
return s.store.ListPasskeys(ctx, userID)
}
// BeginPasskeyLogin начинает вход по ключу. Email необязателен: без него
// предлагаем выбрать любой «обнаруживаемый» ключ (passkey-сценарий), с ним —
// только ключи конкретного аккаунта.
func (s *Service) BeginPasskeyLogin(ctx context.Context, email string) (*PasskeyLoginStart, error) {
if s.webauthn == nil {
return nil, ErrPasskeysDisabled
}
var (
options *protocol.CredentialAssertion
session *webauthn.SessionData
userID uint64
err error
)
if strings.TrimSpace(email) == "" {
options, session, err = s.webauthn.BeginDiscoverableLogin(
webauthn.WithUserVerification(protocol.VerificationPreferred),
)
} else {
user, lookupErr := s.userByEmail(ctx, email)
if lookupErr != nil {
// Не раскрываем, существует ли аккаунт: церемония без ключей
// выглядит для клиента как обычный вход по ключу.
options, session, err = s.webauthn.BeginDiscoverableLogin(
webauthn.WithUserVerification(protocol.VerificationPreferred),
)
} else {
userID = user.ID
waUser, buildErr := s.webAuthnUser(ctx, user)
if buildErr != nil {
return nil, buildErr
}
options, session, err = s.webauthn.BeginLogin(waUser,
webauthn.WithUserVerification(protocol.VerificationPreferred),
)
}
}
if err != nil {
return nil, fmt.Errorf("begin passkey login: %w", err)
}
ceremonyID, err := s.newCeremony(userID, *session)
if err != nil {
return nil, err
}
return &PasskeyLoginStart{Options: options, Ceremony: ceremonyID, ExpiresIn: passkeyCeremonyTTL}, nil
}
// FinishPasskeyLogin проверяет подпись аутентификатора и выдаёт сессию.
func (s *Service) FinishPasskeyLogin(ctx context.Context, ceremonyID string, body io.Reader, ip, userAgent string) (*store.User, string, *store.Session, error) {
if s.webauthn == nil {
return nil, "", nil, ErrPasskeysDisabled
}
parsed, err := protocol.ParseCredentialRequestResponseBody(body)
if err != nil {
return nil, "", nil, fmt.Errorf("%w: %w", ErrPasskeyVerification, err)
}
entry, ok := s.ceremonies.take(ceremonyID)
if !ok {
return nil, "", nil, ErrPasskeyCeremony
}
// Ключ ищем по credential id: обработчик отдаёт пользователя, которому
// принадлежит ключ, — по нему библиотека и проверяет подпись.
passkey, err := s.store.GetPasskeyByCredentialID(ctx, store.EncodeCredentialID(parsed.RawID))
if err != nil {
_ = s.store.RecordSecurityEvent(ctx, nil, "passkey_login_failed", ip, userAgent, `{"reason":"unknown_credential"}`)
return nil, "", nil, ErrPasskeyUnknown
}
if entry.userID != 0 && entry.userID != passkey.UserID {
// Церемония начиналась для другого аккаунта.
_ = s.store.RecordSecurityEvent(ctx, &passkey.UserID, "passkey_login_failed", ip, userAgent, `{"reason":"ceremony_mismatch"}`)
return nil, "", nil, ErrPasskeyCeremony
}
user, err := s.store.GetUser(ctx, passkey.UserID)
if err != nil {
return nil, "", nil, err
}
if banned, err := s.store.IsInstanceBanned(ctx, user.ID); err != nil {
return nil, "", nil, err
} else if banned {
_ = s.store.RecordSecurityEvent(ctx, &user.ID, "login_banned", ip, userAgent, `{"method":"passkey"}`)
return nil, "", nil, ErrUserBanned
}
if err := s.checkLockout(ctx, user.ID); err != nil {
return nil, "", nil, err
}
waUser, err := s.webAuthnUser(ctx, user)
if err != nil {
return nil, "", nil, err
}
// Библиотека различает два сценария: церемония без user id — вход
// «обнаруживаемым» ключом (клиент выбирает аккаунт сам), с user id —
// вход по ключам конкретного аккаунта.
var (
validatedUser webauthn.User
credential *webauthn.Credential
)
if len(entry.session.UserID) > 0 {
credential, err = s.webauthn.ValidateLogin(waUser, entry.session, parsed)
validatedUser = waUser
} else {
validatedUser, credential, err = s.webauthn.ValidatePasskeyLogin(
func(rawID, _ []byte) (webauthn.User, error) {
if store.EncodeCredentialID(rawID) != passkey.CredentialID {
return nil, ErrPasskeyUnknown
}
return waUser, nil
}, entry.session, parsed)
}
if err != nil {
_ = s.store.RecordSecurityEvent(ctx, &user.ID, "passkey_login_failed", ip, userAgent, `{"reason":"verification"}`)
return nil, "", nil, fmt.Errorf("%w: %w", ErrPasskeyVerification, err)
}
if validatedUser == nil {
return nil, "", nil, ErrPasskeyVerification
}
// Обновляем запись credential: счётчик подписей и флаги бэкапа (клоны).
encoded, err := json.Marshal(credential)
if err != nil {
return nil, "", nil, fmt.Errorf("encode credential: %w", err)
}
if err := s.store.UpdatePasskeyUsage(ctx, passkey.ID, encoded, credential.Authenticator.SignCount); err != nil {
return nil, "", nil, err
}
token, session, err := s.createSession(ctx, user.ID, userAgent, ip)
if err != nil {
return nil, "", nil, err
}
_ = s.store.RecordSecurityEvent(ctx, &user.ID, "passkey_login", ip, userAgent,
`{"passkey_id":"`+fmt.Sprint(passkey.ID)+`"}`)
return user, token, session, nil
}
// webAuthnUser собирает webauthn.User: handle выводится из секрета инстанса,
// чтобы идентификатор аккаунта не утекал в аутентификатор открытым текстом.
func (s *Service) webAuthnUser(ctx context.Context, user *store.User) (*webAuthnUser, error) {
passkeys, err := s.store.ListPasskeys(ctx, user.ID)
if err != nil {
return nil, fmt.Errorf("list passkeys: %w", err)
}
credentials := make([]webauthn.Credential, 0, len(passkeys))
for _, passkey := range passkeys {
var credential webauthn.Credential
if err := json.Unmarshal(passkey.CredentialJSON, &credential); err != nil {
// Повреждённая запись не должна ломать вход остальными ключами.
s.logger.WarnContext(ctx, "пропускаем повреждённую запись passkey",
slog.Uint64("passkey_id", passkey.ID), slog.Any("error", err))
continue
}
credentials = append(credentials, credential)
}
name := user.Username
if user.DisplayName != "" {
name = user.DisplayName
}
return &webAuthnUser{
handle: s.passkeyHandle(user.ID),
name: user.Username,
displayName: name,
credentials: credentials,
}, nil
}
// passkeyHandle — стабильный непрозрачный user handle (16 байт HMAC).
func (s *Service) passkeyHandle(userID uint64) []byte {
index := s.masterKey.BlindIndex("webauthn:user:" + fmt.Sprint(userID))
raw, err := hex.DecodeString(index)
if err != nil || len(raw) < 16 {
return []byte(fmt.Sprint(userID))
}
return raw[:16]
}
// newCeremony регистрирует церемонию и возвращает её идентификатор.
func (s *Service) newCeremony(userID uint64, session webauthn.SessionData) (string, error) {
ceremonyID, _, err := crypto.NewSessionToken()
if err != nil {
return "", err
}
session.Expires = time.Now().UTC().Add(passkeyCeremonyTTL)
s.ceremonies.put(ceremonyID, ceremony{
session: session,
userID: userID,
expiresAt: session.Expires,
})
return ceremonyID, nil
}
// quoteJSON кодирует строку для подстановки в metadata_json.
func quoteJSON(value string) string {
encoded, err := json.Marshal(value)
if err != nil {
return `""`
}
return string(encoded)
}
+92 -2
View File
@@ -16,6 +16,8 @@ import (
"glchat/internal/crypto"
"glchat/internal/permissions"
"glchat/internal/store"
"github.com/go-webauthn/webauthn/webauthn"
)
var (
@@ -32,6 +34,9 @@ var (
ErrInstanceAdminTOTP = errors.New("auth.instance_admin_requires_2fa")
ErrNoTOTPSecret = errors.New("auth.totp_not_configured")
ErrTOTPAlreadyEnabled = errors.New("auth.totp_already_enabled")
// ErrUserBanned — глобальный бан инстанса (AGENT.md 7.18): вход запрещён,
// действующие сессии недействительны.
ErrUserBanned = errors.New("user.banned")
)
// Config — параметры сервиса аутентификации.
@@ -51,6 +56,12 @@ type Service struct {
policy *passwordPolicy
cfg Config
logger *slog.Logger
// webauthn — RP-конфигурация passkeys (nil, если домен не годится для RP ID).
webauthn *webauthn.WebAuthn
// ceremonies — начатые церемонии WebAuthn (в памяти, одноразовые).
ceremonies *ceremonyStore
// внешний адрес инстанса для redirect_uri, stateKey — ключ подписи state.
stateKey string
}
type instanceSettings interface {
@@ -99,7 +110,10 @@ func New(ctx context.Context, cfg config.Config, st *store.Store, logger *slog.L
sessionTTL = store.SessionTTL
}
return &Service{
// RP-конфигурация passkeys: включается конфигом, при
// отсутствии настроек соответствующие ручки отвечают понятной ошибкой.
service := &Service{
store: st,
hasher: hasher,
masterKey: masterKey,
@@ -108,7 +122,13 @@ func New(ctx context.Context, cfg config.Config, st *store.Store, logger *slog.L
policy: policy,
cfg: Config{SessionTTL: sessionTTL, MaxLoginFails: 5, LockoutWindow: 15 * time.Minute},
logger: logger,
}, nil
stateKey: cfg.SessionPepper,
ceremonies: newCeremonyStore(),
}
// Passkeys требуют корректного RP ID: для IP-адреса (стенд без домена)
// браузеры WebAuthn не работают, поэтому такой инстанс просто живёт без них.
service.webauthn = newWebAuthn(cfg, logger)
return service, nil
}
// argon2ParamsFromConfig проверяет границы параметров Argon2id из env.
@@ -210,6 +230,61 @@ func (s *Service) Register(ctx context.Context, in RegisterInput) (*store.User,
return user, token, session, nil
}
// CreateUserByOperator создаёт аккаунт по команде обслуживания (CLI
// create-user): регистрация на инстансе может быть выключена, а сессия
// создателю не нужна — он действует от имени администратора. Используется в
// том числе для подготовки нагрузки (AGENT.md 11.4).
func (s *Service) CreateUserByOperator(ctx context.Context, in RegisterInput) (*store.User, error) {
username := strings.TrimSpace(in.Username)
if err := crypto.ValidateUsername(username); err != nil {
return nil, ErrInvalidUsername
}
if err := s.policy.validate(in.Password); err != nil {
return nil, ErrWeakPassword
}
email := crypto.NormalizeEmail(in.Email)
if err := validateEmail(email); err != nil {
return nil, err
}
passwordHash, err := s.hasher.Hash(in.Password)
if err != nil {
return nil, fmt.Errorf("hash password: %w", err)
}
userID := s.store.NextID()
emailEncrypted, err := s.encryptEmail(userID, email)
if err != nil {
return nil, err
}
user, err := s.store.CreateUser(ctx, store.CreateUserParams{
ID: userID,
Username: username,
DisplayName: defaultDisplayName(in.DisplayName, username),
EmailEnc: emailEncrypted,
EmailIndex: s.masterKey.BlindIndex(email),
PasswordHash: passwordHash,
Locale: in.Locale,
})
if err != nil {
if errors.Is(err, store.ErrConflict) {
if existing, lookupErr := s.store.GetUserByUsername(ctx, username); lookupErr == nil && existing != nil {
return nil, ErrUsernameTaken
}
return nil, ErrEmailTaken
}
return nil, fmt.Errorf("create user: %w", err)
}
_ = s.store.RecordSecurityEvent(ctx, &user.ID, "created_by_operator", "", "", "")
return user, nil
}
// IssueSessionForOperator выдаёт сессию по команде обслуживания: нужна для
// нагрузочного теста (AGENT.md 11.4), где сотни клиентов не могут пройти вход
// через /auth/login — лимит попыток по IP рассчитан на защиту от перебора.
func (s *Service) IssueSessionForOperator(ctx context.Context, userID uint64, userAgent string) (string, error) {
token, _, err := s.createSession(ctx, userID, userAgent, "")
return token, err
}
// UserByEmail ищет аккаунт по email через blind index (без расшифровки БД).
func (s *Service) UserByEmail(ctx context.Context, email string) (*store.User, error) {
return s.userByEmail(ctx, email)
@@ -310,6 +385,15 @@ func (s *Service) Login(ctx context.Context, in LoginInput) (*store.User, string
return nil, "", nil, err
}
// Глобальный бан инстанса проверяется до пароля: причина отказа важнее
// (AGENT.md 7.18). Бан инстанс-админа невозможен, поэтому порядок безопасен.
if banned, err := s.store.IsInstanceBanned(ctx, user.ID); err != nil {
return nil, "", nil, err
} else if banned {
_ = s.store.RecordSecurityEvent(ctx, &user.ID, "login_banned", in.IP, in.UserAgent, "")
return nil, "", nil, ErrUserBanned
}
if err := s.hasher.Verify(in.Password, user.PasswordHash); err != nil {
_ = s.store.RecordSecurityEvent(ctx, &user.ID, "login_failed", in.IP, in.UserAgent, "")
return nil, "", nil, ErrInvalidCredentials
@@ -355,6 +439,12 @@ func (s *Service) ResolveSession(ctx context.Context, token string) (*store.User
if err != nil {
return nil, nil, ErrSessionExpired
}
// Бан инстанса обрывает уже выданные сессии: сессию удаляем, чтобы клиент
// не возвращался к ней повторно (AGENT.md 7.18).
if user.BannedAt != nil {
_ = s.store.DeleteSession(ctx, session.ID)
return nil, nil, ErrUserBanned
}
_ = s.store.TouchSession(ctx, session.ID)
return user, session, nil
}
+45
View File
@@ -459,3 +459,48 @@ func totpCode(t *testing.T, secret string) string {
}
return code
}
// TestCreateUserByOperator проверяет команду обслуживания: аккаунт создаётся
// даже при выключенной регистрации, дубликаты и слабые пароли отклоняются
// (AGENT.md 10.7, 11.4 — подготовка нагрузки).
func TestCreateUserByOperator(t *testing.T) {
ctx := context.Background()
service, st := newService(t)
if err := st.SetInstanceSetting(ctx, "registration_enabled", "false"); err != nil {
t.Fatalf("SetInstanceSetting: %v", err)
}
// Публичная регистрация закрыта.
if _, _, _, err := service.Register(ctx, auth.RegisterInput{
Username: "operator_blocked", Email: "blocked@example.com", Password: "correct-horse-battery",
}); !errors.Is(err, auth.ErrRegistrationOff) {
t.Fatalf("Register with registration off = %v, want ErrRegistrationOff", err)
}
user, err := service.CreateUserByOperator(ctx, auth.RegisterInput{
Username: "loadtest1", Email: "loadtest1@loadtest.local", Password: "correct-horse-battery",
})
if err != nil {
t.Fatalf("CreateUserByOperator: %v", err)
}
if user.Username != "loadtest1" || user.IsInstanceAdmin {
t.Fatalf("created user = %+v", user)
}
// Вход новым аккаунтом работает без выдачи сессии создателю.
if _, _, _, err := service.Login(ctx, auth.LoginInput{
Email: "loadtest1@loadtest.local", Password: "correct-horse-battery",
}); err != nil {
t.Fatalf("Login as created user: %v", err)
}
if _, err := service.CreateUserByOperator(ctx, auth.RegisterInput{
Username: "loadtest1", Email: "other@loadtest.local", Password: "correct-horse-battery",
}); !errors.Is(err, auth.ErrUsernameTaken) {
t.Fatalf("duplicate username = %v, want ErrUsernameTaken", err)
}
if _, err := service.CreateUserByOperator(ctx, auth.RegisterInput{
Username: "loadtest2", Email: "loadtest2@loadtest.local", Password: "short",
}); !errors.Is(err, auth.ErrWeakPassword) {
t.Fatalf("weak password = %v, want ErrWeakPassword", err)
}
}
+56 -6
View File
@@ -2,6 +2,7 @@ package config
import (
"fmt"
"net"
"os"
"strconv"
"strings"
@@ -15,12 +16,16 @@ const (
)
type Config struct {
Domain string
FilesDomain string
InstanceName string
ListenAddr string
DataDir string
WebRoot string
Domain string
FilesDomain string
InstanceName string
ListenAddr string
DataDir string
WebRoot string
// UpdatesDir — каталог с манифестами автообновления desktop-обёртки и
// артефактами релиза (docs/client-tauri.md §4). Наполняется релизным
// скриптом (scripts/desktop-release.sh), приложение только отдаёт файлы.
UpdatesDir string
DatabasePath string
SQLiteCache string
SQLiteMmap string
@@ -52,6 +57,20 @@ type Config struct {
BuildDate string
ShutdownTTL time.Duration
Maintenance time.Duration
// Retention (AGENT.md 6.4, Фаза 5): сколько хранить сессии, записи аудита и
// файлы без ссылок. Период проверки — RetainInterval.
RetainAuditDays int
RetainOrphanHours int
RetainInterval time.Duration
// Web Push (AGENT.md 7.16, Фаза 7): приватный VAPID-ключ (PKCS#8 DER в
// base64, генерирует установщик) и контакт администратора для push-сервисов
// (mailto:… или https://…). Пустой ключ — Web Push выключен.
VAPIDPrivateKey string
VAPIDSubject string
// Unfurl ссылок (AGENT.md Фаза 7): сервер сам загружает превью страниц по
// ссылкам из сообщений. Выключенный unfurl не ходит в интернет вовсе.
UnfurlEnabled bool
UnfurlTimeout time.Duration
}
func Load() (Config, error) {
@@ -62,6 +81,7 @@ func Load() (Config, error) {
ListenAddr: env("LISTEN_ADDR", DefaultListenAddr),
DataDir: env("DATA_DIR", DefaultDataDir),
WebRoot: env("WEB_ROOT", "/app/web"),
UpdatesDir: env("UPDATES_DIR", "/opt/glchat/updates"),
DatabasePath: env("DATABASE_PATH", ""),
SQLiteCache: env("SQLITE_CACHE_SIZE", "-8000"),
SQLiteMmap: env("SQLITE_MMAP_SIZE", "134217728"),
@@ -86,6 +106,15 @@ func Load() (Config, error) {
BuildDate: env("APP_BUILD_DATE", "unknown"),
ShutdownTTL: time.Duration(envInt("SHUTDOWN_TIMEOUT_SECONDS", 20)) * time.Second,
Maintenance: time.Duration(envInt("DB_MAINTENANCE_INTERVAL_SECONDS", 21600)) * time.Second,
RetainAuditDays: envInt("RETENTION_AUDIT_DAYS", 180),
RetainOrphanHours: envInt("RETENTION_ORPHAN_HOURS", 24),
RetainInterval: time.Duration(envInt("RETENTION_INTERVAL_SECONDS", 3600)) * time.Second,
VAPIDPrivateKey: env("VAPID_PRIVATE_KEY", ""),
VAPIDSubject: env("VAPID_SUBJECT", ""),
UnfurlEnabled: envBool("UNFURL_ENABLED", true),
UnfurlTimeout: time.Duration(envInt("UNFURL_TIMEOUT_SECONDS", 5)) * time.Second,
}
if cfg.FilesDomain == "" {
cfg.FilesDomain = "files." + cfg.Domain
@@ -132,6 +161,27 @@ func (c Config) VoiceEnabled() bool {
return c.LiveKitAPIKey != "" && c.LiveKitAPISecret != "" && c.LiveKitURL != ""
}
// WebPushEnabled сообщает, настроен ли Web Push: без VAPID-ключа подписки
// браузеров принимать некуда (AGENT.md 7.16).
func (c Config) WebPushEnabled() bool { return strings.TrimSpace(c.VAPIDPrivateKey) != "" }
// WebAuthnEnabled сообщает, годится ли домен инстанса как RP ID для passkeys.
// WebAuthn не работает на IP-адресах (исключение — localhost), поэтому стенд
// без домена честно отдаёт `passkeys_enabled: false`, а не ломает вход.
func (c Config) WebAuthnEnabled() bool {
host := strings.TrimSpace(c.Domain)
if host == "" {
return false
}
if parsed, _, err := net.SplitHostPort(host); err == nil {
host = parsed
}
if strings.EqualFold(host, "localhost") {
return true
}
return net.ParseIP(host) == nil
}
func (c Config) BaseURL() string { return c.Scheme() + "://" + c.Domain }
func (c Config) FilesURL() string { return c.Scheme() + "://" + c.FilesDomain }
+126
View File
@@ -0,0 +1,126 @@
package database
import (
"context"
"path/filepath"
"testing"
"github.com/pressly/goose/v3"
)
// channelColumns читает имена колонок таблицы channels.
func channelColumns(ctx context.Context, t *testing.T, db *DB) map[string]bool {
t.Helper()
columns := map[string]bool{}
rows, err := db.Reader.QueryContext(ctx, `SELECT name FROM pragma_table_info('channels')`)
if err != nil {
t.Fatalf("pragma_table_info('channels'): %v", err)
}
defer rows.Close()
for rows.Next() {
var name string
if err := rows.Scan(&name); err != nil {
t.Fatalf("scan: %v", err)
}
columns[name] = true
}
if err := rows.Err(); err != nil {
t.Fatalf("rows: %v", err)
}
return columns
}
// TestDMIconMigration проверяет миграцию 00024 на чистой базе (D-070, D-069):
// колонку icon_file_id у channels, внешний ключ на files с ON DELETE SET NULL,
// откат и повторный накат.
func TestDMIconMigration(t *testing.T) {
ctx := context.Background()
db, err := Open(ctx, Options{
Path: filepath.Join(t.TempDir(), "glchat.db"),
ReadPool: 2,
Migrate: true,
})
if err != nil {
t.Fatalf("Open() вернул ошибку: %v", err)
}
defer func() {
if err := db.Close(); err != nil {
t.Errorf("Close(): %v", err)
}
}()
version, err := db.SchemaVersion(ctx)
if err != nil {
t.Fatalf("SchemaVersion(): %v", err)
}
if version < 24 {
t.Fatalf("schema_version = %d, ожидалось >= 24", version)
}
if !channelColumns(ctx, t, db)["icon_file_id"] {
t.Fatal("в channels нет колонки icon_file_id")
}
// Внешний ключ: беседа ссылается на файл, удаление файла обнуляет ссылку.
if _, err := db.Writer.ExecContext(ctx,
`INSERT INTO users (id, username, username_lower, display_name, email_enc, email_index,
password_hash, locale, created_at, updated_at)
VALUES (42, 'dm_owner', 'dm_owner', 'Владелец', 'enc', 'idx', 'hash', 'ru',
'2026-01-01T00:00:00.000Z', '2026-01-01T00:00:00.000Z')`); err != nil {
t.Fatalf("вставка пользователя: %v", err)
}
if _, err := db.Writer.ExecContext(ctx,
`INSERT INTO files (id, filename, content_type, size_bytes, storage_path)
VALUES (700, 'icon.png', 'image/png', 10, '/tmp/icon.png')`); err != nil {
t.Fatalf("вставка файла: %v", err)
}
if _, err := db.Writer.ExecContext(ctx,
`INSERT INTO channels (id, guild_id, type, name, dm_owner_id, icon_file_id)
VALUES (701, NULL, 'dm', 'Группа', 42, 700)`); err != nil {
t.Fatalf("вставка канала: %v", err)
}
var iconID *int64
if err := db.Reader.QueryRowContext(ctx,
`SELECT icon_file_id FROM channels WHERE id = 701`).Scan(&iconID); err != nil {
t.Fatalf("чтение иконки: %v", err)
}
if iconID == nil || *iconID != 700 {
t.Fatalf("icon_file_id = %v, ожидалось 700", iconID)
}
if _, err := db.Writer.ExecContext(ctx, `DELETE FROM files WHERE id = 700`); err != nil {
t.Fatalf("удаление файла: %v", err)
}
iconID = nil
if err := db.Reader.QueryRowContext(ctx,
`SELECT icon_file_id FROM channels WHERE id = 701`).Scan(&iconID); err != nil {
t.Fatalf("чтение иконки после удаления файла: %v", err)
}
if iconID != nil {
t.Fatalf("после удаления файла icon_file_id = %v, ожидалось NULL", *iconID)
}
// Откат: goose знает версию 24, Down убирает колонку (D-069).
goose.SetBaseFS(migrationsFS)
goose.SetLogger(goose.NopLogger())
if err := goose.SetDialect("sqlite3"); err != nil {
t.Fatalf("SetDialect: %v", err)
}
if err := goose.DownToContext(ctx, db.Writer, "migrations", 23); err != nil {
t.Fatalf("откат миграции 00024: %v", err)
}
if channelColumns(ctx, t, db)["icon_file_id"] {
t.Fatal("после отката колонка icon_file_id осталась")
}
if version, err = db.SchemaVersion(ctx); err != nil {
t.Fatalf("SchemaVersion() после отката: %v", err)
} else if version >= 24 {
t.Fatalf("schema_version после отката = %d, ожидалось < 24", version)
}
// Повторный накат: миграция применима к уже откатанной базе.
if err := goose.UpContext(ctx, db.Writer, "migrations"); err != nil {
t.Fatalf("повторный накат: %v", err)
}
if !channelColumns(ctx, t, db)["icon_file_id"] {
t.Fatal("после повторного наката колонки icon_file_id нет")
}
}
@@ -0,0 +1,158 @@
package database
import (
"context"
"path/filepath"
"testing"
"github.com/pressly/goose/v3"
)
// TestGuildTemplatesMigration проверяет миграцию 00023 на чистой базе:
// таблица личных шаблонов, индекс владельца, внешние ключи (каскад по
// пользователю, обнуление сервера-источника) и откат миграции (AGENT.md 7.3).
func TestGuildTemplatesMigration(t *testing.T) {
ctx := context.Background()
db, err := Open(ctx, Options{
Path: filepath.Join(t.TempDir(), "glchat.db"),
ReadPool: 2,
Migrate: true,
})
if err != nil {
t.Fatalf("Open() вернул ошибку: %v", err)
}
defer func() {
if err := db.Close(); err != nil {
t.Errorf("Close(): %v", err)
}
}()
version, err := db.SchemaVersion(ctx)
if err != nil {
t.Fatalf("SchemaVersion(): %v", err)
}
if version < 23 {
t.Fatalf("schema_version = %d, ожидалось >= 23", version)
}
columns := map[string]bool{}
rows, err := db.Reader.QueryContext(ctx, `SELECT name FROM pragma_table_info('guild_templates')`)
if err != nil {
t.Fatalf("pragma_table_info: %v", err)
}
defer rows.Close()
for rows.Next() {
var name string
if err := rows.Scan(&name); err != nil {
t.Fatalf("scan: %v", err)
}
columns[name] = true
}
if err := rows.Err(); err != nil {
t.Fatalf("rows: %v", err)
}
for _, expected := range []string{
"id", "owner_id", "name", "description", "structure_json", "source_guild_id", "created_at",
} {
if !columns[expected] {
t.Errorf("в guild_templates нет колонки %q", expected)
}
}
var indexes int
if err := db.Reader.QueryRowContext(ctx,
`SELECT COUNT(*) FROM sqlite_master WHERE type = 'index' AND name = 'guild_templates_owner_idx'`,
).Scan(&indexes); err != nil {
t.Fatalf("чтение индекса: %v", err)
}
if indexes != 1 {
t.Error("индекс guild_templates_owner_idx не создан")
}
ownerID := int64(2000)
otherID := int64(2001)
insertUser := `INSERT INTO users (id, username, username_lower, display_name, email_enc, email_index,
password_hash, locale, created_at, updated_at)
VALUES (?, ?, ?, ?, 'enc', ?, 'hash', 'ru', '2026-01-01T00:00:00.000Z', '2026-01-01T00:00:00.000Z')`
if _, err := db.Writer.ExecContext(ctx, insertUser, ownerID, "tpl", "tpl", "Tpl", "idx-tpl"); err != nil {
t.Fatalf("вставка владельца: %v", err)
}
if _, err := db.Writer.ExecContext(ctx, insertUser, otherID, "tpl2", "tpl2", "Tpl2", "idx-tpl2"); err != nil {
t.Fatalf("вставка второго пользователя: %v", err)
}
guildID := int64(3000)
if _, err := db.Writer.ExecContext(ctx,
`INSERT INTO guilds (id, name, owner_id, created_at) VALUES (?, 'Источник', ?, '2026-01-01T00:00:00.000Z')`,
guildID, ownerID); err != nil {
t.Fatalf("вставка сервера: %v", err)
}
insertTemplate := `INSERT INTO guild_templates (id, owner_id, name, structure_json, source_guild_id, created_at)
VALUES (?, ?, 'Шаблон', '{"roles":[],"channels":[]}', ?, '2026-01-01T00:00:00.000Z')`
if _, err := db.Writer.ExecContext(ctx, insertTemplate, 1, ownerID, guildID); err != nil {
t.Fatalf("вставка шаблона: %v", err)
}
// Владелец обязателен: внешний ключ на users.
if _, err := db.Writer.ExecContext(ctx, insertTemplate, 2, 999999, nil); err == nil {
t.Fatal("шаблон без пользователя принят, ожидалась ошибка внешнего ключа")
}
// Удаление сервера-источника не удаляет шаблон, а лишь обнуляет ссылку.
if _, err := db.Writer.ExecContext(ctx, `DELETE FROM guilds WHERE id = ?`, guildID); err != nil {
t.Fatalf("удаление сервера: %v", err)
}
var source *int64
if err := db.Reader.QueryRowContext(ctx,
`SELECT source_guild_id FROM guild_templates WHERE id = 1`).Scan(&source); err != nil {
t.Fatalf("чтение шаблона: %v", err)
}
if source != nil {
t.Errorf("source_guild_id = %d, ожидалось NULL", *source)
}
// Удаление пользователя убирает его шаблоны.
if _, err := db.Writer.ExecContext(ctx, `DELETE FROM users WHERE id = ?`, ownerID); err != nil {
t.Fatalf("удаление владельца: %v", err)
}
var remaining int
if err := db.Reader.QueryRowContext(ctx, `SELECT COUNT(*) FROM guild_templates`).Scan(&remaining); err != nil {
t.Fatalf("подсчёт шаблонов: %v", err)
}
if remaining != 0 {
t.Errorf("после удаления владельца осталось шаблонов: %d", remaining)
}
// Откат: goose должен вернуть версию 22 и убрать таблицу.
goose.SetBaseFS(migrationsFS)
goose.SetLogger(goose.NopLogger())
if err := goose.SetDialect("sqlite3"); err != nil {
t.Fatalf("SetDialect: %v", err)
}
if err := goose.DownToContext(ctx, db.Writer, "migrations", 22); err != nil {
t.Fatalf("откат миграции: %v", err)
}
var tables int
if err := db.Reader.QueryRowContext(ctx,
`SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = 'guild_templates'`,
).Scan(&tables); err != nil {
t.Fatalf("чтение sqlite_master: %v", err)
}
if tables != 0 {
t.Error("после отката таблица guild_templates осталась")
}
if version, err := db.SchemaVersion(ctx); err != nil {
t.Fatalf("SchemaVersion после отката: %v", err)
} else if version != 22 {
t.Errorf("после отката schema_version = %d, ожидалось 22", version)
}
// Повторное применение возвращает таблицу: миграция идемпотентна по goose.
if err := db.Migrate(ctx); err != nil {
t.Fatalf("повторная миграция: %v", err)
}
if err := db.Reader.QueryRowContext(ctx,
`SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = 'guild_templates'`,
).Scan(&tables); err != nil {
t.Fatalf("чтение sqlite_master: %v", err)
}
if tables != 1 {
t.Error("после повторного применения таблица guild_templates не создана")
}
}
@@ -0,0 +1,15 @@
-- +goose Up
-- Глобальные лимиты медиа (AGENT.md 7.7): меняются в админ-панели инстанса и
-- действуют на все сервера. Вложения регулируются профилем производительности
-- (MAX_UPLOAD_SIZE), поэтому здесь их нет.
INSERT INTO instance_settings (key, value) VALUES
('max_avatar_size', '5242880'),
('max_guild_image_size', '5242880'),
('max_emoji_size', '524288'),
('max_sound_size', '524288'),
('max_cosmetic_size', '5242880')
ON CONFLICT (key) DO NOTHING;
-- +goose Down
DELETE FROM instance_settings WHERE key IN
('max_avatar_size', 'max_guild_image_size', 'max_emoji_size', 'max_sound_size', 'max_cosmetic_size');
@@ -0,0 +1,14 @@
-- +goose Up
-- Глобальные баны инстанса (AGENT.md 7.18, 7.19): администратор инстанса
-- блокирует аккаунт целиком, а не участие в отдельном сервере. Отдельная
-- таблица, а не колонка в users: причина и автор бана нужны для аудита, а
-- разбан должен сохранять историю (запись удаляется вместе с аккаунтом).
CREATE TABLE instance_bans (
user_id INTEGER PRIMARY KEY REFERENCES users (id) ON DELETE CASCADE,
actor_id INTEGER REFERENCES users (id) ON DELETE SET NULL,
reason TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
);
-- +goose Down
DROP TABLE instance_bans;
@@ -0,0 +1,36 @@
-- +goose Up
-- Фаза 7: passkeys (WebAuthn), AGENT.md 7.1.
--
-- Таблица webauthn_credentials создана в 00002 «на вырост» и до Фазы 7 не
-- использовалась ни одной строкой кода, поэтому пересобираем её в том виде,
-- который нужен рабочей реализации: полная запись credential хранится в JSON
-- (credential_json) — так структура не разъезжается с версиями библиотеки
-- go-webauthn, а отдельные колонки нужны только для списка ключей.
-- PII здесь нет: имя ключа задаёт сам пользователь.
DROP TABLE webauthn_credentials;
CREATE TABLE webauthn_credentials (
id INTEGER PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users (id) ON DELETE CASCADE,
credential_id TEXT NOT NULL UNIQUE,
name TEXT NOT NULL DEFAULT '',
credential_json TEXT NOT NULL,
sign_count INTEGER NOT NULL DEFAULT 0,
transports TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
last_used_at TEXT
);
CREATE INDEX webauthn_credentials_user_idx ON webauthn_credentials (user_id);
-- +goose Down
DROP TABLE webauthn_credentials;
CREATE TABLE webauthn_credentials (
id INTEGER PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users (id) ON DELETE CASCADE,
credential_id TEXT NOT NULL UNIQUE,
public_key BLOB NOT NULL,
sign_count INTEGER NOT NULL DEFAULT 0,
transports TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
);
@@ -0,0 +1,27 @@
-- +goose Up
-- Фаза 7: OAuth-провайдеры (AGENT.md 7.1).
--
-- Связь внешнего аккаунта с пользователем инстанса. Внешний идентификатор
-- (subject) хранится только как blind index (HMAC-SHA-256 от
-- "provider:subject"), email — тоже индексом: сырых PII в таблице нет
-- (AGENT.md 9.2). Токены провайдеров не сохраняются вовсе: они нужны только
-- на время обмена кода на профиль.
--
-- `IF NOT EXISTS` — страховка от частично применённой миграции: на стенде
-- таблицу успела создать промежуточная редакция 00018 (в ней обе таблицы
-- жили вместе), и без этого инстанс не поднимался после обновления.
CREATE TABLE IF NOT EXISTS oauth_accounts (
id INTEGER PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users (id) ON DELETE CASCADE,
provider TEXT NOT NULL,
subject_index TEXT NOT NULL,
email_index TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
last_login_at TEXT,
UNIQUE (provider, subject_index)
);
CREATE INDEX IF NOT EXISTS oauth_accounts_user_idx ON oauth_accounts (user_id);
-- +goose Down
DROP TABLE IF EXISTS oauth_accounts;
@@ -0,0 +1,13 @@
-- +goose Up
-- Фаза 7: групповые личные беседы (AGENT.md 7.8).
--
-- Личная беседа на несколько участников живёт в тех же таблицах, что и 1:1
-- (channels.type = 'dm' + dm_participants), поэтому нужен только владелец:
-- он добавляет и удаляет участников и переименовывает беседу. Когда
-- участников остаётся двое, владелец сбрасывается — беседа снова обычная
-- личная (см. internal/store/dm_groups.go).
ALTER TABLE channels ADD COLUMN dm_owner_id INTEGER REFERENCES users (id) ON DELETE SET NULL;
-- +goose Down
ALTER TABLE channels DROP COLUMN dm_owner_id;
@@ -0,0 +1,29 @@
-- +goose Up
-- Фаза 7: Web Push (AGENT.md 7.16).
--
-- Подписка браузера (Push API) живёт на устройстве: эндпоинт выдаёт push-сервис
-- (FCM/Mozilla/Apple), ключи p256dh/auth шифруют payload по RFC 8291. Один
-- пользователь может подписать несколько устройств, поэтому уникален эндпоинт,
-- а не пользователь: повторная подписка с того же устройства обновляет запись.
--
-- Счётчик неудач и время последнего успеха нужны, чтобы чистить мёртвые
-- подписки: push-сервис отвечает 404/410 на отозванные, а временные сбои
-- (5xx, 429) не должны удалять запись сразу.
CREATE TABLE push_subscriptions (
id INTEGER PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users (id) ON DELETE CASCADE,
endpoint TEXT NOT NULL UNIQUE,
p256dh TEXT NOT NULL,
auth TEXT NOT NULL,
user_agent TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL,
last_success_at TEXT,
last_failure_at TEXT,
failure_count INTEGER NOT NULL DEFAULT 0
);
CREATE INDEX push_subscriptions_user_idx ON push_subscriptions (user_id);
-- +goose Down
DROP TABLE push_subscriptions;
@@ -0,0 +1,22 @@
-- +goose Up
-- Фаза 7: кэш серверных превью ссылок (unfurl).
--
-- Ключ — sha256 (hex) от нормализованного URL: сам URL длинный и с query,
-- а сравнивать нужно каноническую форму (internal/unfurl.Normalize).
-- status: ok | empty | blocked | error. Истёкшие записи не отдаются
-- (expires_at > now) и удаляются фоновой уборкой.
CREATE TABLE link_previews (
url_hash TEXT PRIMARY KEY,
url TEXT NOT NULL,
title TEXT NOT NULL DEFAULT '',
description TEXT NOT NULL DEFAULT '',
site_name TEXT NOT NULL DEFAULT '',
image_url TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL,
fetched_at TEXT NOT NULL,
expires_at TEXT NOT NULL
);
CREATE INDEX link_previews_expires_idx ON link_previews (expires_at);
-- +goose Down
DROP TABLE link_previews;
@@ -0,0 +1,23 @@
-- +goose Up
-- Фаза 7: личные шаблоны серверов (AGENT.md 7.3).
--
-- Шаблон — приватная сущность пользователя: он виден и используется только
-- владельцем (owner_id), поэтому у таблицы нет guild_id и связанных с ним
-- прав. structure_json хранит структуру в том же виде, что внутренний
-- guildTemplate: роли с цветом и правами, категории и комнаты. source_guild_id
-- — сервер, с которого структура снята; при удалении сервера ссылка
-- обнуляется, а сам шаблон остаётся рабочим. Вместе с пользователем его
-- шаблоны удаляются каскадом.
CREATE TABLE guild_templates (
id INTEGER PRIMARY KEY,
owner_id INTEGER NOT NULL REFERENCES users (id) ON DELETE CASCADE,
name TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '',
structure_json TEXT NOT NULL,
source_guild_id INTEGER REFERENCES guilds (id) ON DELETE SET NULL,
created_at TEXT NOT NULL
);
CREATE INDEX guild_templates_owner_idx ON guild_templates (owner_id, id DESC);
-- +goose Down
DROP TABLE guild_templates;
@@ -0,0 +1,12 @@
-- +goose Up
-- Фаза 7: иконка групповой беседы (AGENT.md 7.8, D-070).
--
-- Иконка — обычная загрузка (D-042): содержимое лежит в files, а канал
-- ссылается на запись. Колонка одна на все каналы, но заполняется только у
-- групповых бесед (channels.type = 'dm' с владельцем): у 1:1 иконка — аватар
-- собеседника, у комнат сервера — своё оформление.
-- ON DELETE SET NULL: удаление файла не должно ломать беседу.
ALTER TABLE channels ADD COLUMN icon_file_id INTEGER REFERENCES files (id) ON DELETE SET NULL;
-- +goose Down
ALTER TABLE channels DROP COLUMN icon_file_id;
@@ -0,0 +1,10 @@
-- +goose Up
-- Поиск серверов в админ-панели инстанса без учёта регистра (AGENT.md 7.18):
-- SQLite lower() не знает кириллицу, поэтому нормализованную копию названия
-- приводит приложение — тем же приёмом, что и текст сообщений (00007).
-- Значение заполняется при создании и переименовании сервера, а для старых
-- записей — разовым backfill при старте (store.BackfillGuildSearchIndex).
ALTER TABLE guilds ADD COLUMN name_lower TEXT NOT NULL DEFAULT '';
-- +goose Down
ALTER TABLE guilds DROP COLUMN name_lower;
@@ -0,0 +1,51 @@
-- +goose Up
-- Фаза 7: звонки в личных и групповых беседах (AGENT.md 7.8, 7.14).
--
-- Звонок — отдельная сущность беседы: у него своя комната LiveKit, своё
-- состояние участников и своя история (кто звонил, когда, сколько длился,
-- кто пропустил). В voice_states звонок не помещается: там guild_id
-- обязателен, а у личной беседы сервера нет.
--
-- Статусы звонка (dm_calls.status): ringing — идут гудки, active — хотя бы
-- двое в комнате, ended — завершён. Состояния участника
-- (dm_call_participants.state): invited — приглашён и ещё не ответил,
-- joined — в звонке, declined — отклонил, left — вышел, missed — не ответил.
CREATE TABLE dm_calls (
id INTEGER PRIMARY KEY,
channel_id INTEGER NOT NULL REFERENCES channels (id) ON DELETE CASCADE,
initiator_id INTEGER NOT NULL REFERENCES users (id) ON DELETE CASCADE,
status TEXT NOT NULL,
-- media: audio | video — чем открыли звонок, клиент показывает это в истории.
media TEXT NOT NULL DEFAULT 'audio',
created_at TEXT NOT NULL,
-- started_at — момент, когда звонок стал active: от него считается
-- длительность разговора, а не от гудков.
started_at TEXT,
ended_at TEXT,
ended_by INTEGER REFERENCES users (id) ON DELETE SET NULL,
-- end_reason: completed | canceled | declined | missed | failed.
end_reason TEXT
);
CREATE INDEX dm_calls_channel_idx ON dm_calls (channel_id, id DESC);
-- В беседе одновременно идёт не больше одного звонка: гонку двух «позвонить»
-- закрывает сама база, а не проверка в приложении.
CREATE UNIQUE INDEX dm_calls_live_channel_idx ON dm_calls (channel_id) WHERE status <> 'ended';
CREATE TABLE dm_call_participants (
call_id INTEGER NOT NULL REFERENCES dm_calls (id) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES users (id) ON DELETE CASCADE,
state TEXT NOT NULL,
invited_at TEXT NOT NULL,
joined_at TEXT,
left_at TEXT,
self_mute INTEGER NOT NULL DEFAULT 0,
self_deaf INTEGER NOT NULL DEFAULT 0,
camera INTEGER NOT NULL DEFAULT 0,
screen INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (call_id, user_id)
);
CREATE INDEX dm_call_participants_user_idx ON dm_call_participants (user_id, call_id DESC);
-- +goose Down
DROP TABLE dm_call_participants;
DROP TABLE dm_calls;
@@ -0,0 +1,28 @@
-- +goose Up
-- Вход через внешние провайдеры (OAuth) удалён по решению владельца
-- 27.09.2026: самодостаточному инстансу «на своём сервере» он не нужен, а
-- оператору пришлось бы заводить отдельное приложение у каждого провайдера
-- (свой client id/secret, свои redirect URI, модерация) — слишком много
-- трения ради способа входа, который дублирует пароль и ключи доступа.
-- Решение и разбор — D-084.
--
-- Таблица осталась от Фазы 7 (миграция 00019, её не переписываем: она уже
-- применена на стендах), кода, который её читает, больше нет. Данные в ней
-- могли быть только у инсталляций, которые реально включали OAuth; на наших
-- стендах провайдеры ни разу не настраивались, поэтому удаление безопасно.
DROP TABLE IF EXISTS oauth_accounts;
-- +goose Down
-- Откат возвращает структуру таблицы (пустую): сам код входа лежит в истории
-- git, повторное включение потребует и его, и новые ключи провайдеров.
CREATE TABLE IF NOT EXISTS oauth_accounts (
id INTEGER PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users (id) ON DELETE CASCADE,
provider TEXT NOT NULL,
subject_index TEXT NOT NULL,
email_index TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
last_login_at TEXT,
UNIQUE (provider, subject_index)
);
CREATE INDEX IF NOT EXISTS oauth_accounts_user_idx ON oauth_accounts (user_id);
@@ -0,0 +1,54 @@
package database
import (
"context"
"path/filepath"
"testing"
"github.com/pressly/goose/v3"
)
// tableExists сообщает, есть ли таблица в схеме.
func tableExists(ctx context.Context, t *testing.T, db *DB, name string) bool {
t.Helper()
var count int
if err := db.Reader.QueryRowContext(ctx,
`SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = ?`, name).Scan(&count); err != nil {
t.Fatalf("sqlite_master(%s): %v", name, err)
}
return count > 0
}
// TestOAuthRemovalMigration проверяет миграцию 00027 (D-084): вход через
// внешние провайдеры удалён, таблица связи внешних аккаунтов уходит из схемы.
// Откат возвращает структуру (пустую) — код входа лежит в истории git.
func TestOAuthRemovalMigration(t *testing.T) {
ctx := context.Background()
db, err := Open(ctx, Options{
Path: filepath.Join(t.TempDir(), "glchat.db"),
ReadPool: 2,
Migrate: true,
})
if err != nil {
t.Fatalf("open: %v", err)
}
defer func() { _ = db.Close() }()
if tableExists(ctx, t, db, "oauth_accounts") {
t.Fatal("после миграций таблицы oauth_accounts быть не должно")
}
// Откат одной миграции возвращает таблицу, повторный накат снова убирает.
if err := goose.DownToContext(ctx, db.Writer, "migrations", 26); err != nil {
t.Fatalf("down to 26: %v", err)
}
if !tableExists(ctx, t, db, "oauth_accounts") {
t.Fatal("откат 00027 должен вернуть структуру oauth_accounts")
}
if err := goose.UpContext(ctx, db.Writer, "migrations"); err != nil {
t.Fatalf("up: %v", err)
}
if tableExists(ctx, t, db, "oauth_accounts") {
t.Fatal("повторный накат 00027 должен снова удалить oauth_accounts")
}
}
+104
View File
@@ -0,0 +1,104 @@
package database
import (
"context"
"path/filepath"
"testing"
)
// TestPushSubscriptionsMigration проверяет миграцию 00021 на чистой базе:
// таблица подписок Web Push, уникальность эндпоинта, внешний ключ на users и
// индекс по пользователю (AGENT.md 7.16, Фаза 7).
func TestPushSubscriptionsMigration(t *testing.T) {
ctx := context.Background()
db, err := Open(ctx, Options{
Path: filepath.Join(t.TempDir(), "glchat.db"),
ReadPool: 2,
Migrate: true,
})
if err != nil {
t.Fatalf("Open() вернул ошибку: %v", err)
}
defer func() {
if err := db.Close(); err != nil {
t.Errorf("Close(): %v", err)
}
}()
version, err := db.SchemaVersion(ctx)
if err != nil {
t.Fatalf("SchemaVersion(): %v", err)
}
if version < 21 {
t.Fatalf("schema_version = %d, ожидалось >= 21", version)
}
columns := map[string]bool{}
rows, err := db.Reader.QueryContext(ctx, `SELECT name FROM pragma_table_info('push_subscriptions')`)
if err != nil {
t.Fatalf("pragma_table_info: %v", err)
}
defer rows.Close()
for rows.Next() {
var name string
if err := rows.Scan(&name); err != nil {
t.Fatalf("scan: %v", err)
}
columns[name] = true
}
if err := rows.Err(); err != nil {
t.Fatalf("rows: %v", err)
}
for _, expected := range []string{
"id", "user_id", "endpoint", "p256dh", "auth", "user_agent",
"created_at", "last_success_at", "last_failure_at", "failure_count",
} {
if !columns[expected] {
t.Errorf("в push_subscriptions нет колонки %q", expected)
}
}
var indexes int
if err := db.Reader.QueryRowContext(ctx,
`SELECT COUNT(*) FROM sqlite_master WHERE type = 'index' AND name = 'push_subscriptions_user_idx'`,
).Scan(&indexes); err != nil {
t.Fatalf("чтение индекса: %v", err)
}
if indexes != 1 {
t.Errorf("индекс push_subscriptions_user_idx не создан")
}
userID := int64(1000)
if _, err := db.Writer.ExecContext(ctx,
`INSERT INTO users (id, username, username_lower, display_name, email_enc, email_index,
password_hash, locale, created_at, updated_at)
VALUES (?, 'push', 'push', 'Push', 'enc', 'idx', 'hash', 'ru', '2026-01-01T00:00:00.000Z', '2026-01-01T00:00:00.000Z')`,
userID); err != nil {
t.Fatalf("вставка пользователя: %v", err)
}
insert := `INSERT INTO push_subscriptions (id, user_id, endpoint, p256dh, auth, created_at)
VALUES (?, ?, ?, 'key', 'auth', '2026-01-01T00:00:00.000Z')`
if _, err := db.Writer.ExecContext(ctx, insert, 1, userID, "https://push.example.com/one"); err != nil {
t.Fatalf("вставка подписки: %v", err)
}
// Эндпоинт уникален: повторная подписка с того же устройства обновляет
// запись, а не создаёт вторую.
if _, err := db.Writer.ExecContext(ctx, insert, 2, userID, "https://push.example.com/one"); err == nil {
t.Fatal("повторный эндпоинт принят, ожидалась ошибка уникальности")
}
// Внешний ключ: подписка без пользователя невозможна.
if _, err := db.Writer.ExecContext(ctx, insert, 3, 999999, "https://push.example.com/two"); err == nil {
t.Fatal("подписка без пользователя принята, ожидалась ошибка внешнего ключа")
}
// Удаление пользователя убирает его подписки.
if _, err := db.Writer.ExecContext(ctx, `DELETE FROM users WHERE id = ?`, userID); err != nil {
t.Fatalf("удаление пользователя: %v", err)
}
var remaining int
if err := db.Reader.QueryRowContext(ctx, `SELECT COUNT(*) FROM push_subscriptions`).Scan(&remaining); err != nil {
t.Fatalf("подсчёт подписок: %v", err)
}
if remaining != 0 {
t.Errorf("после удаления пользователя осталось подписок: %d", remaining)
}
}
+101
View File
@@ -132,6 +132,9 @@ type ReadyChannel struct {
Slowmode int `json:"slowmode_seconds,omitempty"`
CanSend bool `json:"can_send"`
CanConnect bool `json:"can_connect"`
// Description — статус комнаты: шапка показывает его рядом с названием
// (AGENT.md 7.5, 11.6), поэтому он приходит и в READY, и в событиях комнат.
Description string `json:"description,omitempty"`
// BackgroundFileID — фон комнаты: приходит и в READY, и в CHANNEL_UPDATE
// (AGENT.md 7.5), иначе после перезагрузки фон терялся бы до REST-запроса.
BackgroundFileID string `json:"background_file_id,omitempty"`
@@ -141,6 +144,40 @@ type ReadyChannel struct {
Status string `json:"status,omitempty"`
LastMessageID string `json:"last_message_id,omitempty"`
LastMessageAt string `json:"last_message_at,omitempty"`
// Групповая личная беседа (AGENT.md 7.8, Фаза 7): имя, состав и владелец.
// У 1:1 is_group = false, а собеседник лежит в recipient_id.
IsGroup bool `json:"is_group,omitempty"`
MemberCount int `json:"member_count,omitempty"`
MemberIDs []string `json:"member_ids,omitempty"`
OwnerID string `json:"owner_id,omitempty"`
// ActiveCall — звонок, идущий в личной беседе (AGENT.md 7.8, Фаза 7):
// список бесед и шапка показывают «идёт звонок» сразу после подключения,
// не дожидаясь события DM_CALL_UPDATE.
ActiveCall *ReadyDMCall `json:"active_call,omitempty"`
}
// ReadyDMCall — звонок в личной беседе в снапшоте. Форма совпадает с
// dmCallPayload REST-ручек и событиями DM_CALL_*, чтобы клиент разбирал
// состояние звонка одним кодом (AGENT.md 8.3).
type ReadyDMCall struct {
ID string `json:"id"`
ChannelID string `json:"channel_id"`
InitiatorID string `json:"initiator_id"`
Status string `json:"status"`
Media string `json:"media"`
CreatedAt string `json:"created_at"`
StartedAt string `json:"started_at,omitempty"`
Participants []ReadyDMCallParticipant `json:"participants"`
}
// ReadyDMCallParticipant — участник звонка в снапшоте.
type ReadyDMCallParticipant struct {
UserID string `json:"user_id"`
State string `json:"state"`
SelfMute bool `json:"self_mute"`
SelfDeaf bool `json:"self_deaf"`
Camera bool `json:"camera"`
Screen bool `json:"screen"`
}
type ReadyRole struct {
@@ -391,6 +428,70 @@ func (s *Service) SendToUser(userID uint64, event string, payload any) {
}
}
// InvalidateUser закрывает все соединения пользователя: клиент получает
// INVALID_SESSION и уходит на экран входа. Нужно там, где сессии отозваны
// (logout-all, смена пароля, действия администратора) — иначе второе
// устройство остаётся «в приложении» до следующего запроса (AGENT.md 11.6).
func (s *Service) InvalidateUser(userID uint64, reason string) {
s.invalidateUser(userID, "", reason)
}
// InvalidateUserExcept закрывает соединения пользователя, кроме текущего:
// смена пароля отзывает остальные сессии, но устройство, с которого её
// сменили, продолжает работать (AGENT.md 7.1, 11.6).
func (s *Service) InvalidateUserExcept(userID uint64, keepTokenHash, reason string) {
s.invalidateUser(userID, keepTokenHash, reason)
}
// InvalidateSession закрывает соединение одной сессии пользователя: панель
// инстанса отзывает конкретное устройство (AGENT.md 7.18), остальные
// устройства продолжают работу. Соединение опознаётся по хэшу токена сессии —
// как и в InvalidateUserExcept, только условие обратное.
func (s *Service) InvalidateSession(userID uint64, tokenHash, reason string) {
if tokenHash == "" {
return
}
s.mu.Lock()
targets := make([]*clientSession, 0, 1)
for _, session := range s.sessions {
if session.userID != userID {
continue
}
if session.buffer == nil || session.buffer.tokenHash != tokenHash {
continue
}
targets = append(targets, session)
}
s.mu.Unlock()
for _, session := range targets {
// Сначала кадр (writeSync дожидается записи), потом закрытие.
s.sendInvalidSession(session, reason)
session.close()
}
}
func (s *Service) invalidateUser(userID uint64, keepTokenHash, reason string) {
s.mu.Lock()
targets := make([]*clientSession, 0, 2)
for _, session := range s.sessions {
if session.userID != userID {
continue
}
// Соединение опознаём по хэшу токена сессии: у каждой сессии свой буфер
// RESUME, он же хранит владельца (AGENT.md 8.3).
if keepTokenHash != "" && session.buffer != nil && session.buffer.tokenHash == keepTokenHash {
continue
}
targets = append(targets, session)
}
s.mu.Unlock()
for _, session := range targets {
// Сначала кадр (writeSync дожидается записи), потом закрытие.
s.sendInvalidSession(session, reason)
session.close()
}
}
func frame(op int, payload any) ([]byte, error) {
var encoded json.RawMessage
if payload != nil {
+155
View File
@@ -6,6 +6,7 @@ import (
"log/slog"
"net/http/httptest"
"path/filepath"
"slices"
"strings"
"testing"
"time"
@@ -214,6 +215,71 @@ func TestHelloAndReady(t *testing.T) {
}
}
// TestReadyCarriesMyPermissions проверяет, что снапшот несёт права участника:
// пустой `my_permissions` выключал композер и управление сервером у всех,
// включая владельца (AGENT.md 11.6).
func TestReadyCarriesMyPermissions(t *testing.T) {
f := newFixture(t)
token := registerUser(t, f, "perm_user", "perm@example.com")
joinMainGuildForTest(t, f, token)
conn, _ := f.dial(t)
send(t, conn, gateway.OpIdentify, map[string]any{"token": token})
var snapshot gateway.Ready
if err := json.Unmarshal(readEnvelope(t, conn).D, &snapshot); err != nil {
t.Fatalf("decode READY: %v", err)
}
perms := snapshot.Guilds[0].MyPerms
if len(perms) == 0 {
t.Fatal("READY must carry my_permissions of the default role")
}
// Роль @user даёт эти права каждому участнику (AGENT.md 6.3).
for _, want := range []string{"VIEW_CHANNEL", "SEND_MESSAGES"} {
if !slices.Contains(perms, want) {
t.Fatalf("my_permissions = %v, want %s", perms, want)
}
}
if !snapshot.Guilds[0].Channels[0].CanSend {
t.Fatal("my_permissions must agree with channel can_send")
}
}
// TestReadyGivesInstanceAdminAllPermissions: у инстанс-админа права
// максимальные даже на чужом сервере, где он не участник (AGENT.md 7.19).
func TestReadyGivesInstanceAdminAllPermissions(t *testing.T) {
f := newFixture(t)
ctx := context.Background()
token := registerUser(t, f, "admin_user", "admin@example.com")
// В инстансе всего один сервер (главный): снапшот админа несёт все серверы
// инстанса, а права на них максимальные.
joinMainGuildForTest(t, f, token)
user, _, err := f.auth.ResolveSession(ctx, token)
if err != nil {
t.Fatalf("ResolveSession: %v", err)
}
if err := f.store.SetInstanceAdmin(ctx, user.ID, true); err != nil {
t.Fatalf("SetInstanceAdmin: %v", err)
}
conn, _ := f.dial(t)
send(t, conn, gateway.OpIdentify, map[string]any{"token": token})
var snapshot gateway.Ready
if err := json.Unmarshal(readEnvelope(t, conn).D, &snapshot); err != nil {
t.Fatalf("decode READY: %v", err)
}
if len(snapshot.Guilds) != 1 {
t.Fatalf("instance admin must see the guild, got %d", len(snapshot.Guilds))
}
perms := snapshot.Guilds[0].MyPerms
for _, want := range []string{"ADMINISTRATOR", "SEND_MESSAGES", "MANAGE_ROLES", "BAN_MEMBERS"} {
if !slices.Contains(perms, want) {
t.Fatalf("my_permissions инстанс-админа = %v, want %s", perms, want)
}
}
}
func TestReadyHidesPrivateChannels(t *testing.T) {
f := newFixture(t)
ctx := context.Background()
@@ -358,6 +424,56 @@ func TestDispatchReachesConnectedClient(t *testing.T) {
}
}
// TestInvalidateUserExceptKeepsCurrentSession: смена пароля отзывает остальные
// сессии, но соединение устройства, с которого её сменили, остаётся живым,
// иначе пользователь терял бы интерфейс в момент успешной смены (AGENT.md 7.1).
func TestInvalidateUserExceptKeepsCurrentSession(t *testing.T) {
f := newFixture(t)
ctx := context.Background()
token := registerUser(t, f, "except_user", "except@example.com")
// Вторая сессия того же пользователя: вход тем же паролем с другого
// «устройства» (в тесте — второй токен).
user, currentSession, err := f.auth.ResolveSession(ctx, token)
if err != nil {
t.Fatalf("ResolveSession: %v", err)
}
email, err := f.auth.Email(ctx, user.ID)
if err != nil {
t.Fatalf("Email: %v", err)
}
_, otherToken, otherSession, err := f.auth.Login(ctx, auth.LoginInput{
Email: email, Password: "correct-horse-battery",
})
if err != nil {
t.Fatalf("Login: %v", err)
}
currentConn, _ := f.dial(t)
send(t, currentConn, gateway.OpIdentify, map[string]any{"token": token})
if ready := readEnvelope(t, currentConn); ready.T != "READY" {
t.Fatalf("expected READY, got %q", ready.T)
}
otherConn, _ := f.dial(t)
send(t, otherConn, gateway.OpIdentify, map[string]any{"token": otherToken})
if ready := readEnvelope(t, otherConn); ready.T != "READY" {
t.Fatalf("expected READY for the second device, got %q", ready.T)
}
f.service.InvalidateUserExcept(user.ID, currentSession.TokenHash, "password_changed")
// Второе устройство получает INVALID_SESSION...
invalid := readEnvelope(t, otherConn)
if invalid.Op != gateway.OpInvalidSess {
t.Fatalf("other device op = %d, want INVALID_SESSION (%d)", invalid.Op, gateway.OpInvalidSess)
}
// ...а текущее продолжает работать: heartbeat отвечает.
send(t, currentConn, gateway.OpHeartbeat, nil)
if ack := readEnvelope(t, currentConn); ack.Op != gateway.OpHeartbeatAck {
t.Fatalf("current device op = %d, want HEARTBEAT_ACK", ack.Op)
}
_ = otherSession
}
func TestResumeReplaysMissedEvents(t *testing.T) {
f := newFixture(t)
token := registerUser(t, f, "resume_user", "resume@example.com")
@@ -404,3 +520,42 @@ func TestResumeReplaysMissedEvents(t *testing.T) {
t.Fatalf("stale resume = %q, want full READY", full.T)
}
}
// TestInvalidateUserClosesConnections проверяет отзыв сессий: после
// logout-all, смены пароля или действий администратора соединения клиента
// должны закрыться с INVALID_SESSION, а не ждать следующего запроса
// (AGENT.md 11.6). Отдельно проверяется, что чужого пользователя это не задевает.
func TestInvalidateUserClosesConnections(t *testing.T) {
f := newFixture(t)
token := registerUser(t, f, "invalidate_user", "invalidate@example.com")
otherToken := registerUser(t, f, "invalidate_other", "invalidate-other@example.com")
conn, _ := f.dial(t)
send(t, conn, gateway.OpIdentify, map[string]any{"token": token})
if ready := readEnvelope(t, conn); ready.T != "READY" {
t.Fatalf("expected READY, got %q", ready.T)
}
otherConn, _ := f.dial(t)
send(t, otherConn, gateway.OpIdentify, map[string]any{"token": otherToken})
if ready := readEnvelope(t, otherConn); ready.T != "READY" {
t.Fatalf("expected READY for the other client, got %q", ready.T)
}
user, err := f.auth.UserByEmail(context.Background(), "invalidate@example.com")
if err != nil {
t.Fatalf("UserByEmail: %v", err)
}
f.service.InvalidateUser(user.ID, "logout_all")
invalid := readEnvelope(t, conn)
if invalid.Op != gateway.OpInvalidSess {
t.Fatalf("op = %d, want INVALID_SESSION (%d)", invalid.Op, gateway.OpInvalidSess)
}
// Второй пользователь продолжает работать: heartbeat отвечает.
send(t, otherConn, gateway.OpHeartbeat, nil)
if ack := readEnvelope(t, otherConn); ack.Op != gateway.OpHeartbeatAck {
t.Fatalf("other client op = %d, want HEARTBEAT_ACK", ack.Op)
}
}
+38
View File
@@ -0,0 +1,38 @@
package gateway
// Присутствие в Gateway: кто прямо сейчас держит соединение. Нужно Web Push
// (AGENT.md 7.16, Фаза 7): если у получателя есть активная сессия, уведомление
// покажет сам клиент по своим правилам, и дублировать его на телефон не нужно.
//
// Файл отдельный, чтобы не трогать горячий gateway.go: логика читает те же
// поля и под тем же мьютексом, что и рассылка.
// IsUserOnline сообщает, есть ли у пользователя хотя бы одно соединение.
func (s *Service) IsUserOnline(userID uint64) bool {
if s == nil || userID == 0 {
return false
}
s.mu.RLock()
defer s.mu.RUnlock()
for _, session := range s.sessions {
if session.userID == userID {
return true
}
}
return false
}
// OnlineUsers возвращает количество уникальных пользователей с соединением —
// используется в тестах и диагностике.
func (s *Service) OnlineUsers() int {
if s == nil {
return 0
}
s.mu.RLock()
defer s.mu.RUnlock()
seen := make(map[uint64]struct{}, len(s.sessions))
for _, session := range s.sessions {
seen[session.userID] = struct{}{}
}
return len(seen)
}
+95 -12
View File
@@ -92,9 +92,31 @@ func (s *Snapshot) Build(ctx context.Context, user *store.User) (*Ready, error)
RecipientID: formatID(summary.RecipientID),
Status: visibleStatus(summary.Status, summary.LastSeenAt),
}
if summary.AvatarFileID != nil {
if summary.IsGroup {
// Групповая беседа: имя и состав вместо собеседника, иконка — своя
// загрузка (D-070), а не аватар участника.
channel.IsGroup = true
channel.Name = summary.GroupName
channel.MemberCount = summary.MemberCount
channel.MemberIDs = make([]string, 0, len(summary.Participants))
for _, participant := range summary.Participants {
channel.MemberIDs = append(channel.MemberIDs, formatID(participant.UserID))
}
if summary.OwnerID != nil {
channel.OwnerID = formatID(*summary.OwnerID)
}
if summary.Channel.IconFileID != nil {
channel.IconFileID = formatID(*summary.Channel.IconFileID)
}
} else if summary.AvatarFileID != nil {
// У 1:1 иконка беседы — аватар собеседника (AGENT.md 7.8).
channel.IconFileID = formatID(*summary.AvatarFileID)
}
// Звонок в беседе: клиент рисует маркер «идёт звонок» сразу после
// подключения, а не только по событию (AGENT.md 7.8).
if call, err := s.store.GetLiveDMCall(ctx, summary.Channel.ID); err == nil {
channel.ActiveCall = readyDMCall(call)
}
if summary.LastMessageID != 0 {
channel.LastMessageID = formatID(summary.LastMessageID)
}
@@ -104,9 +126,12 @@ func (s *Snapshot) Build(ctx context.Context, user *store.User) (*Ready, error)
ready.DMChannels = append(ready.DMChannels, channel)
}
guilds, err := s.store.ListGuildsForUser(ctx, user.ID)
// Инстанс-админ видит в снапшоте все серверы инстанса, включая чужие и
// приватные, где он не участник: без этого он не мог войти в чужой сервер
// из интерфейса, хотя права у него есть везде (AGENT.md 7.19, 11.5).
guilds, err := s.userGuilds(ctx, user)
if err != nil {
return nil, fmt.Errorf("list user guilds: %w", err)
return nil, err
}
for _, guild := range guilds {
resolved, err := s.calculator.Guild(ctx, guild.ID, user.ID, user.IsInstanceAdmin)
@@ -126,6 +151,23 @@ func (s *Snapshot) Build(ctx context.Context, user *store.User) (*Ready, error)
return ready, nil
}
// userGuilds выбирает серверы для снапшота: обычному пользователю — только
// свои, администратору инстанса — все серверы инстанса (AGENT.md 7.19, 11.5).
func (s *Snapshot) userGuilds(ctx context.Context, user *store.User) ([]store.Guild, error) {
if user.IsInstanceAdmin {
guilds, err := s.store.ListAllGuilds(ctx)
if err != nil {
return nil, fmt.Errorf("list instance guilds: %w", err)
}
return guilds, nil
}
guilds, err := s.store.ListGuildsForUser(ctx, user.ID)
if err != nil {
return nil, fmt.Errorf("list user guilds: %w", err)
}
return guilds, nil
}
func (s *Snapshot) buildGuild(ctx context.Context, guild store.Guild, user *store.User, resolved permissions.Resolved) (ReadyGuild, error) {
readyGuild := ReadyGuild{
ID: formatID(guild.ID),
@@ -140,7 +182,7 @@ func (s *Snapshot) buildGuild(ctx context.Context, guild store.Guild, user *stor
Roles: []ReadyRole{},
MemberIDs: []string{},
MyRoles: []string{},
MyPerms: []string{},
MyPerms: readyPermissions(resolved),
Emojis: []ReadyEmoji{},
VoiceStates: []ReadyVoiceState{},
Sounds: []ReadySound{},
@@ -257,16 +299,29 @@ func (s *Snapshot) buildGuild(ctx context.Context, guild store.Guild, user *stor
return readyGuild, nil
}
// readyPermissions отдаёт права пользователя на сервере списком имён — тем же
// движком и в том же виде, что REST (`permissions.Names`). Без них клиент
// выключал композер и прятал управление сервером (AGENT.md 11.6).
func readyPermissions(resolved permissions.Resolved) []string {
// У инстанс-админа права максимальные на любом сервере, включая чужой, где
// он не участник (AGENT.md 7.19): в маске прав роли их нет.
if resolved.IsInstanceAdmin {
return permissions.Names(permissions.AllPermissions)
}
return permissions.Names(resolved.Guild)
}
func channelPayload(channel store.Channel, canSend, canConnect bool) ReadyChannel {
payload := ReadyChannel{
ID: formatID(channel.ID),
Type: string(channel.Type),
Name: channel.Name,
Position: channel.Position,
UserLimit: channel.UserLimit,
Slowmode: channel.SlowmodeSeconds,
CanSend: canSend,
CanConnect: canConnect,
ID: formatID(channel.ID),
Type: string(channel.Type),
Name: channel.Name,
Description: channel.Description,
Position: channel.Position,
UserLimit: channel.UserLimit,
Slowmode: channel.SlowmodeSeconds,
CanSend: canSend,
CanConnect: canConnect,
}
if channel.GuildID != nil {
payload.GuildID = formatID(*channel.GuildID)
@@ -377,6 +432,34 @@ func optionalFormatID(id *uint64) string {
return formatID(*id)
}
// readyDMCall переводит звонок из хранилища в форму снапшота: та же форма, что
// у событий DM_CALL_* и REST-ручек звонков (AGENT.md 8.3).
func readyDMCall(call *store.DMCallWithParticipants) *ReadyDMCall {
payload := &ReadyDMCall{
ID: formatID(call.ID),
ChannelID: formatID(call.ChannelID),
InitiatorID: formatID(call.InitiatorID),
Status: call.Status,
Media: call.Media,
CreatedAt: call.CreatedAt.UTC().Format(time.RFC3339),
Participants: make([]ReadyDMCallParticipant, 0, len(call.Participants)),
}
if call.StartedAt != nil {
payload.StartedAt = call.StartedAt.UTC().Format(time.RFC3339)
}
for _, participant := range call.Participants {
payload.Participants = append(payload.Participants, ReadyDMCallParticipant{
UserID: formatID(participant.UserID),
State: participant.State,
SelfMute: participant.SelfMute,
SelfDeaf: participant.SelfDeaf,
Camera: participant.Camera,
Screen: participant.Screen,
})
}
return payload
}
func formatID(id uint64) string {
if id == 0 {
return ""
+12 -9
View File
@@ -325,7 +325,7 @@ func (s *Service) register(session *clientSession, tokenHash string) {
s.sessions[session.id] = session
buffer, ok := s.buffers[tokenHash]
if !ok {
buffer = newResumeBuffer(ResumeBufferSize)
buffer = newResumeBuffer(ResumeBufferSize, tokenHash)
s.buffers[tokenHash] = buffer
}
// Владелец буфера нужен, чтобы доставлять адресные события (SendToUser)
@@ -347,7 +347,7 @@ func (s *Service) bufferFor(tokenHash string) *resumeBuffer {
if buffer, ok := s.buffers[tokenHash]; ok {
return buffer
}
buffer := newResumeBuffer(ResumeBufferSize)
buffer := newResumeBuffer(ResumeBufferSize, tokenHash)
s.buffers[tokenHash] = buffer
return buffer
}
@@ -405,11 +405,14 @@ func newSessionID() string {
// resumeBuffer хранит последние события для RESUME (AGENT.md 8.3).
type resumeBuffer struct {
mu sync.Mutex
owner uint64
limit int
items []bufferedEvent
updated time.Time
mu sync.Mutex
// tokenHash — сессия пользователя, которой принадлежит буфер: по нему
// адресные отзывы отличают текущее соединение от остальных.
tokenHash string
owner uint64
limit int
items []bufferedEvent
updated time.Time
}
type bufferedEvent struct {
@@ -417,8 +420,8 @@ type bufferedEvent struct {
payload []byte
}
func newResumeBuffer(limit int) *resumeBuffer {
return &resumeBuffer{limit: limit, updated: time.Now()}
func newResumeBuffer(limit int, tokenHash string) *resumeBuffer {
return &resumeBuffer{limit: limit, tokenHash: tokenHash, updated: time.Now()}
}
func (b *resumeBuffer) append(seq int64, payload []byte) {
+4
View File
@@ -14,6 +14,8 @@ const (
CodeBadRequest ErrorCode = "request.bad"
CodeRateLimited ErrorCode = "ratelimit.hit"
CodeNotReady ErrorCode = "instance.not_ready"
// CodeOriginForbidden — state-changing запрос пришёл с чужого Origin.
CodeOriginForbidden ErrorCode = "origin.forbidden"
)
type Error struct {
@@ -38,6 +40,8 @@ func (e Error) HTTPStatus() int {
return http.StatusTooManyRequests
case CodeNotReady:
return http.StatusServiceUnavailable
case CodeOriginForbidden:
return http.StatusForbidden
default:
return http.StatusInternalServerError
}
+2 -2
View File
@@ -100,7 +100,7 @@ func TestRecovererTurnsPanicIntoError(t *testing.T) {
func TestSecurityHeaders(t *testing.T) {
rec := httptest.NewRecorder()
SecurityHeaders("files.example.com")(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
SecurityHeaders(SecurityOptions{FilesDomain: "files.example.com"})(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
})).ServeHTTP(rec, httptest.NewRequestWithContext(context.Background(), http.MethodGet, "/", nil))
@@ -120,7 +120,7 @@ func TestSecurityHeadersAddHSTSBehindProxy(t *testing.T) {
req.Header.Set("X-Forwarded-Proto", "https")
rec := httptest.NewRecorder()
SecurityHeaders("files.example.com")(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
SecurityHeaders(SecurityOptions{FilesDomain: "files.example.com"})(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
})).ServeHTTP(rec, req)
+93 -9
View File
@@ -118,26 +118,72 @@ func Recoverer(logger *slog.Logger) Middleware {
}
}
func SecurityHeaders(filesDomain string) Middleware {
frameAncestors := "'none'"
csp := strings.Join([]string{
// SecurityOptions — параметры заголовков безопасности (AGENT.md 9.7).
type SecurityOptions struct {
// FilesDomain — отдельный домен файлов: он попадает в img-src.
FilesDomain string
// LiveKitURL — адрес SFU: клиент подключается к нему по WebSocket.
LiveKitURL string
}
// cspPolicy собирает Content-Security-Policy. Директивы перечислены явно:
// скрипты и воркер только свои, стили допускают inline (React ставит style
// атрибуты), картинки — свои, внешние https (аватары вебхуков) и blob,
// соединения — свой origin, домен файлов и LiveKit.
func cspPolicy(options SecurityOptions, tls bool) string {
connect := []string{"'self'"}
if options.FilesDomain != "" {
connect = append(connect, "https://"+options.FilesDomain, "wss://"+options.FilesDomain)
}
if options.LiveKitURL != "" {
parsed := strings.TrimSuffix(options.LiveKitURL, "/")
parsed = strings.Replace(parsed, "https://", "wss://", 1)
parsed = strings.Replace(parsed, "http://", "ws://", 1)
connect = append(connect, parsed)
}
directives := []string{
"default-src 'self'",
"base-uri 'self'",
"object-src 'none'",
"frame-ancestors " + frameAncestors,
"frame-ancestors 'none'",
"form-action 'self'",
}, "; ")
"script-src 'self'",
"worker-src 'self'",
"manifest-src 'self'",
"style-src 'self' 'unsafe-inline'",
"font-src 'self' data:",
"img-src 'self' data: blob: https:",
"media-src 'self' blob:",
"connect-src " + strings.Join(connect, " "),
}
if tls {
directives = append(directives, "upgrade-insecure-requests")
}
return strings.Join(directives, "; ")
}
func SecurityHeaders(options SecurityOptions) Middleware {
secureCSP := cspPolicy(options, true)
plainCSP := cspPolicy(options, false)
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
h := w.Header()
h.Set("Content-Security-Policy", csp)
if isTLS(r) {
h.Set("Content-Security-Policy", secureCSP)
h.Set("Strict-Transport-Security", "max-age=31536000; includeSubDomains")
} else {
h.Set("Content-Security-Policy", plainCSP)
}
h.Set("Referrer-Policy", "strict-origin-when-cross-origin")
h.Set("X-Content-Type-Options", "nosniff")
h.Set("X-Frame-Options", "DENY")
h.Set("Cross-Origin-Resource-Policy", "same-site")
if isTLS(r) {
h.Set("Strict-Transport-Security", "max-age=31536000; includeSubDomains")
}
h.Set("Cross-Origin-Opener-Policy", "same-origin")
h.Set("X-Permitted-Cross-Domain-Policies", "none")
// Голос и демонстрация экрана нужны самому приложению, остальное
// браузеру здесь не требуется.
h.Set("Permissions-Policy",
"camera=(self), microphone=(self), display-capture=(self), geolocation=(), payment=(), usb=()")
next.ServeHTTP(w, r)
})
}
@@ -193,3 +239,41 @@ func RequestIDFrom(ctx context.Context) string {
}
return ""
}
// stateChangingMethods — методы, которые меняют состояние: для них проверяем
// Origin, чтобы чужая страница не отправила запрос с cookie пользователя
// (AGENT.md 9.7, 8.1).
var stateChangingMethods = map[string]bool{
http.MethodPost: true,
http.MethodPut: true,
http.MethodPatch: true,
http.MethodDelete: true,
}
// OriginCheck отклоняет state-changing запросы с чужого Origin. Запросы без
// Origin (curl, CLI, десктоп с Bearer) пропускаем: браузер их не подделает, а
// cookie сессии помечена SameSite=Lax.
func OriginCheck(allowedOrigins []string) Middleware {
allowed := make(map[string]bool, len(allowedOrigins))
for _, origin := range allowedOrigins {
allowed[strings.ToLower(strings.TrimSuffix(origin, "/"))] = true
}
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !stateChangingMethods[r.Method] {
next.ServeHTTP(w, r)
return
}
origin := strings.ToLower(strings.TrimSuffix(r.Header.Get("Origin"), "/"))
if origin == "" {
next.ServeHTTP(w, r)
return
}
if allowed[origin] {
next.ServeHTTP(w, r)
return
}
WriteError(w, NewError(CodeOriginForbidden, "origin is not allowed"))
})
}
}
+146
View File
@@ -0,0 +1,146 @@
package httpx
import (
"errors"
"fmt"
"net"
"net/http"
"net/url"
"strings"
"syscall"
"time"
)
// Проверки адресов для исходящих запросов (AGENT.md 9.2, 9.7): сервер сам
// ходит в интернет только там, где это осознанно (Web Push, превью ссылок),
// поэтому внутренние подсети должны быть закрыты и для IP-литералов, и для
// имён, которые в них разрешаются (DNS rebinding).
var (
// ErrAddressBlocked — адрес ведёт во внутреннюю сеть или не является IP.
ErrAddressBlocked = errors.New("address is not allowed")
// ErrURLUnsupported — схема, хост или формат URL недопустимы.
ErrURLUnsupported = errors.New("url is not supported")
)
// IsBlockedIP сообщает, что адрес нельзя запрашивать: loopback, приватные
// диапазоны, link-local (включая метаданные облака 169.254.169.254),
// multicast, unspecified и IPv4-mapped варианты приватных адресов.
func IsBlockedIP(ip net.IP) bool {
if ip == nil {
return true
}
// IPv4-mapped (::ffff:10.0.0.1) разворачиваем в IPv4: иначе проверки
// 4-байтовых диапазонов его не увидят.
if v4 := ip.To4(); v4 != nil {
ip = v4
}
if ip.IsLoopback() || ip.IsPrivate() || ip.IsLinkLocalUnicast() ||
ip.IsLinkLocalMulticast() || ip.IsMulticast() || ip.IsUnspecified() {
return true
}
// 0.0.0.0/8 и 100.64.0.0/10 (CGNAT), 192.0.0.0/24 — заведомо не публичные
// адреса назначения.
if v4 := ip.To4(); v4 != nil {
return v4[0] == 0 ||
(v4[0] == 100 && v4[1] >= 64 && v4[1] <= 127) ||
(v4[0] == 192 && v4[1] == 0 && v4[2] == 0)
}
// IPv6: unique local (fc00::/7) и link-local уже покрыты стандартными
// проверками, отдельно закрываем 2001:db8::/32 (диапазон документации).
return len(ip) == net.IPv6len && ip[0] == 0x20 && ip[1] == 0x01 && ip[2] == 0x0d && ip[3] == 0xb8
}
// SafeDialControl — функция для net.Dialer.Control: проверяет уже разрешённый
// адрес в момент подключения, поэтому подмена DNS между проверкой и запросом
// ничего не даёт.
func SafeDialControl(_ string, address string, _ syscall.RawConn) error {
host, _, err := net.SplitHostPort(address)
if err != nil {
host = address
}
ip := net.ParseIP(strings.Trim(host, "[]"))
if ip == nil {
return fmt.Errorf("%w: %q is not an ip address", ErrAddressBlocked, address)
}
if IsBlockedIP(ip) {
return fmt.Errorf("%w: %s", ErrAddressBlocked, ip)
}
return nil
}
// SafeTransport собирает транспорт для исходящих запросов: без прокси из
// окружения, с проверкой адреса на подключении, с таймаутами и без keep-alive
// на чужих хостах.
func SafeTransport(timeout time.Duration) *http.Transport {
if timeout <= 0 {
timeout = 5 * time.Second
}
return &http.Transport{
Proxy: nil,
DialContext: (&net.Dialer{
Timeout: timeout,
KeepAlive: 30 * time.Second,
Control: SafeDialControl,
}).DialContext,
TLSHandshakeTimeout: timeout,
ResponseHeaderTimeout: timeout,
ExpectContinueTimeout: time.Second,
MaxIdleConns: 4,
MaxIdleConnsPerHost: 2,
IdleConnTimeout: 30 * time.Second,
ForceAttemptHTTP2: true,
}
}
// SafeClient возвращает HTTP-клиент с проверкой адресов и общим таймаутом.
func SafeClient(timeout time.Duration) *http.Client {
if timeout <= 0 {
timeout = 5 * time.Second
}
return &http.Client{
Transport: SafeTransport(timeout),
Timeout: timeout,
CheckRedirect: func(req *http.Request, via []*http.Request) error {
if len(via) >= 3 {
return fmt.Errorf("%w: too many redirects", ErrAddressBlocked)
}
// Каждый хоп проверяем теми же правилами, что и первый запрос:
// редирект во внутреннюю сеть — обычный приём SSRF.
if _, err := ValidatePublicURL(req.URL.String(), false); err != nil {
return err
}
return nil
},
}
}
// ValidatePublicURL проверяет URL для исходящего запроса: разрешены только
// http/https (http — если allowHTTP), без userinfo и без литерального
// внутреннего адреса. Имя хоста окончательно проверяется при подключении
// (SafeDialControl): до резолва доверять ему нельзя.
func ValidatePublicURL(raw string, allowHTTP bool) (*url.URL, error) {
trimmed := strings.TrimSpace(raw)
if trimmed == "" || len(trimmed) > 2048 {
return nil, fmt.Errorf("%w: empty or too long", ErrURLUnsupported)
}
parsed, err := url.Parse(trimmed)
if err != nil {
return nil, fmt.Errorf("%w: %w", ErrURLUnsupported, err)
}
scheme := strings.ToLower(parsed.Scheme)
if scheme != "https" && (!allowHTTP || scheme != "http") {
return nil, fmt.Errorf("%w: scheme %q", ErrURLUnsupported, parsed.Scheme)
}
if parsed.User != nil {
return nil, fmt.Errorf("%w: userinfo is not allowed", ErrURLUnsupported)
}
host := parsed.Hostname()
if host == "" {
return nil, fmt.Errorf("%w: host is empty", ErrURLUnsupported)
}
if ip := net.ParseIP(host); ip != nil && IsBlockedIP(ip) {
return nil, fmt.Errorf("%w: %s", ErrAddressBlocked, ip)
}
return parsed, nil
}
+141
View File
@@ -0,0 +1,141 @@
package httpx
import (
"net"
"net/http"
"net/url"
"strings"
"testing"
"time"
)
// TestIsBlockedIP покрывает адреса, по которым сервер не должен ходить сам
// (AGENT.md 9.2, 9.7): внутренние сети, метаданные облака, multicast.
func TestIsBlockedIP(t *testing.T) {
blocked := []string{
"127.0.0.1", "127.9.9.9", "::1",
"10.0.0.1", "172.16.0.1", "172.31.255.255", "192.168.1.1",
"169.254.169.254", "fe80::1",
"0.0.0.0", "::", "0.1.2.3",
"224.0.0.1", "ff02::1",
"100.64.0.1", "100.127.255.255",
"192.0.0.1",
"fc00::1", "fd12:3456::1",
"::ffff:10.0.0.1", "::ffff:127.0.0.1",
"2001:db8::1",
}
for _, raw := range blocked {
ip := net.ParseIP(raw)
if ip == nil {
t.Fatalf("не разобран адрес %q", raw)
}
if !IsBlockedIP(ip) {
t.Errorf("IsBlockedIP(%s) = false, ожидалось true", raw)
}
}
allowed := []string{"8.8.8.8", "1.1.1.1", "138.16.226.159", "2606:4700::1111", "100.63.255.255"}
for _, raw := range allowed {
ip := net.ParseIP(raw)
if ip == nil {
t.Fatalf("не разобран адрес %q", raw)
}
if IsBlockedIP(ip) {
t.Errorf("IsBlockedIP(%s) = true, ожидалось false", raw)
}
}
if !IsBlockedIP(nil) {
t.Error("IsBlockedIP(nil) = false, ожидалось true")
}
}
func TestValidatePublicURL(t *testing.T) {
cases := []struct {
name string
raw string
allowHTTP bool
wantErr bool
}{
{name: "https", raw: "https://example.com/page?a=1"},
{name: "http запрещён", raw: "http://example.com/", wantErr: true},
{name: "http разрешён флагом", raw: "http://example.com/", allowHTTP: true},
{name: "file", raw: "file:///etc/passwd", wantErr: true},
{name: "gopher", raw: "gopher://example.com/", wantErr: true},
{name: "javascript", raw: "javascript:alert(1)", wantErr: true},
{name: "userinfo", raw: "https://user:pass@example.com/", wantErr: true},
{name: "пусто", raw: " ", wantErr: true},
{name: "loopback", raw: "https://127.0.0.1/x", wantErr: true},
{name: "метаданные", raw: "https://169.254.169.254/latest/meta-data/", wantErr: true},
{name: "приватная сеть", raw: "https://10.1.2.3/", wantErr: true},
{name: "ipv6 loopback", raw: "https://[::1]/", wantErr: true},
{name: "слишком длинный", raw: "https://example.com/" + strings.Repeat("a", 2100), wantErr: true},
}
for _, testCase := range cases {
t.Run(testCase.name, func(t *testing.T) {
parsed, err := ValidatePublicURL(testCase.raw, testCase.allowHTTP)
if testCase.wantErr {
if err == nil {
t.Fatalf("ValidatePublicURL(%q) = %v, ожидалась ошибка", testCase.raw, parsed)
}
return
}
if err != nil {
t.Fatalf("ValidatePublicURL(%q) вернул ошибку: %v", testCase.raw, err)
}
})
}
}
// TestSafeDialControl проверяет, что проверка идёт по уже разрешённому адресу:
// именно так закрывается DNS rebinding.
func TestSafeDialControl(t *testing.T) {
blocked := []string{"127.0.0.1:443", "[::1]:443", "169.254.169.254:80", "10.0.0.5:8080"}
for _, address := range blocked {
if err := SafeDialControl("tcp", address, nil); err == nil {
t.Errorf("SafeDialControl пропустил %s", address)
}
}
if err := SafeDialControl("tcp", "93.184.216.34:443", nil); err != nil {
t.Fatalf("SafeDialControl отклонил публичный адрес: %v", err)
}
// Имя вместо адреса означает, что резолв не произошёл: доверять нельзя.
if err := SafeDialControl("tcp", "example.com:443", nil); err == nil {
t.Fatal("SafeDialControl принял неразрешённое имя хоста")
}
}
func TestSafeClientBlocksUnsafeRedirects(t *testing.T) {
client := SafeClient(time.Second)
origin := &http.Request{URL: &url.URL{Scheme: "https", Host: "example.com"}}
via := []*http.Request{origin}
redirect := &http.Request{URL: &url.URL{Scheme: "http", Host: "127.0.0.1:8080", Path: "/steal"}}
if err := client.CheckRedirect(redirect, via); err == nil {
t.Fatal("CheckRedirect пропустил переход на внутренний адрес")
}
// Схема http запрещена всегда: редирект не должен понижать защиту.
redirect = &http.Request{URL: &url.URL{Scheme: "http", Host: "example.com"}}
if err := client.CheckRedirect(redirect, via); err == nil {
t.Fatal("CheckRedirect пропустил переход на http")
}
redirect = &http.Request{URL: &url.URL{Scheme: "https", Host: "example.com", Path: "/next"}}
if err := client.CheckRedirect(redirect, via); err != nil {
t.Fatalf("CheckRedirect отклонил обычный https-редирект: %v", err)
}
// Больше трёх переходов — отказ (защита от цепочек).
many := []*http.Request{origin, origin, origin, origin}
if err := client.CheckRedirect(redirect, many); err == nil {
t.Fatal("CheckRedirect пропустил слишком длинную цепочку")
}
}
func TestSafeTransportHasNoProxy(t *testing.T) {
transport := SafeTransport(time.Second)
if transport.Proxy != nil {
t.Fatal("транспорт не должен использовать прокси из окружения")
}
if transport.DialContext == nil {
t.Fatal("транспорт должен задавать DialContext с проверкой адреса")
}
}
+4 -4
View File
@@ -11,7 +11,7 @@ type Features struct {
// VoiceURL — адрес LiveKit для клиента (ws/wss), пусто при выключенном голосе.
VoiceURL string `json:"voice_url,omitempty"`
WebPushEnabled bool `json:"web_push_enabled"`
OAuthEnabled bool `json:"oauth_enabled"`
UnfurlEnabled bool `json:"unfurl_enabled"`
PasskeysEnabled bool `json:"passkeys_enabled"`
}
@@ -46,9 +46,9 @@ func New(cfg config.Config) Response {
AntiBotEnabled: false,
VoiceEnabled: cfg.VoiceEnabled(),
VoiceURL: cfg.LiveKitURL,
WebPushEnabled: false,
OAuthEnabled: false,
PasskeysEnabled: false,
WebPushEnabled: cfg.WebPushEnabled(),
UnfurlEnabled: cfg.UnfurlEnabled,
PasskeysEnabled: cfg.WebAuthnEnabled(),
},
}
}
+1 -1
View File
@@ -58,7 +58,7 @@ func TestJSONUsesSnakeCaseKeys(t *testing.T) {
if !ok {
t.Fatalf("features is not an object: %s", raw)
}
for _, key := range []string{"registration_enabled", "anti_bot_enabled", "voice_enabled", "web_push_enabled", "oauth_enabled", "passkeys_enabled"} {
for _, key := range []string{"registration_enabled", "anti_bot_enabled", "voice_enabled", "web_push_enabled", "passkeys_enabled"} {
if _, ok := features[key]; !ok {
t.Errorf("features is missing key %q", key)
}
+343
View File
@@ -0,0 +1,343 @@
// Package push отправляет Web Push уведомления браузерам и телефонам
// (AGENT.md 7.16, Фаза 7): VAPID-подпись, шифрование полезной нагрузки по
// RFC 8291 и очередь доставки, чтобы HTTP-ручки не ждали push-сервисы.
//
// Решение «уведомлять или нет» принимает сервер (internal/push/rules.go) по
// тем же правилам, что нативные уведомления desktop-обёртки
// (web/src/lib/desktopNotifications.ts), а показывает уведомление service
// worker клиента (web/public/sw.js).
package push
import (
"context"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/x509"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"log/slog"
"net/http"
"strings"
"sync"
"time"
webpush "github.com/SherClockHolmes/webpush-go"
"glchat/internal/config"
"glchat/internal/httpx"
"glchat/internal/store"
)
const (
// DefaultTTL — сколько push-сервис хранит недоставленное уведомление.
DefaultTTL = 12 * time.Hour
// MaxPayload — предел полезной нагрузки: запись RFC 8291 — 4096 байт,
// оставляем запас на шифрование и служебные поля.
MaxPayload = 3000
// DeliverTimeout — сколько ждём push-сервис на одну доставку.
DeliverTimeout = 10 * time.Second
// MaxFailures — после стольких неудач подряд подписка удаляется.
MaxFailures = 5
// Воркеры и очередь: push не должен ни блокировать запросы, ни плодить
// горутины на каждого получателя.
workerCount = 4
queueSize = 256
)
// VAPIDSubject по умолчанию: push-сервисы требуют контакт администратора.
const defaultSubjectPrefix = "mailto:admin@"
// Payload — то, что получает service worker (web/public/sw.js).
type Payload struct {
Title string `json:"title"`
Body string `json:"body"`
// Kind — "mention" или "direct": клиент по нему выбирает иконку и текст.
Kind string `json:"kind"`
GuildID string `json:"guild_id,omitempty"`
GuildName string `json:"guild_name,omitempty"`
ChannelID string `json:"channel_id"`
ChannelName string `json:"channel_name,omitempty"`
AuthorID string `json:"author_id,omitempty"`
AuthorName string `json:"author_name,omitempty"`
MessageID string `json:"message_id,omitempty"`
// URL — путь, который открывает клик по уведомлению.
URL string `json:"url"`
// Tag — ключ схлопывания: уведомления одной комнаты заменяют друг друга.
Tag string `json:"tag"`
}
// Service — отправитель Web Push. Нулевое значение безопасно: методы
// проверяют nil, поэтому «push выключен» не требует ветвлений у вызывающего.
type Service struct {
logger *slog.Logger
store *store.Store
publicKey string
privateKey string
subject string
ttl int
client webpush.HTTPClient
startOnce sync.Once
queue chan delivery
closed chan struct{}
closeOnce sync.Once
wg sync.WaitGroup
}
type delivery struct {
subscription store.PushSubscription
payload Payload
}
// New собирает сервис из конфига. Если VAPID-ключ не задан — возвращает nil
// без ошибки: инстанс без push работает как обычно. Некорректный ключ —
// ошибка: молча выключенный push выглядел бы как «уведомления не приходят».
func New(cfg config.Config, st *store.Store, logger *slog.Logger) (*Service, error) {
if logger == nil {
logger = slog.New(slog.DiscardHandler)
}
if strings.TrimSpace(cfg.VAPIDPrivateKey) == "" {
return nil, nil
}
privateKey, publicKey, err := ParseVAPIDKeys(cfg.VAPIDPrivateKey)
if err != nil {
return nil, fmt.Errorf("parse VAPID key: %w", err)
}
subject := strings.TrimSpace(cfg.VAPIDSubject)
if subject == "" {
subject = defaultSubjectPrefix + cfg.Domain
}
return &Service{
logger: logger,
store: st,
publicKey: publicKey,
privateKey: privateKey,
subject: subject,
ttl: int(DefaultTTL.Seconds()),
client: httpx.SafeClient(DeliverTimeout),
queue: make(chan delivery, queueSize),
closed: make(chan struct{}),
}, nil
}
// Enabled сообщает, настроен ли Web Push на инстансе.
func (s *Service) Enabled() bool { return s != nil && s.privateKey != "" }
// PublicKey — публичный VAPID-ключ (base64url) для `applicationServerKey`.
func (s *Service) PublicKey() string {
if s == nil {
return ""
}
return s.publicKey
}
// Enqueue ставит доставку в очередь. Возвращает false, если push выключен или
// очередь переполнена: уведомление теряется осознанно, но запрос не ждёт.
func (s *Service) Enqueue(subscription store.PushSubscription, payload Payload) bool {
if !s.Enabled() {
return false
}
select {
case <-s.closed:
return false
default:
}
s.start()
select {
case s.queue <- delivery{subscription: subscription, payload: payload}:
return true
default:
s.logger.Warn("push queue is full, notification dropped",
slog.String("user_id", formatID(subscription.UserID)))
return false
}
}
// Close останавливает воркеры: вызывается при завершении процесса и в тестах.
func (s *Service) Close() {
if s == nil {
return
}
s.closeOnce.Do(func() { close(s.closed) })
s.wg.Wait()
}
func (s *Service) start() {
s.startOnce.Do(func() {
for i := 0; i < workerCount; i++ {
s.wg.Add(1)
go s.work()
}
})
}
func (s *Service) work() {
defer s.wg.Done()
for {
select {
case <-s.closed:
return
case job := <-s.queue:
s.deliver(context.Background(), job)
}
}
}
// deliver отправляет одно уведомление и обслуживает ответ push-сервиса:
// успех отмечается, 404/410 удаляют подписку, остальные ошибки копят счётчик.
func (s *Service) deliver(ctx context.Context, job delivery) {
payload, err := json.Marshal(job.payload)
if err != nil {
s.logger.WarnContext(ctx, "push payload marshal failed", slog.Any("error", err))
return
}
if len(payload) > MaxPayload {
s.logger.WarnContext(ctx, "push payload is too large",
slog.Int("bytes", len(payload)), slog.String("user_id", formatID(job.subscription.UserID)))
return
}
ctx, cancel := context.WithTimeout(ctx, DeliverTimeout)
defer cancel()
response, err := webpush.SendNotificationWithContext(ctx, payload, &webpush.Subscription{
Endpoint: job.subscription.Endpoint,
Keys: webpush.Keys{
P256dh: job.subscription.P256dh,
Auth: job.subscription.Auth,
},
}, &webpush.Options{
HTTPClient: s.client,
Subscriber: s.subject,
TTL: s.ttl,
Urgency: webpush.UrgencyNormal,
Topic: topicFor(job.payload),
VAPIDPublicKey: s.publicKey,
VAPIDPrivateKey: s.privateKey,
})
if err != nil {
s.noteFailure(ctx, job.subscription, 0, err)
return
}
defer func() { _ = response.Body.Close() }()
switch {
case response.StatusCode >= 200 && response.StatusCode < 300:
if err := s.store.TouchPushSubscription(ctx, job.subscription.ID); err != nil {
s.logger.WarnContext(ctx, "push subscription touch failed", slog.Any("error", err))
}
case response.StatusCode == http.StatusNotFound || response.StatusCode == http.StatusGone:
// Подписка отозвана браузером или push-сервисом — хранить нечего.
if err := s.store.DeletePushSubscriptionByID(ctx, job.subscription.ID); err != nil {
s.logger.WarnContext(ctx, "push subscription delete failed", slog.Any("error", err))
}
default:
s.noteFailure(ctx, job.subscription, response.StatusCode, nil)
}
}
// noteFailure копит неудачи и убирает подписку, которая не работает подряд.
func (s *Service) noteFailure(ctx context.Context, subscription store.PushSubscription, status int, cause error) {
count, err := s.store.FailPushSubscription(ctx, subscription.ID)
if err != nil {
s.logger.WarnContext(ctx, "push subscription failure note failed", slog.Any("error", err))
return
}
attributes := []any{
slog.String("user_id", formatID(subscription.UserID)),
slog.Int("status", status),
slog.Int("failures", count),
}
if cause != nil {
attributes = append(attributes, slog.Any("error", cause))
}
s.logger.WarnContext(ctx, "web push delivery failed", attributes...)
if count >= MaxFailures {
if err := s.store.DeletePushSubscriptionByID(ctx, subscription.ID); err != nil {
s.logger.WarnContext(ctx, "push subscription delete failed", slog.Any("error", err))
return
}
s.logger.InfoContext(ctx, "stale push subscription removed",
slog.String("user_id", formatID(subscription.UserID)))
}
}
// topicFor схлопывает уведомления одной комнаты: свежее заменяет предыдущее,
// иначе упоминания в активной переписке заваливают телефон.
func topicFor(payload Payload) string {
if payload.ChannelID == "" {
return ""
}
return "channel-" + payload.ChannelID
}
// ParseVAPIDKeys принимает приватный ключ в двух видах и возвращает пару в
// формате, которого ждёт web-push (base64url без выравнивания):
// - PKCS#8 DER в base64 — так ключ генерирует установщик (`openssl pkcs8`);
// - «сырой» 32-байтовый скаляр в base64url — вывод GenerateVAPIDKeys().
//
// Публичный ключ всегда выводится из приватного: хранить его отдельно
// незачем, а рассинхронизация пары ломала бы подписку молча.
func ParseVAPIDKeys(encoded string) (privateKey, publicKey string, err error) {
trimmed := strings.TrimSpace(encoded)
raw, decodeErr := decodeBase64(trimmed)
if decodeErr != nil {
return "", "", fmt.Errorf("decode: %w", decodeErr)
}
var key *ecdsa.PrivateKey
switch {
case len(raw) == 32:
// «Сырой» скаляр: так ключ печатает GenerateVAPIDKeys().
parsed, parseErr := ecdsa.ParseRawPrivateKey(elliptic.P256(), raw)
if parseErr != nil {
return "", "", fmt.Errorf("parse raw private key: %w", parseErr)
}
key = parsed
default:
parsed, parseErr := x509.ParsePKCS8PrivateKey(raw)
if parseErr != nil {
return "", "", fmt.Errorf("parse PKCS#8: %w", parseErr)
}
ecdsaKey, ok := parsed.(*ecdsa.PrivateKey)
if !ok {
return "", "", errors.New("key is not an ECDSA private key")
}
if ecdsaKey.Curve != elliptic.P256() {
return "", "", fmt.Errorf("curve %s is not P-256", ecdsaKey.Curve.Params().Name)
}
key = ecdsaKey
}
// Bytes() у ecdsa отдаёт фиксированную длину и несжатую точку (SEC 1):
// именно в таком виде ключи ждёт web-push, и никаких big.Int снаружи.
scalar, err := key.Bytes()
if err != nil {
return "", "", fmt.Errorf("encode private key: %w", err)
}
point, err := key.PublicKey.Bytes()
if err != nil {
return "", "", fmt.Errorf("encode public key: %w", err)
}
return base64.RawURLEncoding.EncodeToString(scalar),
base64.RawURLEncoding.EncodeToString(point), nil
}
// decodeBase64 принимает base64 (standard/raw/url) — установщик пишет
// стандартный base64, а ключи из библиотек приходят в base64url.
func decodeBase64(value string) ([]byte, error) {
encodings := []*base64.Encoding{
base64.StdEncoding, base64.RawStdEncoding,
base64.URLEncoding, base64.RawURLEncoding,
}
var lastErr error
for _, encoding := range encodings {
decoded, err := encoding.DecodeString(value)
if err == nil {
return decoded, nil
}
lastErr = err
}
return nil, lastErr
}
func formatID(id uint64) string { return fmt.Sprintf("%d", id) }
+369
View File
@@ -0,0 +1,369 @@
package push
import (
"bytes"
"context"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/x509"
"encoding/base64"
"encoding/json"
"log/slog"
"net/http"
"net/http/httptest"
"path/filepath"
"strings"
"sync"
"testing"
"time"
webpush "github.com/SherClockHolmes/webpush-go"
"glchat/internal/config"
"glchat/internal/database"
"glchat/internal/store"
)
// newPushTestStore открывает временную БД с миграциями и создаёт пользователя:
// подписки ссылаются на users по внешнему ключу.
func newPushTestStore(t *testing.T) (*store.Store, uint64) {
t.Helper()
ctx := context.Background()
db, err := database.Open(ctx, database.Options{
Path: filepath.Join(t.TempDir(), "glchat.db"),
ReadPool: 2,
Migrate: true,
})
if err != nil {
t.Fatalf("открыть тестовую БД: %v", err)
}
t.Cleanup(func() {
if err := db.Close(); err != nil {
t.Errorf("закрыть тестовую БД: %v", err)
}
})
st := store.New(db)
user, err := st.CreateUser(ctx, store.CreateUserParams{
Username: "push_user",
DisplayName: "Push",
EmailEnc: "enc",
EmailIndex: "idx",
PasswordHash: "hash",
})
if err != nil {
t.Fatalf("создать пользователя: %v", err)
}
return st, user.ID
}
// testVAPIDKey возвращает приватный ключ в том же виде, в каком его пишет
// установщик: PKCS#8 DER в base64 (одной строкой).
func testVAPIDKey(t *testing.T) string {
t.Helper()
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
t.Fatalf("сгенерировать ключ: %v", err)
}
der, err := x509.MarshalPKCS8PrivateKey(key)
if err != nil {
t.Fatalf("marshal PKCS#8: %v", err)
}
return base64.StdEncoding.EncodeToString(der)
}
func testConfig(t *testing.T) config.Config {
return config.Config{
Domain: "gl.example.com",
VAPIDPrivateKey: testVAPIDKey(t),
VAPIDSubject: "mailto:admin@example.com",
}
}
// testSubscription сохраняет в БД подписку с указанным эндпоинтом. Ключи
// настоящие (P-256 и 16 байт auth): иначе шифрование RFC 8291 не соберётся.
func testSubscription(t *testing.T, st *store.Store, userID uint64, endpoint string) store.PushSubscription {
t.Helper()
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
t.Fatalf("сгенерировать ключ подписки: %v", err)
}
point, err := key.PublicKey.Bytes()
if err != nil {
t.Fatalf("публичный ключ подписки: %v", err)
}
auth := make([]byte, 16)
if _, err := rand.Read(auth); err != nil {
t.Fatalf("auth: %v", err)
}
subscription, err := st.SavePushSubscription(context.Background(), store.SavePushSubscriptionParams{
UserID: userID,
Endpoint: endpoint,
P256dh: base64.RawURLEncoding.EncodeToString(point),
Auth: base64.RawURLEncoding.EncodeToString(auth),
UserAgent: "test",
})
if err != nil {
t.Fatalf("сохранить подписку: %v", err)
}
return *subscription
}
func TestNewDisabledWithoutKey(t *testing.T) {
st, _ := newPushTestStore(t)
service, err := New(config.Config{Domain: "example.com"}, st, slog.New(slog.DiscardHandler))
if err != nil {
t.Fatalf("New без ключа вернул ошибку: %v", err)
}
if service != nil {
t.Fatal("без VAPID-ключа сервис должен быть nil")
}
if service.Enabled() {
t.Fatal("nil-сервис не может быть включён")
}
if service.PublicKey() != "" {
t.Fatal("у выключенного сервиса нет публичного ключа")
}
if service.Enqueue(store.PushSubscription{}, Payload{}) {
t.Fatal("выключенный сервис не должен принимать доставку")
}
service.Close()
}
func TestNewRejectsBrokenKey(t *testing.T) {
st, _ := newPushTestStore(t)
if _, err := New(config.Config{VAPIDPrivateKey: "not-a-key"}, st, nil); err == nil {
t.Fatal("некорректный ключ должен давать ошибку, а не тихо выключать push")
}
}
func TestParseVAPIDKeysAcceptsBothFormats(t *testing.T) {
// Формат библиотеки: «сырой» скаляр в base64url.
rawPrivate, rawPublic, err := webpush.GenerateVAPIDKeys()
if err != nil {
t.Fatalf("GenerateVAPIDKeys: %v", err)
}
privateKey, publicKey, err := ParseVAPIDKeys(rawPrivate)
if err != nil {
t.Fatalf("ParseVAPIDKeys(raw): %v", err)
}
if privateKey != rawPrivate || publicKey != rawPublic {
t.Fatalf("пары не совпали: %s/%s против %s/%s", privateKey, publicKey, rawPrivate, rawPublic)
}
// Формат установщика: PKCS#8 DER в base64.
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
t.Fatalf("GenerateKey: %v", err)
}
der, err := x509.MarshalPKCS8PrivateKey(key)
if err != nil {
t.Fatalf("MarshalPKCS8PrivateKey: %v", err)
}
privateKey, publicKey, err = ParseVAPIDKeys(base64.StdEncoding.EncodeToString(der))
if err != nil {
t.Fatalf("ParseVAPIDKeys(pkcs8): %v", err)
}
decodedPublic, err := base64.RawURLEncoding.DecodeString(publicKey)
if err != nil {
t.Fatalf("публичный ключ не base64url: %v", err)
}
expectedPublic, err := key.PublicKey.Bytes()
if err != nil {
t.Fatalf("публичный ключ ключа: %v", err)
}
if !bytes.Equal(decodedPublic, expectedPublic) {
t.Fatalf("публичный ключ не совпал с приватным: %d байт", len(decodedPublic))
}
expectedPrivate, err := key.Bytes()
if err != nil {
t.Fatalf("приватный ключ: %v", err)
}
decodedPrivate, err := base64.RawURLEncoding.DecodeString(privateKey)
if err != nil {
t.Fatalf("приватный ключ не base64url: %v", err)
}
if !bytes.Equal(decodedPrivate, expectedPrivate) {
t.Fatal("приватный ключ не совпал с исходным")
}
if _, _, err := ParseVAPIDKeys("!!!not base64!!!"); err == nil {
t.Fatal("мусор должен отвергаться")
}
if _, _, err := ParseVAPIDKeys(base64.StdEncoding.EncodeToString([]byte("слишком короткий ключ"))); err == nil {
t.Fatal("неверная длина ключа должна отвергаться")
}
}
func TestDeliverSuccessMarksSubscription(t *testing.T) {
st, userID := newPushTestStore(t)
var (
mu sync.Mutex
seen http.Header
method string
)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
mu.Lock()
seen = r.Header.Clone()
method = r.Method
mu.Unlock()
w.WriteHeader(http.StatusCreated)
}))
defer server.Close()
service, err := New(testConfig(t), st, slog.New(slog.DiscardHandler))
if err != nil {
t.Fatalf("New: %v", err)
}
// Эндпоинт тестовый (http + 127.0.0.1), поэтому клиент подменяем: в бою
// адрес проверяет httpx.SafeClient.
service.client = server.Client()
subscription := testSubscription(t, st, userID, server.URL)
service.deliver(context.Background(), delivery{
subscription: subscription,
payload: Payload{Title: "Упоминание", Body: "привет", ChannelID: "10", Tag: "channel-10"},
})
mu.Lock()
defer mu.Unlock()
if method != http.MethodPost {
t.Fatalf("метод = %q, ожидался POST", method)
}
if seen.Get("Content-Encoding") != "aes128gcm" {
t.Fatalf("Content-Encoding = %q", seen.Get("Content-Encoding"))
}
if seen.Get("TTL") != "43200" {
t.Fatalf("TTL = %q, ожидалось 43200", seen.Get("TTL"))
}
if seen.Get("Topic") != "channel-10" {
t.Fatalf("Topic = %q", seen.Get("Topic"))
}
if !strings.HasPrefix(seen.Get("Authorization"), "vapid t=") {
t.Fatalf("нет VAPID-подписи: %q", seen.Get("Authorization"))
}
updated, err := st.GetPushSubscriptionByEndpoint(context.Background(), server.URL)
if err != nil {
t.Fatalf("прочитать подписку: %v", err)
}
if updated.LastSuccessAt == nil {
t.Fatal("успешная доставка не отмечена")
}
if updated.FailureCount != 0 {
t.Fatalf("счётчик неудач = %d, ожидался 0", updated.FailureCount)
}
service.Close()
}
func TestDeliverRemovesRevokedSubscription(t *testing.T) {
for _, status := range []int{http.StatusNotFound, http.StatusGone} {
st, userID := newPushTestStore(t)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(status)
}))
service, err := New(testConfig(t), st, slog.New(slog.DiscardHandler))
if err != nil {
t.Fatalf("New: %v", err)
}
service.client = server.Client()
subscription := testSubscription(t, st, userID, server.URL)
service.deliver(context.Background(), delivery{subscription: subscription, payload: Payload{Title: "x"}})
if _, err := st.GetPushSubscriptionByEndpoint(context.Background(), server.URL); err == nil {
t.Fatalf("подписка с ответом %d должна быть удалена", status)
}
service.Close()
server.Close()
}
}
func TestDeliverDropsSubscriptionAfterRepeatedFailures(t *testing.T) {
st, userID := newPushTestStore(t)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
}))
defer server.Close()
service, err := New(testConfig(t), st, slog.New(slog.DiscardHandler))
if err != nil {
t.Fatalf("New: %v", err)
}
service.client = server.Client()
subscription := testSubscription(t, st, userID, server.URL)
for i := 0; i < MaxFailures; i++ {
service.deliver(context.Background(), delivery{subscription: subscription, payload: Payload{Title: "x"}})
}
if _, err := st.GetPushSubscriptionByEndpoint(context.Background(), server.URL); err == nil {
t.Fatalf("после %d неудач подписка должна быть удалена", MaxFailures)
}
service.Close()
}
// Очередь не блокирует запрос: Enqueue возвращает управление сразу, а воркеры
// доставляют уведомление в фоне.
func TestEnqueueDeliversInBackground(t *testing.T) {
st, userID := newPushTestStore(t)
delivered := make(chan struct{}, 1)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
select {
case delivered <- struct{}{}:
default:
}
w.WriteHeader(http.StatusOK)
}))
defer server.Close()
service, err := New(testConfig(t), st, slog.New(slog.DiscardHandler))
if err != nil {
t.Fatalf("New: %v", err)
}
service.client = server.Client()
subscription := testSubscription(t, st, userID, server.URL)
if !service.Enqueue(subscription, Payload{Title: "Упоминание", Body: "привет", ChannelID: "10"}) {
t.Fatal("Enqueue отказал при пустой очереди")
}
select {
case <-delivered:
case <-time.After(5 * time.Second):
t.Fatal("уведомление не доставлено за 5 секунд")
}
service.Close()
}
// Публичный ключ из конфига годится для applicationServerKey браузера.
func TestPublicKeyIsBase64URLPoint(t *testing.T) {
st, _ := newPushTestStore(t)
service, err := New(testConfig(t), st, slog.New(slog.DiscardHandler))
if err != nil {
t.Fatalf("New: %v", err)
}
decoded, err := base64.RawURLEncoding.DecodeString(service.PublicKey())
if err != nil {
t.Fatalf("публичный ключ не base64url: %v", err)
}
if len(decoded) != 65 {
t.Fatalf("длина публичного ключа = %d, ожидалось 65", len(decoded))
}
service.Close()
}
// Полезная нагрузка сериализуется в JSON с полями, которые читает service
// worker клиента (web/public/sw.js).
func TestPayloadJSONShape(t *testing.T) {
payload := Payload{
Title: "Упоминание в #общий", Body: "привет", Kind: "mention",
GuildID: "1", ChannelID: "10", MessageID: "99", Tag: "channel-10", URL: "/app/1/10",
}
encoded, err := json.Marshal(payload)
if err != nil {
t.Fatalf("marshal: %v", err)
}
for _, field := range []string{`"title"`, `"body"`, `"kind"`, `"channel_id"`, `"tag"`, `"url"`} {
if !strings.Contains(string(encoded), field) {
t.Errorf("в payload нет поля %s: %s", field, encoded)
}
}
}
+235
View File
@@ -0,0 +1,235 @@
package push
import (
"strings"
"unicode"
"glchat/internal/store"
)
// Правила уведомлений: сервер повторяет логику desktop-уведомлений клиента
// (web/src/lib/desktopNotifications.ts, docs/client-tauri.md §5), потому что
// push уходит на устройство, где приложение не запущено и решить не может.
//
// Отличия от клиента ровно два и оба вынужденные:
// - «окно в фокусе и комната открыта» сервер не знает, поэтому вместо этого
// проверяется наличие активного соединения шлюза: если оно есть,
// уведомление покажет сам клиент по своим правилам;
// - «уведомления включены» на устройстве — это наличие подписки: отписался,
// значит выключил.
// previewLimit — сколько символов сообщения показываем в уведомлении.
const previewLimit = 140
// NotificationInput — данные, от которых зависит решение об уведомлении.
type NotificationInput struct {
// MessageType — тип сообщения (AGENT.md 7.6).
MessageType store.MessageType
Content string
Mentions []uint64
// AuthorID — автор сообщения (0 у системных сообщений и вебхуков).
AuthorID uint64
// MeID — получатель: свои сообщения не уведомляют.
MeID uint64
// IsDirect — личная беседа (в т.ч. групповая).
IsDirect bool
// ChannelName — имя комнаты для заголовка (у беседы — имя или пусто).
ChannelName string
// ChannelID — комната сообщения: по ней строится ссылка и ключ схлопывания.
ChannelID uint64
// MessageID — сообщение: клик открывает комнату и подсвечивает его.
MessageID uint64
// GuildID — сервер комнаты (0 — личная беседа).
GuildID uint64
// GuildName — имя сервера для текста уведомления.
GuildName string
// AuthorName — отображаемое имя автора.
AuthorName string
// Online — у получателя есть активное соединение шлюза.
Online bool
}
// NotificationFor возвращает уведомление или nil, если показывать не нужно.
// Чистая функция: её поведение проверяется тестами без сети.
func NotificationFor(input NotificationInput) *Payload {
// Системные сообщения (входы, баны) уведомлений не требуют.
if input.MessageType == store.MessageSystem {
return nil
}
if input.AuthorID != 0 && input.AuthorID == input.MeID {
return nil
}
mentionsMe := false
for _, mentioned := range input.Mentions {
if mentioned == input.MeID {
mentionsMe = true
break
}
}
if !mentionsMe && !input.IsDirect {
return nil
}
// Получатель в приложении: уведомление покажет клиент (и только если окно
// неактивно) — второй раз с телефона дёргать не нужно.
if input.Online {
return nil
}
body := previewText(input.Content)
if body == "" && len(input.Mentions) == 0 {
return nil
}
if body == "" {
// Вложение без текста: так же, как в клиенте.
body = "Вложение"
}
kind := "mention"
title := "Упоминание"
if input.IsDirect {
kind = "direct"
title = "Личное сообщение"
if input.ChannelName != "" {
title = "Личное сообщение — " + input.ChannelName
}
} else if input.ChannelName != "" {
title = "Упоминание в #" + input.ChannelName
}
channel := formatOptionalID(input.ChannelID)
tag := ""
url := "/app"
switch {
case input.GuildID != 0 && input.ChannelID != 0:
url = "/app/" + formatID(input.GuildID) + "/" + channel
tag = "channel-" + channel
case input.ChannelID != 0:
// Личная беседа: раздел друзей выбирает беседу по идентификатору.
url = "/app/friends/" + channel
tag = "channel-" + channel
}
return &Payload{
Title: title,
Body: body,
Kind: kind,
GuildID: formatOptionalID(input.GuildID),
GuildName: input.GuildName,
ChannelID: channel,
ChannelName: input.ChannelName,
AuthorID: formatOptionalID(input.AuthorID),
AuthorName: input.AuthorName,
MessageID: formatOptionalID(input.MessageID),
URL: url,
Tag: tag,
}
}
// previewText убирает Markdown-разметку и лишние переводы строк — та же
// подготовка, что в web/src/lib/desktopNotifications.ts.
func previewText(content string) string {
plain := stripCodeBlocks(content)
plain = stripInlineCode(plain)
plain = stripLinks(plain)
plain = strings.Map(func(r rune) rune {
if strings.ContainsRune("*_~>#", r) {
return -1
}
return r
}, plain)
plain = collapseSpaces(plain)
runes := []rune(plain)
if len(runes) <= previewLimit {
return plain
}
return strings.TrimRight(string(runes[:previewLimit-1]), " ") + "…"
}
// stripCodeBlocks убирает блоки ```…``` целиком, оставляя пометку.
func stripCodeBlocks(content string) string {
var builder strings.Builder
rest := content
for {
start := strings.Index(rest, "```")
if start < 0 {
builder.WriteString(rest)
return builder.String()
}
builder.WriteString(rest[:start])
builder.WriteString(" [код] ")
end := strings.Index(rest[start+3:], "```")
if end < 0 {
return builder.String()
}
rest = rest[start+3+end+3:]
}
}
// stripInlineCode убирает обратные кавычки, оставляя их содержимое.
func stripInlineCode(content string) string {
var builder strings.Builder
for {
start := strings.Index(content, "`")
if start < 0 {
builder.WriteString(content)
return builder.String()
}
builder.WriteString(content[:start])
rest := content[start+1:]
end := strings.Index(rest, "`")
if end < 0 {
builder.WriteString(rest)
return builder.String()
}
builder.WriteString(rest[:end])
content = rest[end+1:]
}
}
// stripLinks оставляет от ссылки её текст: [текст](url) → текст.
func stripLinks(content string) string {
var builder strings.Builder
rest := content
for {
open := strings.Index(rest, "](")
if open < 0 {
builder.WriteString(rest)
return builder.String()
}
start := strings.LastIndex(rest[:open], "[")
if start < 0 {
builder.WriteString(rest[:open+2])
rest = rest[open+2:]
continue
}
builder.WriteString(rest[:start])
builder.WriteString(rest[start+1 : open])
end := strings.Index(rest[open+2:], ")")
if end < 0 {
return builder.String()
}
rest = rest[open+2+end+1:]
}
}
// collapseSpaces схлопывает любые пробельные последовательности в один пробел.
func collapseSpaces(value string) string {
var builder strings.Builder
space := false
for _, r := range value {
if unicode.IsSpace(r) {
if !space && builder.Len() > 0 {
builder.WriteRune(' ')
}
space = true
continue
}
space = false
builder.WriteRune(r)
}
return strings.TrimSpace(builder.String())
}
// formatOptionalID печатает идентификатор в десятичном виде, пусто для нуля.
func formatOptionalID(id uint64) string {
if id == 0 {
return ""
}
return formatID(id)
}

Some files were not shown because too many files have changed in this diff Show More