72 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
263 changed files with 43772 additions and 515 deletions
+12
View File
@@ -38,6 +38,18 @@ 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
+64 -1
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)
@@ -186,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 \
+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
+85 -4
View File
@@ -11,6 +11,7 @@ import (
"strconv"
"time"
webpush "github.com/SherClockHolmes/webpush-go"
"github.com/pquerna/otp/totp"
"glchat/internal/auth"
@@ -37,10 +38,14 @@ func runCLI(args []string) int {
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
@@ -275,8 +280,6 @@ func cliSetInstanceAdmin(command string, args []string) int {
})
}
// cliCleanup запускает чистку вручную: сессии, аудит и файлы без ссылок
// (AGENT.md 6.4, 10.7).
// cliCreateUser создаёт аккаунты по команде обслуживания: регистрация на
// инстансе может быть выключена, а для нагрузочного теста (AGENT.md 11.4)
// нужны десятки пользователей. Пароль задаёт оператор, аккаунты создаются без
@@ -378,6 +381,51 @@ func cliCreateUser(args []string) int {
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, "сколько дней хранить журнал аудита (по умолчанию из конфигурации)")
@@ -399,8 +447,10 @@ func cliCleanup(args []string) int {
options.OrphanHours = *orphanHours
}
stats, err := retention.New(st, options).RunOnce(ctx)
fmt.Printf("удалено: сессий %d, записей аудита %d, файлов %d, файлов без записи %d (%d байт)\n",
stats.Sessions, stats.Audit, stats.Files, stats.Unknown, stats.Bytes)
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
})
}
@@ -415,3 +465,34 @@ func cliReindex(_ []string) int {
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
}
+10 -3
View File
@@ -38,12 +38,12 @@ func main() {
os.Exit(healthcheck())
}
// Подкоманды обслуживания (AGENT.md 10.7): bootstrap-admin, reset-password,
// make-admin, remove-admin, totp-setup, totp-reset, cleanup, reindex.
// Остальные команды реализует 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",
"create-user", "cleanup", "reindex":
"create-user", "delete-user", "cleanup", "reindex", "vapid-keys":
os.Exit(runCLI(os.Args[1:]))
}
}
@@ -161,6 +161,13 @@ 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,
+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
+40 -12
View File
@@ -36,15 +36,19 @@ glchat — управление инстансом
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 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 пересобрать индекс поиска (FTS5)
glchat vapid-keys сгенерировать пару VAPID-ключей для Web Push
glchat cleanup чистка: сессии, аудит, файлы без ссылок
glchat create-user создать аккаунты (--count/--prefix/--sessions)
glchat change-domain <domain> сменить домен
@@ -222,24 +226,46 @@ cmd_verify_backup() {
GLCHAT_ETC_DIR="${GLCHAT_ETC_DIR}" bash "${GLCHAT_ETC_DIR}/verify-backup.sh" "$@"
}
cmd_reindex() {
# 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 reindex
${COMPOSE} exec -T app glchat "$@" </dev/null
}
# cmd_reindex пересобирает индекс полнотекстового поиска (AGENT.md 10.7).
cmd_reindex() {
app_cli reindex
}
# cmd_vapid_keys печатает новую пару VAPID-ключей (Фаза 7): строки из вывода
# добавляются в .env инстанса вручную — смена ключа отзывает все подписки,
# поэтому автоматически её никто не делает.
cmd_vapid_keys() {
app_cli vapid-keys "$@"
}
# cmd_cleanup запускает чистку данных вручную: те же правила, что у
# периодического обслуживания (AGENT.md 6.4).
cmd_cleanup() {
require_stack
${COMPOSE} exec -T app glchat cleanup "$@"
app_cli cleanup "$@"
}
# cmd_create_user создаёт аккаунты командой обслуживания: регистрация может
# быть выключена, а для нагрузочного теста нужны десятки пользователей
# (AGENT.md 10.7, 11.4). Флаг --sessions сразу выдаёт сессии для генератора.
cmd_create_user() {
require_stack
${COMPOSE} exec -T app glchat create-user "$@"
app_cli create-user "$@"
}
# cmd_account обслуживает аккаунт: сброс пароля, права инстанс-админа, 2FA и
# мягкое удаление (AGENT.md 10.7) — тонкая обёртка над подкомандами бинарника.
cmd_account() {
app_cli "$@"
}
main() {
@@ -252,15 +278,17 @@ 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 "$@" ;;
+170 -14
View File
@@ -78,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:-}"
@@ -105,7 +110,11 @@ 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}"
# Версия образа: файл 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}"
@@ -122,9 +131,23 @@ GLCHAT_ASSUME_YES="${GLCHAT_ASSUME_YES:-0}"
TARGET_DIR="${GLCHAT_ETC_DIR:-/opt/glchat}"
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).
@@ -171,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 <путь> путь к ключу на внешнем узле
@@ -236,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 ;;
@@ -273,6 +301,7 @@ parse_args() {
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}"
@@ -390,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 считается включённым и когда сертификаты обслуживает внешний прокси
@@ -410,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
}
@@ -501,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"
@@ -622,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
@@ -631,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=""
@@ -664,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)"
@@ -672,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
@@ -716,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 не записывается"
@@ -751,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@
@@ -820,6 +958,12 @@ BACKUP_OFFSITE_TARGET=@BACKUP_OFFSITE_TARGET@
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@
@@ -856,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}
@@ -888,6 +1034,10 @@ 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:-}
@@ -1113,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))" \
@@ -1128,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}" \
@@ -1136,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" \
@@ -1592,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
@@ -1619,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. По умолчанию не выполняем её, чтобы
+8 -3
View File
@@ -59,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}" \
+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();
+12
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.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
+89 -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,10 +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.2 h1:X4Ksno9+x3cz0TZv69ec1hxP/+tymuR8PXQJyDwfh78=
github.com/fxamacker/cbor/v2 v2.9.2/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
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=
@@ -21,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=
@@ -34,18 +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.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)
}
+19 -2
View File
@@ -16,6 +16,8 @@ import (
"glchat/internal/crypto"
"glchat/internal/permissions"
"glchat/internal/store"
"github.com/go-webauthn/webauthn/webauthn"
)
var (
@@ -54,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 {
@@ -102,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,
@@ -111,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.
+48 -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
@@ -57,6 +62,15 @@ type Config struct {
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) {
@@ -67,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"),
@@ -94,6 +109,12 @@ func Load() (Config, error) {
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
@@ -140,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,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)
}
}
+83 -2
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 {
@@ -396,12 +433,56 @@ func (s *Service) SendToUser(userID uint64, event string, payload any) {
// (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 {
targets = append(targets, session)
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 {
+116
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")
+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) {
+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)
}
+134
View File
@@ -0,0 +1,134 @@
package push
import (
"strings"
"testing"
"glchat/internal/store"
)
// input — сообщение по умолчанию: обычное сообщение в комнате сервера.
// Набор проверок повторяет web/tests/desktopNotifications.test.ts: правила
// push и нативных уведомлений обязаны совпадать.
func input(overrides func(*NotificationInput)) NotificationInput {
value := NotificationInput{
MessageType: store.MessageDefault,
Content: "привет",
Mentions: nil,
AuthorID: 2,
MeID: 1,
IsDirect: false,
ChannelName: "общий",
ChannelID: 10,
MessageID: 99,
GuildID: 1,
GuildName: "Сервер",
AuthorName: "Аня",
}
if overrides != nil {
overrides(&value)
}
return value
}
func TestNotificationForMention(t *testing.T) {
payload := NotificationFor(input(func(in *NotificationInput) { in.Mentions = []uint64{1} }))
if payload == nil {
t.Fatal("упоминание не дало уведомления")
}
if payload.Title != "Упоминание в #общий" || payload.Body != "привет" {
t.Fatalf("неожиданный текст: %+v", payload)
}
if payload.Kind != "mention" || payload.ChannelID != "10" || payload.Tag != "channel-10" {
t.Fatalf("неожиданные поля: %+v", payload)
}
if payload.URL != "/app/1/10" {
t.Fatalf("ссылка = %q, ожидалось /app/1/10", payload.URL)
}
}
func TestNotificationForDirect(t *testing.T) {
payload := NotificationFor(input(func(in *NotificationInput) {
in.IsDirect = true
in.GuildID = 0
in.GuildName = ""
in.ChannelName = "Аня"
}))
if payload == nil {
t.Fatal("личное сообщение не дало уведомления")
}
if payload.Title != "Личное сообщение — Аня" || payload.Kind != "direct" {
t.Fatalf("неожиданный текст: %+v", payload)
}
if payload.URL != "/app/friends/10" {
t.Fatalf("ссылка = %q, ожидалось /app/friends/10", payload.URL)
}
}
func TestNotificationForSilentCases(t *testing.T) {
cases := map[string]NotificationInput{
"обычное сообщение в комнате": input(nil),
"своё сообщение": input(func(in *NotificationInput) {
in.IsDirect = true
in.AuthorID = in.MeID
}),
"системное сообщение": input(func(in *NotificationInput) {
in.IsDirect = true
in.MessageType = store.MessageSystem
}),
"получатель в клиенте": input(func(in *NotificationInput) {
in.IsDirect = true
in.Mentions = []uint64{1}
in.Online = true
}),
"упоминание другого пользователя": input(func(in *NotificationInput) {
in.Mentions = []uint64{777}
}),
"пустое сообщение без упоминаний": input(func(in *NotificationInput) {
in.IsDirect = true
in.Content = ""
}),
}
for name, value := range cases {
if payload := NotificationFor(value); payload != nil {
t.Errorf("%s: уведомление не должно отправляться (%+v)", name, payload)
}
}
}
// Упоминание с пустым текстом (одно вложение) — как в клиенте: «Вложение».
func TestNotificationForAttachmentOnly(t *testing.T) {
payload := NotificationFor(input(func(in *NotificationInput) {
in.IsDirect = true
in.Content = ""
in.Mentions = []uint64{1}
}))
if payload == nil || payload.Body != "Вложение" {
t.Fatalf("ожидалось «Вложение», получили %+v", payload)
}
}
func TestPreviewText(t *testing.T) {
if got := previewText("**жирный**\n\n[ссылка](https://example.com) `код`"); got != "жирный ссылка код" {
t.Fatalf("previewText = %q", got)
}
if got := previewText("```go\nfunc main() {}\n``` после"); got != "[код] после" {
t.Fatalf("блок кода = %q", got)
}
long := previewText(strings.Repeat("а", 400))
if len([]rune(long)) > previewLimit {
t.Fatalf("длина превью %d превышает лимит %d", len([]rune(long)), previewLimit)
}
if !strings.HasSuffix(long, "…") {
t.Fatalf("длинный текст не обрезан: %q", long)
}
}
func TestFormatOptionalID(t *testing.T) {
if got := formatOptionalID(0); got != "" {
t.Fatalf("нулевой идентификатор = %q, ожидалась пустая строка", got)
}
if got := formatOptionalID(42); got != "42" {
t.Fatalf("идентификатор = %q", got)
}
}
+29
View File
@@ -40,8 +40,19 @@ type Stats struct {
Unknown int64
// Bytes — сколько освободили на диске.
Bytes int64
// PushSubscriptions — мёртвые подписки Web Push (Фаза 7).
PushSubscriptions int64
// LinkPreviews — истёкшие записи кэша превью ссылок (Фаза 7).
LinkPreviews int64
}
// Мёртвые подписки Web Push и кэш превью ссылок (Фаза 7): подписка, которая
// не доставлялась неделю и накопила неудачи, и протухшее превью не нужны.
const (
stalePushAfterDays = 7
stalePushMaxFailures = 5
)
// Runner выполняет чистку по расписанию.
type Runner struct {
store *store.Store
@@ -118,6 +129,22 @@ func (r *Runner) RunOnce(ctx context.Context) (Stats, error) {
stats.Bytes += bytes
}
// 5. Мёртвые подписки Web Push: браузер мог отозвать подписку, а
// push-сервис — перестать отвечать. Живые подписки не трогаем.
cutoff := r.now().AddDate(0, 0, -stalePushAfterDays)
removed, err := r.store.DeleteStalePushSubscriptions(ctx, cutoff, stalePushMaxFailures)
if err != nil {
errs = append(errs, fmt.Errorf("push subscriptions: %w", err))
}
stats.PushSubscriptions = removed
// 6. Истёкший кэш превью ссылок: перезапрашивать его незачем.
previews, err := r.store.DeleteExpiredLinkPreviews(ctx, r.now())
if err != nil {
errs = append(errs, fmt.Errorf("link previews: %w", err))
}
stats.LinkPreviews = previews
stats2 := stats
if len(errs) > 0 {
return stats2, errors.Join(errs...)
@@ -235,6 +262,8 @@ func (r *Runner) Run(ctx context.Context, interval time.Duration) func() {
slog.Int64("audit", stats.Audit),
slog.Int64("files", stats.Files),
slog.Int64("unknown_files", stats.Unknown),
slog.Int64("push_subscriptions", stats.PushSubscriptions),
slog.Int64("link_previews", stats.LinkPreviews),
slog.Int64("bytes", stats.Bytes))
}
}
+5
View File
@@ -149,6 +149,11 @@ func humaError(err error) huma.StatusError {
return nil
}
converted := newAPIError(err)
if converted.Status >= http.StatusInternalServerError {
// Единственное место, где видно текст неожиданной ошибки: клиенту
// уходит «internal error», поэтому пишем причину в лог (AGENT.md 9.4).
slog.Error("unexpected API error", slog.String("code", converted.Code), slog.Any("error", err))
}
return &humaAPIError{status: converted.Status, code: converted.Code, message: converted.Message}
}
+702
View File
@@ -0,0 +1,702 @@
package server
import (
"context"
"errors"
"log/slog"
"net/http"
"time"
"github.com/danielgtaylor/huma/v2"
"glchat/internal/store"
"glchat/internal/voice"
)
// Звонки в личных и групповых беседах (AGENT.md 7.8, 7.14). Голос тот же, что
// в серверных комнатах — те же токены LiveKit и те же лимиты, — но серверных
// ролей здесь нет: позвонить может участник беседы, приглашение приходит
// событием DM_CALL_RING, а состояние звонка живёт в dm_calls.
const (
// maxDMCallParticipants — предел участников звонка (AGENT.md 7.8).
maxDMCallParticipants = 10
// dmCallRingTimeout — сколько звоним, прежде чем звонок станет пропущенным.
dmCallRingTimeout = time.Minute
// dmCallLimitWindow и dmCallLimitCount — не больше десяти новых звонков в
// минуту на пользователя: гудки не должны превращаться в спам.
dmCallLimitWindow = time.Minute
dmCallLimitCount = 10
)
type dmCallParticipantPayload 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"`
JoinedAt string `json:"joined_at,omitempty"`
LeftAt string `json:"left_at,omitempty"`
}
// dmCallPayload — звонок для API и Gateway: состояние, состав и длительность.
type dmCallPayload 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"`
EndedAt string `json:"ended_at,omitempty"`
EndedBy string `json:"ended_by,omitempty"`
EndReason string `json:"end_reason,omitempty"`
DurationSeconds int `json:"duration_seconds"`
Participants []dmCallParticipantPayload `json:"participants"`
}
type dmCallOutput struct {
Body struct {
Call dmCallPayload `json:"call"`
}
}
// dmLiveCallOutput — текущий звонок беседы: null, если никто не звонит.
type dmLiveCallOutput struct {
Body struct {
Call *dmCallPayload `json:"call"`
}
}
// dmCallJoinOutput — ответ на создание и принятие звонка: токен LiveKit и адрес
// сигналинга, как у входа в серверную голосовую комнату (AGENT.md 7.14).
type dmCallJoinOutput struct {
Body struct {
Call dmCallPayload `json:"call"`
Token string `json:"token"`
URL string `json:"url"`
Room string `json:"room"`
}
}
type dmCallHistoryOutput struct {
Body struct {
Calls []dmCallPayload `json:"calls"`
}
}
// registerDMCallRoutes описывает звонки бесед: начало, принятие, отклонение,
// выход, флаги микрофона и камеры, текущий звонок и историю.
func (s *Server) registerDMCallRoutes(api huma.API) {
security := []map[string][]string{{"sessionCookie": {}}, {"bearerAuth": {}}}
huma.Register(api, huma.Operation{
OperationID: "startDirectCall",
Method: http.MethodPost,
Path: "/channels/{channel_id}/call",
Summary: "Позвонить в личной беседе (выдаёт токен LiveKit)",
Tags: []string{"DirectCalls"},
Security: security,
}, func(ctx context.Context, input *struct {
ChannelID string `path:"channel_id"`
Body struct {
Media string `json:"media,omitempty" enum:"audio,video"`
}
},
) (*dmCallJoinOutput, error) {
user, _, err := requireUser(ctx)
if err != nil {
return nil, err
}
channel, err := s.groupDMForMember(ctx, input.ChannelID, user.ID)
if err != nil {
return nil, err
}
// Новые звонки ограничены: гудки не должны превращаться в спам.
if !user.IsInstanceAdmin {
if allowed, retryAfter := s.dmCallLimiter.Allow(dmCallUserKey(user.ID)); !allowed {
return nil, rateLimitedError(retryAfter)
}
}
clientURL, err := s.requireVoice()
if err != nil {
return nil, err
}
media := input.Body.Media
if media == "" {
media = dmCallMediaAudio
}
if media != dmCallMediaAudio && media != dmCallMediaVideo {
return nil, humaErrorStatus(http.StatusUnprocessableEntity, "validation.failed", "unknown call media")
}
// Кто получит приглашение: все участники беседы, кроме звонящего.
// Заблокировавшие его не приглашаются — блокировка запрещает личные
// сообщения в обе стороны (AGENT.md 7.2).
participants, err := s.store.DMParticipantProfiles(ctx, channel.ID, user.ID)
if err != nil {
return nil, humaError(err)
}
if len(participants) > maxDMCallParticipants {
return nil, humaErrorStatus(http.StatusConflict, "dm.call_limit", "too many participants for a call")
}
invitees := make([]uint64, 0, len(participants))
for _, participant := range participants {
if participant.UserID == user.ID {
continue
}
blocked, err := s.store.IsBlocked(ctx, user.ID, participant.UserID)
if err != nil {
return nil, humaError(err)
}
if blocked {
continue
}
invitees = append(invitees, participant.UserID)
}
call, err := s.store.CreateDMCall(ctx, channel.ID, user.ID, media, invitees)
if err != nil {
if errors.Is(err, store.ErrConflict) {
return nil, humaErrorStatus(http.StatusConflict, "dm.call_in_progress", "a call is already running")
}
return nil, humaError(err)
}
token, err := s.issueDMCallToken(user, call.ID)
if err != nil {
return nil, err
}
s.dispatchDMCallStart(call, user.ID)
output := &dmCallJoinOutput{}
output.Body.Call = s.dmCallPayload(call)
output.Body.Token = token
output.Body.URL = clientURL
output.Body.Room = voice.CallRoomName(call.ID)
return output, nil
})
huma.Register(api, huma.Operation{
OperationID: "acceptDirectCall",
Method: http.MethodPost,
Path: "/channels/{channel_id}/call/accept",
Summary: "Принять звонок в личной беседе",
Tags: []string{"DirectCalls"},
Security: security,
}, func(ctx context.Context, input *struct {
ChannelID string `path:"channel_id"`
},
) (*dmCallJoinOutput, error) {
user, _, err := requireUser(ctx)
if err != nil {
return nil, err
}
channel, err := s.groupDMForMember(ctx, input.ChannelID, user.ID)
if err != nil {
return nil, err
}
clientURL, err := s.requireVoice()
if err != nil {
return nil, err
}
call, err := s.store.GetLiveDMCall(ctx, channel.ID)
if err != nil {
return nil, humaErrorStatus(http.StatusNotFound, "dm.call_not_found", "no call is running")
}
invited, err := s.store.IsDMCallParticipant(ctx, call.ID, user.ID)
if err != nil {
return nil, humaError(err)
}
if !invited {
return nil, humaErrorStatus(http.StatusForbidden, "perm.denied", "you were not invited to this call")
}
// Лимит десяти участников: одиннадцатому в комнате места нет.
if joined, err := s.store.CountDMCallParticipants(ctx, call.ID, store.DMCallJoined); err != nil {
return nil, humaError(err)
} else if joined >= maxDMCallParticipants {
return nil, humaErrorStatus(http.StatusConflict, "dm.call_limit", "the call is full")
}
if _, err := s.store.JoinDMCallParticipant(ctx, call.ID, user.ID); err != nil {
return nil, humaError(err)
}
call, err = s.store.GetDMCall(ctx, call.ID)
if err != nil {
return nil, humaError(err)
}
token, err := s.issueDMCallToken(user, call.ID)
if err != nil {
return nil, err
}
s.dispatchDMCallUpdate(call)
output := &dmCallJoinOutput{}
output.Body.Call = s.dmCallPayload(call)
output.Body.Token = token
output.Body.URL = clientURL
output.Body.Room = voice.CallRoomName(call.ID)
return output, nil
})
huma.Register(api, huma.Operation{
OperationID: "declineDirectCall",
Method: http.MethodPost,
Path: "/channels/{channel_id}/call/decline",
Summary: "Отклонить звонок в личной беседе",
Tags: []string{"DirectCalls"},
Security: security,
}, func(ctx context.Context, input *struct {
ChannelID string `path:"channel_id"`
},
) (*dmCallOutput, error) {
user, _, err := requireUser(ctx)
if err != nil {
return nil, err
}
channel, err := s.groupDMForMember(ctx, input.ChannelID, user.ID)
if err != nil {
return nil, err
}
call, err := s.store.GetLiveDMCall(ctx, channel.ID)
if err != nil {
return nil, humaErrorStatus(http.StatusNotFound, "dm.call_not_found", "no call is running")
}
// Отклонение инициатора — это отмена звонка: остальные получают конец.
reason := store.DMCallReasonDeclined
if call.InitiatorID == user.ID {
reason = store.DMCallReasonCanceled
}
call, err = s.leaveDMCall(ctx, call, user.ID, store.DMCallDeclined, reason)
if err != nil {
return nil, err
}
output := &dmCallOutput{}
output.Body.Call = s.dmCallPayload(call)
return output, nil
})
huma.Register(api, huma.Operation{
OperationID: "endDirectCall",
Method: http.MethodPost,
Path: "/channels/{channel_id}/call/end",
Summary: "Выйти из звонка или завершить его",
Tags: []string{"DirectCalls"},
Security: security,
}, func(ctx context.Context, input *struct {
ChannelID string `path:"channel_id"`
},
) (*dmCallOutput, error) {
user, _, err := requireUser(ctx)
if err != nil {
return nil, err
}
channel, err := s.groupDMForMember(ctx, input.ChannelID, user.ID)
if err != nil {
return nil, err
}
call, err := s.store.GetLiveDMCall(ctx, channel.ID)
if err != nil {
return nil, humaErrorStatus(http.StatusNotFound, "dm.call_not_found", "no call is running")
}
reason := store.DMCallReasonCompleted
if call.Status == store.DMCallRinging {
reason = store.DMCallReasonCanceled
if call.InitiatorID != user.ID {
reason = store.DMCallReasonDeclined
}
}
call, err = s.leaveDMCall(ctx, call, user.ID, store.DMCallLeft, reason)
if err != nil {
return nil, err
}
output := &dmCallOutput{}
output.Body.Call = s.dmCallPayload(call)
return output, nil
})
huma.Register(api, huma.Operation{
OperationID: "updateMyDirectCallState",
Method: http.MethodPatch,
Path: "/channels/{channel_id}/call/@me",
Summary: "Обновить свои флаги в звонке (микрофон, звук, камера, экран)",
Tags: []string{"DirectCalls"},
Security: security,
}, func(ctx context.Context, input *struct {
ChannelID string `path:"channel_id"`
Body struct {
SelfMute *bool `json:"self_mute,omitempty"`
SelfDeaf *bool `json:"self_deaf,omitempty"`
Camera *bool `json:"camera,omitempty"`
Screen *bool `json:"screen,omitempty"`
}
},
) (*dmCallOutput, error) {
user, _, err := requireUser(ctx)
if err != nil {
return nil, err
}
channel, err := s.groupDMForMember(ctx, input.ChannelID, user.ID)
if err != nil {
return nil, err
}
call, err := s.store.GetLiveDMCall(ctx, channel.ID)
if err != nil {
return nil, humaErrorStatus(http.StatusNotFound, "dm.call_not_found", "no call is running")
}
current := dmCallParticipant(call, user.ID)
if current == nil || current.State != store.DMCallJoined {
return nil, humaErrorStatus(http.StatusForbidden, "perm.denied", "you are not in this call")
}
flags := store.DMCallFlags{
SelfMute: current.SelfMute,
SelfDeaf: current.SelfDeaf,
Camera: current.Camera,
Screen: current.Screen,
}
if input.Body.SelfMute != nil {
flags.SelfMute = *input.Body.SelfMute
}
if input.Body.SelfDeaf != nil {
flags.SelfDeaf = *input.Body.SelfDeaf
}
if input.Body.Camera != nil {
flags.Camera = *input.Body.Camera
}
if input.Body.Screen != nil {
flags.Screen = *input.Body.Screen
}
if err := s.store.UpdateDMCallFlags(ctx, call.ID, user.ID, flags); err != nil {
return nil, humaError(err)
}
updated, err := s.store.GetDMCall(ctx, call.ID)
if err != nil {
return nil, humaError(err)
}
s.dispatchDMCallUpdate(updated)
output := &dmCallOutput{}
output.Body.Call = s.dmCallPayload(updated)
return output, nil
})
huma.Register(api, huma.Operation{
OperationID: "getDirectCall",
Method: http.MethodGet,
Path: "/channels/{channel_id}/call",
Summary: "Текущий звонок беседы",
Tags: []string{"DirectCalls"},
Security: security,
}, func(ctx context.Context, input *struct {
ChannelID string `path:"channel_id"`
},
) (*dmLiveCallOutput, error) {
user, _, err := requireUser(ctx)
if err != nil {
return nil, err
}
channel, err := s.groupDMForMember(ctx, input.ChannelID, user.ID)
if err != nil {
return nil, err
}
output := &dmLiveCallOutput{}
call, err := s.store.GetLiveDMCall(ctx, channel.ID)
if errors.Is(err, store.ErrNotFound) {
return output, nil
}
if err != nil {
return nil, humaError(err)
}
payload := s.dmCallPayload(call)
output.Body.Call = &payload
return output, nil
})
huma.Register(api, huma.Operation{
OperationID: "listDirectCalls",
Method: http.MethodGet,
Path: "/channels/{channel_id}/calls",
Summary: "История звонков беседы",
Tags: []string{"DirectCalls"},
Security: security,
}, func(ctx context.Context, input *struct {
ChannelID string `path:"channel_id"`
Limit int `query:"limit" default:"50" minimum:"1" maximum:"100"`
Before string `query:"before,omitempty"`
},
) (*dmCallHistoryOutput, error) {
user, _, err := requireUser(ctx)
if err != nil {
return nil, err
}
channel, err := s.groupDMForMember(ctx, input.ChannelID, user.ID)
if err != nil {
return nil, err
}
var beforeID uint64
if input.Before != "" {
if beforeID, err = parseID("before", input.Before); err != nil {
return nil, err
}
}
calls, err := s.store.ListDMCalls(ctx, channel.ID, input.Limit, beforeID)
if err != nil {
return nil, humaError(err)
}
output := &dmCallHistoryOutput{}
output.Body.Calls = make([]dmCallPayload, 0, len(calls))
for index := range calls {
output.Body.Calls = append(output.Body.Calls, s.dmCallPayload(&calls[index]))
}
return output, nil
})
}
// Принятые значения media.
const (
dmCallMediaAudio = "audio"
dmCallMediaVideo = "video"
)
// dmCallUserKey ключует лимит звонков по пользователю.
func dmCallUserKey(userID uint64) string {
return "user:" + formatSnowflake(userID)
}
// requireVoice проверяет, что голос на инстансе включён, и отдаёт адрес
// сигналинга для клиента. Выключенный голос — понятный отказ voice.disabled,
// а не сломанный интерфейс звонка (AGENT.md 7.14).
func (s *Server) requireVoice() (string, error) {
if !s.voice.Enabled() {
return "", humaErrorStatus(http.StatusServiceUnavailable, "voice.disabled", "голос на инстансе не настроен")
}
clientURL := s.voice.VoiceURLForClient(s.cfg.LiveKitURL)
if clientURL == "" {
return "", humaErrorStatus(http.StatusServiceUnavailable, "voice.disabled", "голос на инстансе не настроен")
}
return clientURL, nil
}
// issueDMCallToken выдаёт участнику токен LiveKit той же формы, что и вход в
// серверную голосовую комнату: серверных ролей в беседе нет, поэтому права на
// публикацию есть у каждого участника звонка.
func (s *Server) issueDMCallToken(user *store.User, callID uint64) (string, error) {
return s.voice.Issue(voice.CallRoomName(callID), formatSnowflake(user.ID), user.DisplayName, voice.Grants{
RoomJoin: true, CanPublish: true, CanSubscribe: true, CanPublishData: true,
})
}
// dmCallParticipant ищет участника звонка в составе.
func dmCallParticipant(call *store.DMCallWithParticipants, userID uint64) *store.DMCallParticipant {
for index := range call.Participants {
if call.Participants[index].UserID == userID {
return &call.Participants[index]
}
}
return nil
}
// leaveDMCall помечает участника вышедшим или отклонившим и завершает звонок,
// когда в комнате больше никого не осталось. В беседе вдвоём уход любого
// заканчивает звонок; в групповой — только когда вышли все.
func (s *Server) leaveDMCall(ctx context.Context, call *store.DMCallWithParticipants, userID uint64, state, reason string) (*store.DMCallWithParticipants, error) {
if dmCallParticipant(call, userID) == nil {
return nil, humaErrorStatus(http.StatusForbidden, "perm.denied", "you were not invited to this call")
}
if err := s.store.LeaveDMCallParticipant(ctx, call.ID, userID, state); err != nil {
return nil, humaError(err)
}
s.removeFromLiveKitRoom(ctx, voice.CallRoomName(call.ID), userID)
joined, err := s.store.CountDMCallParticipants(ctx, call.ID, store.DMCallJoined)
if err != nil {
return nil, humaError(err)
}
lastOne := joined == 0 || len(call.Participants) <= 2
// Гудки, на которые никто не ответил, тоже заканчиваются: ждать больше некого.
if call.Status == store.DMCallRinging {
waiting, err := s.store.CountDMCallParticipants(ctx, call.ID, store.DMCallInvited)
if err != nil {
return nil, humaError(err)
}
if waiting == 0 {
lastOne = true
}
}
if lastOne {
if err := s.store.EndDMCall(ctx, call.ID, &userID, reason); err != nil {
return nil, humaError(err)
}
call, err = s.store.GetDMCall(ctx, call.ID)
if err != nil {
return nil, humaError(err)
}
// Комнату закрываем со стороны SFU: иначе участники, чьи клиенты не
// успели получить событие, остались бы говорить.
s.removeCallParticipantsFromLiveKit(ctx, call)
s.dispatchDMCallEnd(call)
return call, nil
}
call, err = s.store.GetDMCall(ctx, call.ID)
if err != nil {
return nil, humaError(err)
}
s.dispatchDMCallUpdate(call)
return call, nil
}
// finishDMCall завершает звонок целиком (сторож и вебхук LiveKit).
func (s *Server) finishDMCall(ctx context.Context, call *store.DMCallWithParticipants, reason string, endedBy *uint64) error {
if err := s.store.EndDMCall(ctx, call.ID, endedBy, reason); err != nil {
return err
}
finished, err := s.store.GetDMCall(ctx, call.ID)
if err != nil {
return err
}
s.removeCallParticipantsFromLiveKit(ctx, finished)
s.dispatchDMCallEnd(finished)
return nil
}
// removeCallParticipantsFromLiveKit убирает из комнаты всех, кто в ней был.
func (s *Server) removeCallParticipantsFromLiveKit(ctx context.Context, call *store.DMCallWithParticipants) {
if s.voiceAdmin == nil || !s.voiceAdmin.Enabled() {
return
}
room := voice.CallRoomName(call.ID)
for _, participant := range call.Participants {
if participant.State != store.DMCallJoined {
continue
}
if err := s.voiceAdmin.RemoveParticipant(ctx, room, formatSnowflake(participant.UserID)); err != nil {
s.logger.DebugContext(ctx, "livekit call remove skipped", slogAnyError(err))
}
}
}
// removeFromLiveKitRoom убирает одного участника из произвольной комнаты.
func (s *Server) removeFromLiveKitRoom(ctx context.Context, room string, userID uint64) {
if s.voiceAdmin == nil || !s.voiceAdmin.Enabled() {
return
}
if err := s.voiceAdmin.RemoveParticipant(ctx, room, formatSnowflake(userID)); err != nil {
s.logger.DebugContext(ctx, "livekit remove skipped", slogAnyError(err))
}
}
// dispatchDMCallStart рассылает приглашение остальным участникам беседы, а
// инициатору — обновление: у него может быть открыто второе устройство.
func (s *Server) dispatchDMCallStart(call *store.DMCallWithParticipants, initiatorID uint64) {
if s.gateway == nil {
return
}
payload := s.dmCallPayload(call)
for _, participant := range call.Participants {
if participant.UserID == initiatorID {
s.gateway.SendToUser(participant.UserID, "DM_CALL_UPDATE", payload)
continue
}
s.gateway.SendToUser(participant.UserID, "DM_CALL_RING", payload)
}
}
// dispatchDMCallUpdate сообщает участникам звонка об изменении состояния.
func (s *Server) dispatchDMCallUpdate(call *store.DMCallWithParticipants) {
if s.gateway == nil {
return
}
payload := s.dmCallPayload(call)
for _, participant := range call.Participants {
s.gateway.SendToUser(participant.UserID, "DM_CALL_UPDATE", payload)
}
}
// dispatchDMCallEnd сообщает о завершении звонка: клиенты гасят рингтон,
// убирают маркер «идёт звонок» и показывают итог.
func (s *Server) dispatchDMCallEnd(call *store.DMCallWithParticipants) {
if s.gateway == nil {
return
}
payload := s.dmCallPayload(call)
for _, participant := range call.Participants {
s.gateway.SendToUser(participant.UserID, "DM_CALL_END", payload)
}
}
// dmCallPayload собирает представление звонка для API и Gateway.
func (s *Server) dmCallPayload(call *store.DMCallWithParticipants) dmCallPayload {
payload := dmCallPayload{
ID: formatSnowflake(call.ID),
ChannelID: formatSnowflake(call.ChannelID),
InitiatorID: formatSnowflake(call.InitiatorID),
Status: call.Status,
Media: call.Media,
CreatedAt: call.CreatedAt.UTC().Format(time.RFC3339),
EndReason: call.EndReason,
DurationSeconds: s.store.DMCallDurationSeconds(&call.DMCall),
Participants: make([]dmCallParticipantPayload, 0, len(call.Participants)),
}
if call.StartedAt != nil {
payload.StartedAt = call.StartedAt.UTC().Format(time.RFC3339)
}
if call.EndedAt != nil {
payload.EndedAt = call.EndedAt.UTC().Format(time.RFC3339)
}
if call.EndedBy != nil {
payload.EndedBy = formatSnowflake(*call.EndedBy)
}
for _, participant := range call.Participants {
entry := dmCallParticipantPayload{
UserID: formatSnowflake(participant.UserID),
State: participant.State,
SelfMute: participant.SelfMute,
SelfDeaf: participant.SelfDeaf,
Camera: participant.Camera,
Screen: participant.Screen,
}
if participant.JoinedAt != nil {
entry.JoinedAt = participant.JoinedAt.UTC().Format(time.RFC3339)
}
if participant.LeftAt != nil {
entry.LeftAt = participant.LeftAt.UTC().Format(time.RFC3339)
}
payload.Participants = append(payload.Participants, entry)
}
return payload
}
// liveCallPayload отдаёт текущий звонок беседы для READY-снапшота: nil, если
// никто не звонит (AGENT.md 8.3).
func (s *Server) liveCallPayload(ctx context.Context, channelID uint64) *dmCallPayload {
call, err := s.store.GetLiveDMCall(ctx, channelID)
if err != nil {
return nil
}
payload := s.dmCallPayload(call)
return &payload
}
// expireStaleDMCalls завершает звонки, о которых уже никто не помнит: гудки,
// на которые минуту никто не ответил, и комнаты, из которых все вышли
// (вебхук LiveKit мог потеряться). Вызывается сторожем голосовых состояний.
func (s *Server) expireStaleDMCalls(ctx context.Context) {
now := time.Now().UTC()
calls, err := s.store.ListStaleDMCalls(ctx, now.Add(-dmCallRingTimeout), now.Add(-time.Minute))
if err != nil {
s.logger.WarnContext(ctx, "failed to list stale dm calls", slog.Any("error", err))
return
}
for index := range calls {
call, err := s.store.GetDMCall(ctx, calls[index].ID)
if err != nil {
continue
}
reason := store.DMCallReasonMissed
if calls[index].Status == store.DMCallActive {
reason = store.DMCallReasonCompleted
}
if err := s.finishDMCall(ctx, call, reason, nil); err != nil {
s.logger.WarnContext(ctx, "failed to finish stale dm call", slog.Any("error", err))
continue
}
s.logger.InfoContext(ctx, "stale dm call finished",
slog.String("call_id", formatSnowflake(call.ID)), slog.String("reason", reason))
}
}
// slogAnyError прячет ошибку RoomService в атрибут лога.
func slogAnyError(err error) slog.Attr { return slog.Any("error", err) }
+182
View File
@@ -0,0 +1,182 @@
package server
import (
"bytes"
"context"
"io"
"net/http"
"strings"
"github.com/go-chi/chi/v5"
"glchat/internal/store"
)
// Иконка групповой беседы (AGENT.md 7.8, D-070): обычная загрузка с
// назначением dm_icon, поэтому анимированные GIF/APNG/WebP не перекодируются и
// не теряют анимацию (D-042). Менять иконку может только владелец беседы; у
// 1:1 своей иконки нет — в интерфейсе показывается аватар собеседника.
// registerDMIconRoutes описывает загрузку и снятие иконки беседы: huma не
// принимает multipart, поэтому ручки живут на роутере.
func (s *Server) registerDMIconRoutes(router chi.Router) {
router.Post("/channels/{channel_id}/icon", s.handleDMChannelIconUpload)
router.Delete("/channels/{channel_id}/icon", s.handleDMChannelIconDelete)
}
// groupDMForIcon проверяет, что канал — групповая беседа, а пользователь —
// её владелец. Посторонним отвечаем 404 (существование чужой беседы не
// подтверждаем, D-070), участнику-не-владельцу — 403, у 1:1 своей иконки нет.
func (s *Server) groupDMForIcon(ctx context.Context, rawChannelID string, userID uint64) (*store.Channel, error) {
channelID, err := parseID("channel_id", rawChannelID)
if err != nil {
return nil, err
}
channel, err := s.store.GetChannel(ctx, channelID)
if err != nil {
return nil, humaError(err)
}
if channel.Type != store.ChannelDM || channel.GuildID != nil {
return nil, humaErrorStatus(http.StatusNotFound, "not_found", "conversation not found")
}
participant, err := s.store.IsDMParticipant(ctx, channelID, userID)
if err != nil {
return nil, humaError(err)
}
if !participant {
return nil, humaErrorStatus(http.StatusNotFound, "not_found", "conversation not found")
}
if channel.DMOwnerID == nil {
// 1:1: иконка беседы — аватар собеседника, своей у неё нет.
return nil, humaErrorStatus(http.StatusUnprocessableEntity, "dm.not_group",
"a direct conversation shows the recipient's avatar and has no icon")
}
if *channel.DMOwnerID != userID {
return nil, humaErrorStatus(http.StatusForbidden, "perm.denied",
"only the group owner can change the conversation icon")
}
return channel, nil
}
// handleDMChannelIconUpload принимает иконку групповой беседы (владелец).
func (s *Server) handleDMChannelIconUpload(w http.ResponseWriter, r *http.Request) {
user, _, ok := s.authenticate(w, r)
if !ok {
return
}
ctx := r.Context()
channel, err := s.groupDMForIcon(ctx, chi.URLParam(r, "channel_id"), user.ID)
if err != nil {
writeHumaAPIError(w, err)
return
}
// Загрузки: 10 в минуту и 100 в сутки на пользователя (AGENT.md 8.6).
if err := s.checkUploadLimit(user); err != nil {
writeHumaAPIError(w, err)
return
}
// Иконка — медиа аватарного размера (AGENT.md 7.7): лимит тот же, что у
// аватаров и оформления. Тело ограничено и по объёму, и по памяти.
limit := s.mediaLimit(ctx, mediaAvatar)
r.Body = http.MaxBytesReader(w, r.Body, limit+maxMultipartOverhead)
if err := r.ParseMultipartForm(maxMultipartMemory); err != nil { //nolint:gosec // объём ограничен MaxBytesReader выше
httpxWriteJSONError(w, http.StatusBadRequest, "request.bad", "malformed multipart body")
return
}
defer func() {
if r.MultipartForm != nil {
_ = r.MultipartForm.RemoveAll()
}
}()
file, header, err := r.FormFile("file")
if err != nil {
httpxWriteJSONError(w, http.StatusBadRequest, "request.bad", `multipart field "file" is required`)
return
}
defer func() { _ = file.Close() }()
if header.Size > limit {
httpxWriteJSONError(w, http.StatusRequestEntityTooLarge, "file.too_large", "conversation icon is too large")
return
}
// Картинку читаем в память: нужно убедиться, что это изображение, а не
// переименованный файл (AGENT.md 9.2).
data, err := io.ReadAll(io.LimitReader(file, limit+1))
if err != nil || int64(len(data)) > limit {
httpxWriteJSONError(w, http.StatusRequestEntityTooLarge, "file.too_large", "conversation icon is too large")
return
}
contentType := header.Header.Get("Content-Type")
if !strings.HasPrefix(contentType, "image/") {
contentType = http.DetectContentType(data)
}
if !strings.HasPrefix(contentType, "image/") {
httpxWriteJSONError(w, http.StatusUnprocessableEntity, "validation.failed", "conversation icon must be an image")
return
}
previous := channel.IconFileID
channelID := channel.ID
stored, err := s.saveUpload(ctx, store.File{
UploaderID: &user.ID,
ChannelID: &channelID,
Filename: sanitizeFilename(header.Filename),
ContentType: contentType,
Purpose: "dm_icon",
}, bytes.NewReader(data))
if err != nil {
writeHumaAPIError(w, err)
return
}
fileID := stored.ID
updated, err := s.store.UpdateChannel(ctx, channel.ID, store.UpdateChannelParams{IconFileID: &fileID})
if err != nil {
// Новая иконка не прижилась: убираем загруженный файл, старая остаётся.
s.deleteStoredFile(ctx, stored.ID)
writeHumaAPIError(w, humaError(err))
return
}
// Предыдущий файл удаляем только после успешной замены: загрузки не должны
// копиться (AGENT.md 7.7), но и терять старую иконку при сбое нельзя.
if previous != nil && *previous != 0 {
s.deleteStoredFile(ctx, *previous)
}
s.dispatchDMUpdate(updated, []uint64{}, 0)
s.writeDMChannel(ctx, w, updated, user.ID)
}
// handleDMChannelIconDelete снимает иконку групповой беседы (владелец).
func (s *Server) handleDMChannelIconDelete(w http.ResponseWriter, r *http.Request) {
user, _, ok := s.authenticate(w, r)
if !ok {
return
}
ctx := r.Context()
channel, err := s.groupDMForIcon(ctx, chi.URLParam(r, "channel_id"), user.ID)
if err != nil {
writeHumaAPIError(w, err)
return
}
previous := channel.IconFileID
updated, err := s.store.UpdateChannel(ctx, channel.ID, store.UpdateChannelParams{ClearIcon: true})
if err != nil {
writeHumaAPIError(w, humaError(err))
return
}
if previous != nil && *previous != 0 {
s.deleteStoredFile(ctx, *previous)
}
s.dispatchDMUpdate(updated, []uint64{}, 0)
s.writeDMChannel(ctx, w, updated, user.ID)
}
// writeDMChannel отдаёт обновлённую беседу в формате `dmChannelPayload` — так
// же, как переименование: клиенту не нужен отдельный запрос за иконкой.
func (s *Server) writeDMChannel(ctx context.Context, w http.ResponseWriter, channel *store.Channel, viewerID uint64) {
summary, err := s.groupDMSummary(ctx, channel, viewerID)
if err != nil {
writeHumaAPIError(w, err)
return
}
httpxWriteJSON(w, http.StatusOK, map[string]any{"channel": s.dmChannelPayload(ctx, summary)})
}
+12
View File
@@ -378,6 +378,18 @@ func (s *Server) requireFileAccess(ctx context.Context, rawFileID string, user *
file.Purpose == "invite_background" {
return file, nil
}
// Иконка групповой беседы видна только её участникам: личные беседы
// приватны, посторонним файл не отдаём (AGENT.md 7.8, 7.19, 9.2).
if file.Purpose == "dm_icon" && file.ChannelID != nil {
participant, err := s.store.IsDMParticipant(ctx, *file.ChannelID, user.ID)
if err != nil {
return nil, humaError(err)
}
if !participant {
return nil, humaErrorStatus(http.StatusNotFound, "not_found", "file not found")
}
return file, nil
}
// Фон комнаты виден тем, кто видит саму комнату (проверка ниже по каналу).
if file.ChannelID != nil {
if _, _, _, err := s.requireChannelPermission(ctx, formatSnowflake(*file.ChannelID), user, permissions.ViewChannel); err != nil {
+26 -5
View File
@@ -271,6 +271,9 @@ func (s *Server) registerGuildCoreRoutes(api huma.API) {
return output, nil
})
// Личные шаблоны серверов (Фаза 7): сохранить структуру, список и удаление.
s.registerUserGuildTemplateRoutes(api, security)
huma.Register(api, huma.Operation{
OperationID: "getGuild",
Method: http.MethodGet,
@@ -355,6 +358,9 @@ func (s *Server) registerGuildCoreRoutes(api huma.API) {
}
s.invalidateGuild(guildID)
s.dispatchGuildUpdate(*guild)
// Правка сервера попадает в журнал: раньше её не было ни у владельца,
// ни у инстанс-админа (AGENT.md 11.5).
s.recordAudit(ctx, user, guildID, "guild.update", "guild", &guildID, "")
return s.guildOutput(ctx, user, *guild)
})
@@ -596,6 +602,8 @@ func (s *Server) registerChannelRoutes(api huma.API) {
"name": channel.Name,
"type": string(channel.Type),
})
// Создание комнаты (в том числе категории) — в журнале сервера.
s.recordAudit(ctx, user, guildID, "channel.create", "channel", &channel.ID, "")
s.invalidateGuild(guildID)
// Видимость комнаты считается персонально, поэтому вместо тела комнаты
// (в нём can_view был бы нулевым) просим клиентов перечитать список.
@@ -659,6 +667,7 @@ func (s *Server) registerChannelRoutes(api huma.API) {
return nil, humaError(err)
}
s.invalidateGuild(guildID)
s.recordAudit(ctx, user, guildID, "channel.update", "channel", &channelID, "")
s.dispatchGuildEvent(guildID, "CHANNEL_UPDATE", channelEventPayload(*channel))
s.dispatchGuildEvent(guildID, "GUILD_CHANNELS_SYNC", map[string]any{
"guild_id": formatSnowflake(guildID),
@@ -838,6 +847,7 @@ func (s *Server) registerChannelRoutes(api huma.API) {
return nil, humaError(err)
}
s.invalidateGuild(guildID)
s.recordAudit(ctx, user, guildID, "channel.delete", "channel", &channelID, "")
s.dispatchGuildEvent(guildID, "CHANNEL_DELETE", map[string]any{
"channel_id": formatSnowflake(channelID),
"guild_id": formatSnowflake(guildID),
@@ -1108,6 +1118,15 @@ func (s *Server) registerMemberRoutes(api huma.API) {
return nil, humaError(err)
}
s.invalidateGuild(guildID)
// Ник и тайм-аут участника тоже действия модерации: журнал сервера
// должен показывать и их (AGENT.md 11.5).
if nickname != nil {
s.recordAudit(ctx, user, guildID, "member.update", "user", &targetID, "")
}
if timeoutUntil != nil {
s.recordAudit(ctx, user, guildID, "member.timeout", "user", &targetID,
"until "+timeoutUntil.UTC().Format(time.RFC3339))
}
payload := map[string]any{
"guild_id": formatSnowflake(guildID),
"user_id": formatSnowflake(targetID),
@@ -1796,11 +1815,12 @@ func (s *Server) recordAudit(ctx context.Context, actor *store.User, guildID uin
}
}
// createGuild создаёт сервер с ролями по умолчанию и общей комнатой (AGENT.md 6.3).
// createGuild создаёт сервер с ролями по умолчанию и структурой выбранного
// шаблона — встроенного или личного (AGENT.md 6.3, 7.3).
func (s *Server) createGuild(ctx context.Context, owner *store.User, name, templateID string) (*store.Guild, error) {
template, known := findGuildTemplate(templateID)
if !known {
return nil, humaErrorStatus(http.StatusUnprocessableEntity, "validation.failed", "unknown guild template")
template, templateReason, err := s.resolveGuildTemplate(ctx, owner, templateID)
if err != nil {
return nil, err
}
settings, err := s.store.InstanceSettings(ctx)
if err != nil {
@@ -1844,7 +1864,8 @@ func (s *Server) createGuild(ctx context.Context, owner *store.User, name, templ
return nil, humaError(err)
}
}
s.recordAudit(ctx, owner, guild.ID, "guild.create", "guild", &guild.ID, "template="+template.ID)
// Причина аудита: встроенный шаблон или personal:<id> у личного (7.3).
s.recordAudit(ctx, owner, guild.ID, "guild.create", "guild", &guild.ID, templateReason)
if bypassed {
s.recordAudit(ctx, owner, guild.ID, "limits.bypass", "guild", &guild.ID, "instance admin bypassed guild limit")
}
+717 -22
View File
@@ -3,15 +3,21 @@ package server
import (
"context"
"crypto/rand"
"encoding/csv"
"encoding/json"
"errors"
"log/slog"
"net/http"
"strconv"
"strings"
"time"
"github.com/danielgtaylor/huma/v2"
"github.com/go-chi/chi/v5"
"glchat/internal/bootstrap"
"glchat/internal/store"
"glchat/internal/sysinfo"
)
// instancePayload — публичная информация об инстансе (AGENT.md 6.5).
@@ -49,6 +55,9 @@ type instanceGuildPayload struct {
type instanceGuildListOutput struct {
Body struct {
Guilds []instanceGuildPayload `json:"guilds"`
// Total — сколько серверов попадает в фильтр: панель листает страницы
// (AGENT.md 7.18).
Total int `json:"total"`
}
}
@@ -260,32 +269,51 @@ func (s *Server) registerInstanceRoutes(api huma.API) {
Summary: "Все серверы инстанса",
Tags: []string{"Instance"},
Security: []map[string][]string{{"sessionCookie": {}}, {"bearerAuth": {}}},
}, func(ctx context.Context, _ *struct{}) (*instanceGuildListOutput, error) {
}, func(ctx context.Context, input *struct {
// Фильтры списка серверов (AGENT.md 7.18): поиск по названию, владелец,
// только главный сервер, плюс страница выдачи.
Query string `query:"q" maxLength:"64"`
OwnerID string `query:"owner_id"`
Main bool `query:"main"`
Limit int `query:"limit" default:"50" minimum:"1" maximum:"200"`
Offset int `query:"offset" default:"0" minimum:"0"`
},
) (*instanceGuildListOutput, error) {
if _, err := requireInstanceAdmin(ctx); err != nil {
return nil, err
}
guilds, err := s.store.ListAllGuilds(ctx)
var ownerID uint64
if input.OwnerID != "" {
parsed, err := parseID("owner_id", input.OwnerID)
if err != nil {
return nil, err
}
ownerID = parsed
}
rows, total, err := s.store.ListInstanceGuilds(ctx, store.InstanceGuildFilter{
Query: input.Query,
OwnerID: ownerID,
MainOnly: input.Main,
Limit: input.Limit,
Offset: input.Offset,
})
if err != nil {
return nil, humaError(err)
}
output := &instanceGuildListOutput{}
output.Body.Guilds = make([]instanceGuildPayload, 0, len(guilds))
for _, guild := range guilds {
payload := instanceGuildPayload{
ID: formatSnowflake(guild.ID),
Name: guild.Name,
OwnerID: formatSnowflake(guild.OwnerID),
IsMain: guild.IsMain,
CreatedAt: guild.CreatedAt.UTC().Format(time.RFC3339),
}
if owner, err := s.store.GetUser(ctx, guild.OwnerID); err == nil {
payload.OwnerName = owner.Username
}
if count, err := s.store.CountGuildMembers(ctx, guild.ID); err == nil {
payload.MemberCount = count
}
output.Body.Guilds = append(output.Body.Guilds, payload)
output.Body.Guilds = make([]instanceGuildPayload, 0, len(rows))
for _, row := range rows {
output.Body.Guilds = append(output.Body.Guilds, instanceGuildPayload{
ID: formatSnowflake(row.ID),
Name: row.Name,
OwnerID: formatSnowflake(row.OwnerID),
OwnerName: row.OwnerName,
IsMain: row.IsMain,
MemberCount: row.MemberCount,
CreatedAt: row.CreatedAt.UTC().Format(time.RFC3339),
})
}
output.Body.Total = total
return output, nil
})
@@ -733,18 +761,238 @@ func (s *Server) registerInstanceRoutes(api huma.API) {
Tags: []string{"Instance"},
Security: []map[string][]string{{"sessionCookie": {}}, {"bearerAuth": {}}},
}, func(ctx context.Context, input *struct {
Limit int `query:"limit" default:"50" minimum:"1" maximum:"200"`
// Фильтры журнала (AGENT.md 7.10, 7.18): действие, актор, цель, сервер
// и период. Даты — в формате YYYY-MM-DD (UTC).
Action string `query:"action" maxLength:"64"`
ActorID string `query:"actor_id"`
TargetID string `query:"target_id"`
GuildID string `query:"guild_id"`
Since string `query:"since" maxLength:"10"`
Until string `query:"until" maxLength:"10"`
Limit int `query:"limit" default:"50" minimum:"1" maximum:"200"`
Offset int `query:"offset" default:"0" minimum:"0"`
},
) (*auditListOutput, error) {
) (*instanceAuditOutput, error) {
if _, err := requireInstanceAdmin(ctx); err != nil {
return nil, err
}
entries, err := s.store.ListInstanceAudit(ctx, input.Limit)
filter, err := auditFilterFromQuery(auditQueryParams{
Action: input.Action, ActorID: input.ActorID, TargetID: input.TargetID,
GuildID: input.GuildID, Since: input.Since, Until: input.Until,
Limit: input.Limit, Offset: input.Offset,
})
if err != nil {
return nil, err
}
entries, total, err := s.store.ListInstanceAuditFiltered(ctx, filter)
if err != nil {
return nil, humaError(err)
}
output := &auditListOutput{}
output := &instanceAuditOutput{}
output.Body.Entries = auditPayloads(entries)
output.Body.Total = total
return output, nil
})
huma.Register(api, huma.Operation{
OperationID: "listInstanceAuditActions",
Method: http.MethodGet,
Path: "/instance/audit/actions",
Summary: "Действия, встречающиеся в журнале инстанса",
Tags: []string{"Instance"},
Security: []map[string][]string{{"sessionCookie": {}}, {"bearerAuth": {}}},
}, func(ctx context.Context, _ *struct{}) (*instanceAuditActionsOutput, error) {
if _, err := requireInstanceAdmin(ctx); err != nil {
return nil, err
}
actions, err := s.store.ListInstanceAuditActions(ctx)
if err != nil {
return nil, humaError(err)
}
output := &instanceAuditActionsOutput{}
output.Body.Actions = actions
return output, nil
})
huma.Register(api, huma.Operation{
OperationID: "getInstanceStats",
Method: http.MethodGet,
Path: "/instance/stats",
Summary: "Статистика инстанса",
Tags: []string{"Instance"},
Security: []map[string][]string{{"sessionCookie": {}}, {"bearerAuth": {}}},
}, func(ctx context.Context, _ *struct{}) (*instanceStatsOutput, error) {
if _, err := requireInstanceAdmin(ctx); err != nil {
return nil, err
}
stats, err := s.store.InstanceStats(ctx)
if err != nil {
return nil, humaError(err)
}
output := &instanceStatsOutput{}
output.Body.Stats = s.instanceStatsPayload(stats)
return output, nil
})
huma.Register(api, huma.Operation{
OperationID: "getInstanceUser",
Method: http.MethodGet,
Path: "/instance/users/{user_id}",
Summary: "Карточка пользователя для админ-панели",
Tags: []string{"Instance"},
Security: []map[string][]string{{"sessionCookie": {}}, {"bearerAuth": {}}},
}, func(ctx context.Context, input *struct {
UserID string `path:"user_id"`
},
) (*instanceUserCardOutput, error) {
if _, err := requireInstanceAdmin(ctx); err != nil {
return nil, err
}
targetID, err := parseID("user_id", input.UserID)
if err != nil {
return nil, err
}
target, err := s.store.GetUser(ctx, targetID)
if err != nil {
return nil, humaError(err)
}
card, err := s.instanceUserCard(ctx, target)
if err != nil {
return nil, err
}
return card, nil
})
huma.Register(api, huma.Operation{
OperationID: "adminRevokeUserSession",
Method: http.MethodDelete,
Path: "/instance/users/{user_id}/sessions/{session_id}",
Summary: "Отозвать одно устройство пользователя",
Tags: []string{"Instance"},
Security: []map[string][]string{{"sessionCookie": {}}, {"bearerAuth": {}}},
}, func(ctx context.Context, input *struct {
UserID string `path:"user_id"`
SessionID string `path:"session_id"`
Body struct {
// StepUpPassword подтверждает действие: отзыв устройства —
// чувствительная операция (AGENT.md 7.1, 7.18).
StepUpPassword string `json:"step_up_password,omitempty"`
StepUpTOTP string `json:"step_up_totp,omitempty"`
}
},
) (*okOutput, error) {
admin, session, err := requireUser(ctx)
if err != nil {
return nil, err
}
if !admin.IsInstanceAdmin {
return nil, humaErrorStatus(http.StatusForbidden, "instance.admin_required", "instance administrator rights required")
}
if err := s.auth.RequireStepUp(ctx, admin, session, input.Body.StepUpPassword, input.Body.StepUpTOTP); err != nil {
return nil, humaError(err)
}
targetID, err := parseID("user_id", input.UserID)
if err != nil {
return nil, err
}
sessionID, err := parseID("session_id", input.SessionID)
if err != nil {
return nil, err
}
targetSession, err := s.store.UserSession(ctx, targetID, sessionID)
if err != nil {
return nil, humaError(err)
}
deleted, err := s.store.DeleteUserSession(ctx, targetID, sessionID)
if err != nil {
return nil, humaError(err)
}
if !deleted {
return nil, humaErrorStatus(http.StatusNotFound, "not_found", "session not found")
}
// Закрываем только это соединение: остальные устройства пользователя
// продолжают работу (AGENT.md 11.6).
if s.gateway != nil {
s.gateway.InvalidateSession(targetID, targetSession.TokenHash, "session_revoked")
}
s.recordSecurityEvent(ctx, &targetID, "session_revoked", session, map[string]any{
"by": "instance_admin",
"session_id": formatSnowflake(sessionID),
})
s.recordAudit(ctx, admin, 0, "instance.user_session_revoke", "user", &targetID, "")
return newOKOutput(), nil
})
huma.Register(api, huma.Operation{
OperationID: "adminResetUser2FA",
Method: http.MethodPost,
Path: "/instance/users/{user_id}/reset-2fa",
Summary: "Сбросить второй фактор пользователя",
Tags: []string{"Instance"},
Security: []map[string][]string{{"sessionCookie": {}}, {"bearerAuth": {}}},
}, func(ctx context.Context, input *struct {
UserID string `path:"user_id"`
Body struct {
// Сброс второго фактора — восстановление доступа: без step-up
// администратора это был бы готовый способ захвата аккаунта.
StepUpPassword string `json:"step_up_password,omitempty"`
StepUpTOTP string `json:"step_up_totp,omitempty"`
}
},
) (*adminReset2FAOutput, error) {
admin, session, err := requireUser(ctx)
if err != nil {
return nil, err
}
if !admin.IsInstanceAdmin {
return nil, humaErrorStatus(http.StatusForbidden, "instance.admin_required", "instance administrator rights required")
}
if err := s.auth.RequireStepUp(ctx, admin, session, input.Body.StepUpPassword, input.Body.StepUpTOTP); err != nil {
return nil, humaError(err)
}
targetID, err := parseID("user_id", input.UserID)
if err != nil {
return nil, err
}
if targetID == admin.ID {
return nil, humaErrorStatus(http.StatusUnprocessableEntity, "validation.failed", "use the security settings to reset your own second factor")
}
target, err := s.store.GetUser(ctx, targetID)
if err != nil {
return nil, humaError(err)
}
// У администратора инстанса второй фактор обязателен (AGENT.md 7.19):
// сброс чужого ключа стал бы обходом этого правила.
if target.IsInstanceAdmin {
return nil, humaErrorStatus(http.StatusForbidden, "instance.admin_protected", "instance administrator second factor cannot be reset")
}
secret, err := s.store.GetTOTPSecret(ctx, targetID)
if errors.Is(err, store.ErrNotFound) || (err == nil && !secret.Enabled) {
return nil, humaErrorStatus(http.StatusUnprocessableEntity, "validation.failed", "two-factor authentication is not enabled")
}
if err != nil {
return nil, humaError(err)
}
if err := s.store.DeleteTOTPSecret(ctx, targetID); err != nil {
return nil, humaError(err)
}
// После сброса второго фактора сессии отзываются: владелец входит
// заново паролем и настраивает новый ключ.
revoked, err := s.store.RevokeUserSessions(ctx, targetID)
if err != nil {
return nil, humaError(err)
}
if s.gateway != nil {
s.gateway.InvalidateUser(targetID, "2fa_reset")
}
s.recordSecurityEvent(ctx, &targetID, "2fa_change", session, map[string]any{
"action": "reset",
"by": "instance_admin",
})
s.recordAudit(ctx, admin, 0, "instance.user_2fa_reset", "user", &targetID, "")
output := &adminReset2FAOutput{}
output.Body.UserID = formatSnowflake(targetID)
output.Body.SessionsRevoked = revoked
return output, nil
})
}
@@ -829,3 +1077,450 @@ func newTemporaryPassword() (string, error) {
}
return builder.String(), nil
}
// instanceAuditOutput — страница журнала инстанса с общим числом записей под
// фильтр: панели нужна пагинация (AGENT.md 7.18).
type instanceAuditOutput struct {
Body struct {
Entries []auditEntryPayload `json:"entries"`
Total int `json:"total"`
}
}
type instanceAuditActionsOutput struct {
Body struct {
Actions []string `json:"actions"`
}
}
// dailyCountPayload — точка роста для графика статистики.
type dailyCountPayload struct {
Date string `json:"date"`
Count int `json:"count"`
}
type instanceGuildStatPayload struct {
ID string `json:"id"`
Name string `json:"name"`
Members int `json:"members"`
Messages int `json:"messages"`
}
type instanceStatsPayload struct {
Users struct {
Total int `json:"total"`
Admins int `json:"admins"`
Banned int `json:"banned"`
New7Days int `json:"new_7_days"`
New30Days int `json:"new_30_days"`
Active24h int `json:"active_24h"`
Daily []dailyCountPayload `json:"daily"`
} `json:"users"`
Messages struct {
Total int `json:"total"`
Today int `json:"today"`
Week int `json:"week"`
Month int `json:"month"`
Daily []dailyCountPayload `json:"daily"`
} `json:"messages"`
Files struct {
Count int `json:"count"`
Bytes int64 `json:"bytes"`
} `json:"files"`
Guilds int `json:"guilds"`
Invites int `json:"invites"`
Bans int `json:"bans"`
DatabaseBytes int64 `json:"database_bytes"`
StorageBytes int64 `json:"storage_bytes"`
TopGuilds []instanceGuildStatPayload `json:"top_guilds"`
BusiestGuilds []instanceGuildStatPayload `json:"busiest_guilds"`
}
type instanceStatsOutput struct {
Body struct {
Stats instanceStatsPayload `json:"stats"`
}
}
type instanceSessionPayload struct {
ID string `json:"id"`
UserAgent string `json:"user_agent,omitempty"`
IP string `json:"ip,omitempty"`
CreatedAt string `json:"created_at"`
LastSeen string `json:"last_seen"`
ExpiresAt string `json:"expires_at"`
// SteppedUp — сессия недавно подтверждала пароль: видно, какие устройства
// администратор может отозвать без последствий для остальных.
SteppedUp bool `json:"stepped_up"`
}
type instanceMembershipRolePayload struct {
ID string `json:"id"`
Name string `json:"name"`
Color int64 `json:"color"`
}
type instanceMembershipPayload struct {
GuildID string `json:"guild_id"`
GuildName string `json:"guild_name"`
Nickname string `json:"nickname,omitempty"`
JoinedAt string `json:"joined_at"`
Roles []instanceMembershipRolePayload `json:"roles"`
}
// instanceUserCardOutput — карточка пользователя: профиль, устройства,
// серверы с ролями, события безопасности и аудит по нему (AGENT.md 7.18).
type instanceUserCardOutput struct {
Body struct {
User instanceUserPayload `json:"user"`
TOTPEnabled bool `json:"totp_enabled"`
Passkeys int `json:"passkeys"`
Sessions []instanceSessionPayload `json:"sessions"`
Guilds []instanceMembershipPayload `json:"guilds"`
SecurityEvents []securityEventPayload `json:"security_events"`
Audit []auditEntryPayload `json:"audit"`
AuditTotal int `json:"audit_total"`
}
}
type adminReset2FAOutput struct {
Body struct {
UserID string `json:"user_id"`
SessionsRevoked int64 `json:"sessions_revoked"`
}
}
// securityEventsLimit и userAuditLimit — сколько событий и записей аудита
// показывать в карточке пользователя: остальное доступно в разделе «Аудит».
const (
securityEventsLimit = 20
userAuditLimit = 20
)
// auditQueryParams — фильтры журнала, как они приходят из строки запроса.
// Один разбор используется и JSON-ручкой (huma), и экспортом CSV.
type auditQueryParams struct {
Action string
ActorID string
TargetID string
GuildID string
Since string
Until string
Limit int
Offset int
}
// auditFilterFromQuery собирает фильтр журнала и разбирает даты (AGENT.md 7.10).
func auditFilterFromQuery(params auditQueryParams) (store.InstanceAuditFilter, error) {
filter := store.InstanceAuditFilter{
Action: strings.TrimSpace(params.Action),
Limit: params.Limit,
Offset: params.Offset,
}
for _, item := range []struct {
raw string
name string
into *uint64
}{
{params.ActorID, "actor_id", &filter.ActorID},
{params.TargetID, "target_id", &filter.TargetID},
{params.GuildID, "guild_id", &filter.GuildID},
} {
if strings.TrimSpace(item.raw) == "" {
continue
}
value, err := parseID(item.name, strings.TrimSpace(item.raw))
if err != nil {
return store.InstanceAuditFilter{}, err
}
*item.into = value
}
since, err := parseAuditDate(params.Since, false)
if err != nil {
return store.InstanceAuditFilter{}, err
}
until, err := parseAuditDate(params.Until, true)
if err != nil {
return store.InstanceAuditFilter{}, err
}
filter.Since = since
filter.Until = until
return filter, nil
}
// parseAuditDate читает дату фильтра в формате YYYY-MM-DD (UTC). Для «по дату»
// берётся конец дня: иначе запись за 20 сентября не попала бы в выборку
// «по 20 сентября» (время в базе хранится с точностью до миллисекунд).
func parseAuditDate(value string, endOfDay bool) (*time.Time, error) {
trimmed := strings.TrimSpace(value)
if trimmed == "" {
return nil, nil
}
parsed, err := time.Parse("2006-01-02", trimmed)
if err != nil {
return nil, humaErrorStatus(http.StatusUnprocessableEntity, "validation.failed", "date must be YYYY-MM-DD")
}
if endOfDay {
parsed = parsed.Add(24*time.Hour - time.Millisecond)
}
return &parsed, nil
}
// instanceStatsPayload дополняет выборку из store размерами хранилища: размер
// базы и файлов виден только серверу (AGENT.md 7.18).
func (s *Server) instanceStatsPayload(stats *store.InstanceStats) instanceStatsPayload {
payload := instanceStatsPayload{
Guilds: stats.Guilds,
Invites: stats.Invites,
Bans: stats.Bans,
}
payload.Users.Total = stats.Users.Total
payload.Users.Admins = stats.Users.Admins
payload.Users.Banned = stats.Users.Banned
payload.Users.New7Days = stats.Users.New7Days
payload.Users.New30Days = stats.Users.New30Days
payload.Users.Active24h = stats.Users.Active24h
payload.Users.Daily = dailyCountsPayload(stats.Users.Daily)
payload.Messages.Total = stats.Messages.Total
payload.Messages.Today = stats.Messages.Today
payload.Messages.Week = stats.Messages.Week
payload.Messages.Month = stats.Messages.Month
payload.Messages.Daily = dailyCountsPayload(stats.Messages.Daily)
payload.Files.Count = stats.Files.Count
payload.Files.Bytes = stats.Files.Bytes
payload.TopGuilds = guildStatsPayload(stats.TopGuilds)
payload.BusiestGuilds = guildStatsPayload(stats.BusiestGuilds)
database := sysinfo.FileBytes(s.databasePath())
database += sysinfo.FileBytes(s.databasePath() + "-wal")
payload.DatabaseBytes = database
payload.StorageBytes = database + stats.Files.Bytes
return payload
}
func dailyCountsPayload(counts []store.DailyCount) []dailyCountPayload {
payload := make([]dailyCountPayload, 0, len(counts))
for _, item := range counts {
payload = append(payload, dailyCountPayload{Date: item.Date, Count: item.Count})
}
return payload
}
func guildStatsPayload(guilds []store.InstanceGuildStat) []instanceGuildStatPayload {
payload := make([]instanceGuildStatPayload, 0, len(guilds))
for _, item := range guilds {
payload = append(payload, instanceGuildStatPayload{
ID: formatSnowflake(item.ID), Name: item.Name,
Members: item.Members, Messages: item.Messages,
})
}
return payload
}
// instanceUserCard собирает карточку пользователя для админ-панели: профиль,
// активные устройства, серверы с ролями, события безопасности и аудит, где он
// актор или цель (AGENT.md 7.18, 7.19).
func (s *Server) instanceUserCard(ctx context.Context, target *store.User) (*instanceUserCardOutput, error) {
output := &instanceUserCardOutput{}
output.Body.User = instanceUserPayloadFrom(*target)
output.Body.Sessions = []instanceSessionPayload{}
output.Body.Guilds = []instanceMembershipPayload{}
output.Body.SecurityEvents = []securityEventPayload{}
sessions, err := s.store.ListSessions(ctx, target.ID)
if err != nil {
return nil, humaError(err)
}
for _, session := range sessions {
output.Body.Sessions = append(output.Body.Sessions, instanceSessionPayload{
ID: formatSnowflake(session.ID),
UserAgent: session.UserAgent,
IP: session.IP,
CreatedAt: session.CreatedAt.UTC().Format(time.RFC3339),
LastSeen: session.LastSeen.UTC().Format(time.RFC3339),
ExpiresAt: session.ExpiresAt.UTC().Format(time.RFC3339),
SteppedUp: session.SteppedUp(time.Now().UTC()),
})
}
memberships, err := s.store.ListUserGuildMemberships(ctx, target.ID)
if err != nil {
return nil, humaError(err)
}
for _, membership := range memberships {
item := instanceMembershipPayload{
GuildID: formatSnowflake(membership.GuildID),
GuildName: membership.GuildName,
Nickname: membership.Nickname,
JoinedAt: membership.JoinedAt.UTC().Format(time.RFC3339),
Roles: []instanceMembershipRolePayload{},
}
for _, role := range membership.Roles {
item.Roles = append(item.Roles, instanceMembershipRolePayload{
ID: formatSnowflake(role.ID), Name: role.Name, Color: role.Color,
})
}
output.Body.Guilds = append(output.Body.Guilds, item)
}
events, err := s.store.ListSecurityEvents(ctx, target.ID, securityEventsLimit)
if err != nil {
return nil, humaError(err)
}
for _, event := range events {
output.Body.SecurityEvents = append(output.Body.SecurityEvents, securityEventPayload{
ID: formatSnowflake(event.ID),
Type: event.Type,
IP: event.IP,
UserAgent: event.UserAgent,
Metadata: event.Metadata,
CreatedAt: event.CreatedAt.UTC().Format(time.RFC3339),
})
}
entries, total, err := s.store.ListUserAudit(ctx, target.ID, userAuditLimit, 0)
if err != nil {
return nil, humaError(err)
}
output.Body.Audit = auditPayloads(entries)
output.Body.AuditTotal = total
passkeys, err := s.store.CountPasskeys(ctx, target.ID)
if err != nil {
return nil, humaError(err)
}
output.Body.Passkeys = passkeys
if secret, err := s.store.GetTOTPSecret(ctx, target.ID); err == nil {
output.Body.TOTPEnabled = secret.Enabled
} else if !errors.Is(err, store.ErrNotFound) {
return nil, humaError(err)
}
return output, nil
}
// registerInstanceExportRoutes описывает выгрузку журнала инстанса: ответ —
// CSV, а не JSON, поэтому ручка живёт на роутере chi, а не в huma.
func (s *Server) registerInstanceExportRoutes(router chi.Router) {
router.Get("/instance/audit/export", s.handleInstanceAuditExport)
}
// handleInstanceAuditExport отдаёт журнал инстанса файлом CSV с теми же
// фильтрами, что и раздел «Аудит» (AGENT.md 7.18). Предел выгрузки — 5000
// строк, иначе один запрос вычитывал бы весь журнал в память.
func (s *Server) handleInstanceAuditExport(w http.ResponseWriter, r *http.Request) {
user, _, ok := s.authenticate(w, r)
if !ok {
return
}
if !user.IsInstanceAdmin {
httpxWriteJSONError(w, http.StatusForbidden, "instance.admin_required", "instance administrator rights required")
return
}
if allowed, retryAfter := s.auditExportLimiter.Allow("audit-export:" + formatSnowflake(user.ID)); !allowed {
writeHumaAPIError(w, rateLimitedError(retryAfter))
return
}
query := r.URL.Query()
filter, err := auditFilterFromQuery(auditQueryParams{
Action: query.Get("action"),
ActorID: query.Get("actor_id"),
TargetID: query.Get("target_id"),
GuildID: query.Get("guild_id"),
Since: query.Get("since"),
Until: query.Get("until"),
Limit: store.MaxInstanceAuditLimit,
})
if err != nil {
writeHumaAPIError(w, err)
return
}
entries, _, err := s.store.ListInstanceAuditFiltered(r.Context(), filter)
if err != nil {
writeHumaAPIError(w, humaError(err))
return
}
w.Header().Set("Content-Type", "text/csv; charset=utf-8")
w.Header().Set("Content-Disposition",
`attachment; filename="audit-`+time.Now().UTC().Format("20060102-150405")+`.csv"`)
w.Header().Set("X-Content-Type-Options", "nosniff")
writer := csv.NewWriter(w)
write := func(row []string) {
safe := make([]string, 0, len(row))
for _, value := range row {
safe = append(safe, csvSafe(value))
}
_ = writer.Write(safe)
}
write([]string{
"id", "created_at", "action", "actor_id", "actor_instance_admin",
"target_type", "target_id", "guild_id", "reason", "changes",
})
for _, entry := range entries {
write([]string{
formatSnowflake(entry.ID),
entry.CreatedAt.UTC().Format(time.RFC3339),
entry.Action,
optionalSnowflake(entry.ActorID),
strconv.FormatBool(entry.ActorInstanceAdmin),
entry.TargetType,
optionalSnowflake(entry.TargetID),
optionalSnowflake(entry.GuildID),
entry.Reason,
string(entry.Changes),
})
}
writer.Flush()
if err := writer.Error(); err != nil {
s.logger.WarnContext(r.Context(), "audit export failed", slog.Any("error", err))
}
s.recordAudit(r.Context(), user, 0, "instance.audit_export", "instance", nil, "")
}
// csvSafe защищает выгрузку от формул Excel: значения, начинающиеся с «=»,
// «+», «-» или «@», таблица воспринимает как формулу (CSV injection).
func csvSafe(value string) string {
if value == "" {
return value
}
switch value[0] {
case '=', '+', '-', '@':
return "'" + value
default:
return value
}
}
// optionalSnowflake печатает необязательный идентификатор.
func optionalSnowflake(id *uint64) string {
if id == nil {
return ""
}
return formatSnowflake(*id)
}
// recordSecurityEvent пишет событие безопасности по действию администратора
// инстанса: владелец аккаунта должен видеть в разделе безопасности, что его
// устройство отозвали или сбросили второй фактор (AGENT.md 7.1, 7.19). Ошибка
// записи не ломает основное действие.
func (s *Server) recordSecurityEvent(
ctx context.Context,
userID *uint64,
eventType string,
session *store.Session,
metadata map[string]any,
) {
encoded := "{}"
if len(metadata) > 0 {
if raw, err := json.Marshal(metadata); err == nil {
encoded = string(raw)
}
}
var ip, userAgent string
if session != nil {
ip, userAgent = session.IP, session.UserAgent
}
if err := s.store.RecordSecurityEvent(ctx, userID, eventType, ip, userAgent, encoded); err != nil {
s.logger.WarnContext(ctx, "failed to record security event", slog.Any("error", err))
}
}
+20 -2
View File
@@ -222,6 +222,9 @@ func (s *Server) registerMessageRoutes(api huma.API) {
} else {
s.dispatchChannelEvent(ctx, channelID, "MESSAGE_CREATE", payload)
}
// Web Push (Фаза 7): упоминания и личные беседы уходят на устройства
// тех, кто не в клиенте — тем же правилам, что у desktop-уведомлений.
s.notifyPushAboutMessage(ctx, message, channel, displayNameOf(user))
output := &messageOutput{}
output.Body.Message = payload
return output, nil
@@ -375,7 +378,7 @@ func (s *Server) registerMessageRoutes(api huma.API) {
if err != nil {
return nil, err
}
channelID, resolved, _, err := s.requireChannelPermission(ctx, input.ChannelID, user, permissions.ViewChannel)
channelID, resolved, channel, err := s.requireChannelPermission(ctx, input.ChannelID, user, permissions.ViewChannel)
if err != nil {
return nil, err
}
@@ -389,6 +392,7 @@ func (s *Server) registerMessageRoutes(api huma.API) {
if err := s.store.DeleteMessage(ctx, message.ID); err != nil {
return nil, humaError(err)
}
s.recordMessageAudit(ctx, user, channel, "message.delete", message.ID)
s.dispatchChannelEvent(ctx, channelID, "MESSAGE_DELETE", map[string]any{
"id": formatSnowflake(message.ID),
"channel_id": formatSnowflake(channelID),
@@ -754,6 +758,15 @@ func (s *Server) requireMessageAuthor(user *store.User, resolved permissions.Res
return humaErrorStatus(http.StatusForbidden, "perm.denied", "permission denied")
}
// recordMessageAudit пишет действие с сообщением в аудит сервера (AGENT.md 7.10).
// Личные беседы серверу не принадлежат — в журнал они не попадают.
func (s *Server) recordMessageAudit(ctx context.Context, actor *store.User, channel *store.Channel, action string, messageID uint64) {
if channel.GuildID == nil {
return
}
s.recordAudit(ctx, actor, *channel.GuildID, action, "message", &messageID, "")
}
// changeReaction ставит или снимает реакцию и рассылает событие.
func (s *Server) changeReaction(ctx context.Context, rawChannelID, rawMessageID, emoji string, add bool) (*okOutput, error) {
user, _, err := requireUser(ctx)
@@ -835,7 +848,7 @@ func (s *Server) changePin(ctx context.Context, rawChannelID, rawMessageID strin
if err != nil {
return nil, err
}
channelID, _, _, err := s.requireChannelPermission(ctx, rawChannelID, user, permissions.ManageMessages)
channelID, _, channel, err := s.requireChannelPermission(ctx, rawChannelID, user, permissions.ManageMessages)
if err != nil {
return nil, err
}
@@ -856,6 +869,11 @@ func (s *Server) changePin(ctx context.Context, rawChannelID, rawMessageID strin
if err := s.store.SetMessagePinned(ctx, message.ID, pinned); err != nil {
return nil, humaError(err)
}
action := "message.unpin"
if pinned {
action = "message.pin"
}
s.recordMessageAudit(ctx, user, channel, action, message.ID)
s.dispatchChannelEvent(ctx, channelID, "CHANNEL_PINS_UPDATE", map[string]any{
"channel_id": formatSnowflake(channelID),
"message_id": formatSnowflake(message.ID),
+255
View File
@@ -0,0 +1,255 @@
package server
import (
"context"
"crypto/ecdsa"
"crypto/elliptic"
"encoding/base64"
"net/http"
"strconv"
"strings"
"github.com/danielgtaylor/huma/v2"
"glchat/internal/httpx"
"glchat/internal/store"
)
// Web Push (AGENT.md 7.16, Фаза 7): подписка устройства, отписка и параметры
// для клиента. VAPID-ключ приватный живёт в конфиге инстанса, наружу уходит
// только публичный — он и нужен браузеру как `applicationServerKey`.
// Лимиты подписок: устройств на пользователя и длины полей от клиента.
const (
maxPushSubscriptionsPerUser = 10
maxPushEndpointLength = 1024
maxPushKeyLength = 128
maxPushUserAgentLength = 200
)
type pushConfigOutput struct {
Body struct {
Enabled bool `json:"enabled"`
PublicKey string `json:"public_key,omitempty"`
}
}
type pushSubscriptionPayload struct {
ID string `json:"id"`
Endpoint string `json:"endpoint"`
UserAgent string `json:"user_agent,omitempty"`
CreatedAt string `json:"created_at"`
}
type pushSubscriptionListOutput struct {
Body struct {
Subscriptions []pushSubscriptionPayload `json:"subscriptions"`
// Limit — сколько устройств можно подписать (для интерфейса).
Limit int `json:"limit"`
}
}
type pushOKOutput struct {
Body struct {
OK bool `json:"ok"`
}
}
// registerPushRoutes описывает ручки Web Push.
func (s *Server) registerPushRoutes(api huma.API) {
security := []map[string][]string{{"sessionCookie": {}}, {"bearerAuth": {}}}
huma.Register(api, huma.Operation{
OperationID: "getPushConfig",
Method: http.MethodGet,
Path: "/push/config",
Summary: "Параметры Web Push для клиента",
Tags: []string{"Push"},
Security: security,
}, func(ctx context.Context, _ *struct{}) (*pushConfigOutput, error) {
if _, _, err := requireUser(ctx); err != nil {
return nil, err
}
output := &pushConfigOutput{}
output.Body.Enabled = s.push.Enabled()
output.Body.PublicKey = s.push.PublicKey()
return output, nil
})
huma.Register(api, huma.Operation{
OperationID: "listPushSubscriptions",
Method: http.MethodGet,
Path: "/push/subscriptions",
Summary: "Подписки устройств текущего пользователя",
Tags: []string{"Push"},
Security: security,
}, func(ctx context.Context, _ *struct{}) (*pushSubscriptionListOutput, error) {
user, _, err := requireUser(ctx)
if err != nil {
return nil, err
}
subscriptions, err := s.store.ListPushSubscriptions(ctx, user.ID)
if err != nil {
return nil, humaError(err)
}
output := &pushSubscriptionListOutput{}
output.Body.Limit = maxPushSubscriptionsPerUser
output.Body.Subscriptions = make([]pushSubscriptionPayload, 0, len(subscriptions))
for i := range subscriptions {
output.Body.Subscriptions = append(output.Body.Subscriptions, pushSubscriptionPayload{
ID: formatSnowflake(subscriptions[i].ID),
Endpoint: subscriptions[i].Endpoint,
UserAgent: subscriptions[i].UserAgent,
CreatedAt: s.store.Timestamp(subscriptions[i].CreatedAt),
})
}
return output, nil
})
huma.Register(api, huma.Operation{
OperationID: "subscribePush",
Method: http.MethodPost,
Path: "/push/subscriptions",
Summary: "Подписать устройство на Web Push",
Tags: []string{"Push"},
Security: security,
}, func(ctx context.Context, input *struct {
UserAgent string `header:"User-Agent"`
Body struct {
Endpoint string `json:"endpoint" maxLength:"1024" minLength:"8"`
Keys struct {
P256dh string `json:"p256dh" maxLength:"128"`
Auth string `json:"auth" maxLength:"128"`
} `json:"keys"`
}
},
) (*pushOKOutput, error) {
user, _, err := requireUser(ctx)
if err != nil {
return nil, err
}
if !s.push.Enabled() {
return nil, humaErrorStatus(http.StatusServiceUnavailable, "push.disabled",
"web push is not configured on this instance")
}
if allowed, retryAfter := s.pushLimiter.Allow(pushLimitKey(user.ID)); !allowed {
return nil, rateLimitedError(retryAfter)
}
endpoint, err := validatePushEndpoint(input.Body.Endpoint)
if err != nil {
return nil, err
}
if err := validatePushKey("p256dh", input.Body.Keys.P256dh, 65); err != nil {
return nil, err
}
if err := validatePushKey("auth", input.Body.Keys.Auth, 16); err != nil {
return nil, err
}
count, err := s.store.CountPushSubscriptions(ctx, user.ID)
if err != nil {
return nil, humaError(err)
}
// Повторная подписка с того же устройства обновляет запись, поэтому
// лимит проверяем только для новой.
if count >= maxPushSubscriptionsPerUser {
existing, err := s.store.GetPushSubscriptionByEndpoint(ctx, endpoint)
if err != nil || existing.UserID != user.ID {
return nil, humaErrorStatus(http.StatusConflict, "push.too_many_subscriptions",
"too many subscribed devices")
}
}
if _, err := s.store.SavePushSubscription(ctx, store.SavePushSubscriptionParams{
UserID: user.ID,
Endpoint: endpoint,
P256dh: input.Body.Keys.P256dh,
Auth: input.Body.Keys.Auth,
UserAgent: truncate(input.UserAgent, maxPushUserAgentLength),
}); err != nil {
return nil, humaError(err)
}
output := &pushOKOutput{}
output.Body.OK = true
return output, nil
})
huma.Register(api, huma.Operation{
OperationID: "unsubscribePush",
Method: http.MethodDelete,
Path: "/push/subscriptions",
Summary: "Отписать устройство (или все) от Web Push",
Tags: []string{"Push"},
Security: security,
}, func(ctx context.Context, input *struct {
Endpoint string `query:"endpoint,omitempty" maxLength:"1024"`
},
) (*pushOKOutput, error) {
user, _, err := requireUser(ctx)
if err != nil {
return nil, err
}
endpoint := strings.TrimSpace(input.Endpoint)
if endpoint != "" {
if _, err := validatePushEndpoint(endpoint); err != nil {
return nil, err
}
}
if _, err := s.store.DeletePushSubscription(ctx, user.ID, endpoint); err != nil {
return nil, humaError(err)
}
output := &pushOKOutput{}
output.Body.OK = true
return output, nil
})
}
// validatePushEndpoint проверяет эндпоинт подписки: http не допускаем, а
// литеральный внутренний адрес отсекаем сразу — иначе сервер сам себе
// организует SSRF, отправляя подписанный VAPID-запрос во внутреннюю сеть.
func validatePushEndpoint(raw string) (string, error) {
trimmed := strings.TrimSpace(raw)
if trimmed == "" || len(trimmed) > maxPushEndpointLength {
return "", humaErrorStatus(http.StatusUnprocessableEntity, "push.invalid_endpoint",
"push endpoint is empty or too long")
}
if _, err := httpx.ValidatePublicURL(trimmed, false); err != nil {
return "", humaErrorStatus(http.StatusUnprocessableEntity, "push.invalid_endpoint",
"push endpoint must be a public https url")
}
return trimmed, nil
}
// validatePushKey проверяет ключ подписки: base64url, длину и — для p256dh —
// что это действительно точка P-256. Без второй проверки мусор от клиента
// доходил бы до шифрования и падал уже в очереди доставки.
func validatePushKey(name, value string, wantBytes int) error {
trimmed := strings.TrimSpace(value)
if trimmed == "" || len(trimmed) > maxPushKeyLength {
return humaErrorStatus(http.StatusUnprocessableEntity, "push.invalid_keys",
name+" key is missing or too long")
}
decoded, err := base64.RawURLEncoding.DecodeString(strings.TrimRight(trimmed, "="))
if err != nil || len(decoded) != wantBytes {
return humaErrorStatus(http.StatusUnprocessableEntity, "push.invalid_keys",
name+" key must be base64url of "+strconv.Itoa(wantBytes)+" bytes")
}
if name == "p256dh" {
if _, err := ecdsa.ParseUncompressedPublicKey(elliptic.P256(), decoded); err != nil {
return humaErrorStatus(http.StatusUnprocessableEntity, "push.invalid_keys",
"p256dh key is not a P-256 point")
}
}
return nil
}
// pushLimitKey — ключ лимита подписок: 10 запросов в минуту на пользователя.
func pushLimitKey(userID uint64) string { return "user:" + formatSnowflake(userID) }
// truncate обрезает пользовательский текст до лимита (User-Agent устройства).
func truncate(value string, limit int) string {
trimmed := strings.TrimSpace(value)
runes := []rune(trimmed)
if len(runes) <= limit {
return trimmed
}
return string(runes[:limit])
}
+356
View File
@@ -0,0 +1,356 @@
package server
import (
"context"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/x509"
"encoding/base64"
"fmt"
"log/slog"
"net/http"
"path/filepath"
"testing"
"time"
"glchat/internal/auth"
"glchat/internal/config"
"glchat/internal/database"
"glchat/internal/gateway"
"glchat/internal/httpx"
"glchat/internal/permissions"
"glchat/internal/source"
"glchat/internal/store"
)
// newPushTestServer поднимает тестовый сервер с настроенным VAPID-ключом:
// newTestServer из server_test.go о push ничего не знает, а ручки обязаны
// работать и с ключом, и без него.
func newPushTestServer(t *testing.T, withKeys bool) (*Server, *store.Store) {
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("open test database: %v", err)
}
t.Cleanup(func() {
if err := db.Close(); err != nil {
t.Errorf("close test database: %v", err)
}
})
cfg := config.Config{
DataDir: t.TempDir(),
Domain: "gl.mhspx.su",
WebRoot: filepath.Join("testdata", "web"),
FilesDomain: "files.gl.mhspx.su",
InstanceName: "glchat",
ListenAddr: "127.0.0.1:0",
Version: "v0.1.0-test",
Commit: "deadbee",
BuildDate: "2026-09-19T00:00:00Z",
MaxUploadSize: 26214400,
TLSEnabled: true,
SessionPepper: "test-pepper",
MasterKey: "00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff",
Argon2MemoryKiB: 1024,
Argon2Iterations: 1,
Argon2Parallelism: 1,
LogLevel: "error",
LogFormat: "json",
UnfurlEnabled: true,
UnfurlTimeout: 5 * time.Second,
}
if withKeys {
cfg.VAPIDPrivateKey = testVAPIDPrivateKey(t)
cfg.VAPIDSubject = "mailto:admin@gl.mhspx.su"
}
logger := slog.New(slog.DiscardHandler)
st := store.New(db)
authService, err := auth.New(context.Background(), cfg, st, logger)
if err != nil {
t.Fatalf("initialize authentication: %v", err)
}
calculator := permissions.NewCalculator(source.New(st))
gatewayService := gateway.New(st, authService, gateway.NewSnapshot(st, calculator), logger, cfg.AllowedOrigins())
return New(cfg, db, logger, Deps{
Store: st, Auth: authService, Gateway: gatewayService, Permissions: calculator,
}), st
}
func testVAPIDPrivateKey(t *testing.T) string {
t.Helper()
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
t.Fatalf("generate key: %v", err)
}
der, err := x509.MarshalPKCS8PrivateKey(key)
if err != nil {
t.Fatalf("marshal PKCS#8: %v", err)
}
return base64.StdEncoding.EncodeToString(der)
}
// testPushKeys возвращает корректные ключи подписки (65-байтовая точка P-256
// и 16 байт auth) в base64url — как их отдаёт PushSubscription.toJSON().
func testPushKeys(t *testing.T) (string, string) {
t.Helper()
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
t.Fatalf("generate subscription key: %v", err)
}
point, err := key.PublicKey.Bytes()
if err != nil {
t.Fatalf("public key bytes: %v", err)
}
auth := make([]byte, 16)
if _, err := rand.Read(auth); err != nil {
t.Fatalf("auth: %v", err)
}
return base64.RawURLEncoding.EncodeToString(point), base64.RawURLEncoding.EncodeToString(auth)
}
// testAuthKey — корректный auth-ключ подписки (16 байт).
func testAuthKey(t *testing.T) string {
t.Helper()
_, auth := testPushKeys(t)
return auth
}
func pushSubscribeBody(t *testing.T, endpoint string) string {
t.Helper()
p256dh, auth := testPushKeys(t)
return fmt.Sprintf(`{"endpoint":%q,"keys":{"p256dh":%q,"auth":%q}}`, endpoint, p256dh, auth)
}
func TestPushConfigDisabledWithoutKeys(t *testing.T) {
srv, _ := newPushTestServer(t, false)
cookie := registerAndLogin(t, srv, "push_off", "push-off@example.com")
rec := doJSON(t, srv, http.MethodGet, "/api/v1/push/config", "", cookie)
if rec.Code != http.StatusOK {
t.Fatalf("GET /push/config = %d, body = %s", rec.Code, rec.Body.String())
}
payload := decodeResponse[struct {
Enabled bool `json:"enabled"`
PublicKey string `json:"public_key"`
}](t, rec)
if payload.Enabled || payload.PublicKey != "" {
t.Fatalf("без ключей push должен быть выключен: %+v", payload)
}
// Подписка на инстансе без ключей — честная ошибка, а не молчаливый успех.
rec = doJSON(t, srv, http.MethodPost, "/api/v1/push/subscriptions",
pushSubscribeBody(t, "https://fcm.googleapis.com/fcm/send/disabled"), cookie)
if rec.Code != http.StatusServiceUnavailable {
t.Fatalf("подписка без ключей = %d, ожидалось 503", rec.Code)
}
if code := errorCodeOf(t, rec); code != "push.disabled" {
t.Fatalf("код ошибки = %q, ожидался push.disabled", code)
}
}
func TestPushSubscribeFlow(t *testing.T) {
srv, st := newPushTestServer(t, true)
cookie := registerAndLogin(t, srv, "push_on", "push-on@example.com")
user, err := srv.auth.UserByEmail(t.Context(), "push-on@example.com")
if err != nil {
t.Fatalf("UserByEmail: %v", err)
}
config := decodeResponse[struct {
Enabled bool `json:"enabled"`
PublicKey string `json:"public_key"`
}](t, doJSON(t, srv, http.MethodGet, "/api/v1/push/config", "", cookie))
if !config.Enabled || config.PublicKey == "" {
t.Fatalf("push должен быть включён: %+v", config)
}
decoded, err := base64.RawURLEncoding.DecodeString(config.PublicKey)
if err != nil || len(decoded) != 65 {
t.Fatalf("публичный ключ не годится для applicationServerKey: %v", err)
}
endpoint := "https://fcm.googleapis.com/fcm/send/device-one"
rec := doJSON(t, srv, http.MethodPost, "/api/v1/push/subscriptions",
pushSubscribeBody(t, endpoint), cookie)
if rec.Code != http.StatusOK {
t.Fatalf("подписка = %d, body = %s", rec.Code, rec.Body.String())
}
subscriptions, err := st.ListPushSubscriptions(t.Context(), user.ID)
if err != nil || len(subscriptions) != 1 {
t.Fatalf("подписок в базе %d (%v), ожидалась 1", len(subscriptions), err)
}
if subscriptions[0].Endpoint != endpoint {
t.Fatalf("эндпоинт = %q", subscriptions[0].Endpoint)
}
// Список для интерфейса отдаёт устройства и лимит.
list := decodeResponse[struct {
Subscriptions []struct {
ID string `json:"id"`
Endpoint string `json:"endpoint"`
} `json:"subscriptions"`
Limit int `json:"limit"`
}](t, doJSON(t, srv, http.MethodGet, "/api/v1/push/subscriptions", "", cookie))
if len(list.Subscriptions) != 1 || list.Limit != maxPushSubscriptionsPerUser {
t.Fatalf("неожиданный список подписок: %+v", list)
}
// Отписка устройства.
rec = doJSON(t, srv, http.MethodDelete,
"/api/v1/push/subscriptions?endpoint="+endpoint, "", cookie)
if rec.Code != http.StatusOK {
t.Fatalf("отписка = %d, body = %s", rec.Code, rec.Body.String())
}
if count, err := st.CountPushSubscriptions(t.Context(), user.ID); err != nil || count != 0 {
t.Fatalf("после отписки подписок %d (%v)", count, err)
}
}
func TestPushSubscribeValidation(t *testing.T) {
srv, _ := newPushTestServer(t, true)
cookie := registerAndLogin(t, srv, "push_bad", "push-bad@example.com")
cases := []struct {
name string
body string
code string
}{
{
name: "http вместо https",
body: pushSubscribeBody(t, "http://fcm.googleapis.com/fcm/send/x"),
code: "push.invalid_endpoint",
},
{
name: "внутренний адрес",
body: pushSubscribeBody(t, "https://10.0.0.5/push"),
code: "push.invalid_endpoint",
},
{
name: "метаданные облака",
body: pushSubscribeBody(t, "https://169.254.169.254/latest/meta-data"),
code: "push.invalid_endpoint",
},
{
name: "loopback",
body: pushSubscribeBody(t, "https://127.0.0.1:8443/push"),
code: "push.invalid_endpoint",
},
{
name: "p256dh не точка кривой",
body: fmt.Sprintf(`{"endpoint":"https://fcm.googleapis.com/fcm/send/x","keys":{"p256dh":%q,"auth":%q}}`,
base64.RawURLEncoding.EncodeToString(make([]byte, 65)), testAuthKey(t)),
code: "push.invalid_keys",
},
{
name: "битый ключ шифрования",
body: `{"endpoint":"https://fcm.googleapis.com/fcm/send/x","keys":{"p256dh":"AAAA","auth":"AAAA"}}`,
code: "push.invalid_keys",
},
{
name: "пустой ключ",
body: `{"endpoint":"https://fcm.googleapis.com/fcm/send/x","keys":{"p256dh":"","auth":""}}`,
code: "push.invalid_keys",
},
}
for _, testCase := range cases {
t.Run(testCase.name, func(t *testing.T) {
rec := doJSON(t, srv, http.MethodPost, "/api/v1/push/subscriptions", testCase.body, cookie)
if rec.Code != http.StatusUnprocessableEntity {
t.Fatalf("код ответа = %d, ожидался 422 (body = %s)", rec.Code, rec.Body.String())
}
if code := errorCodeOf(t, rec); code != testCase.code {
t.Fatalf("код ошибки = %q, ожидался %q", code, testCase.code)
}
})
}
}
func TestPushSubscribeLimit(t *testing.T) {
srv, _ := newPushTestServer(t, true)
cookie := registerAndLogin(t, srv, "push_limit", "push-limit@example.com")
// Лимит частоты проверяется отдельно: здесь важно дойти до предела
// устройств на пользователя.
srv.pushLimiter = httpx.NewRateLimiterWindow(100, time.Minute, 100)
for i := 0; i < maxPushSubscriptionsPerUser; i++ {
endpoint := fmt.Sprintf("https://fcm.googleapis.com/fcm/send/device-%d", i)
rec := doJSON(t, srv, http.MethodPost, "/api/v1/push/subscriptions",
pushSubscribeBody(t, endpoint), cookie)
if rec.Code != http.StatusOK {
t.Fatalf("подписка %d = %d, body = %s", i, rec.Code, rec.Body.String())
}
}
rec := doJSON(t, srv, http.MethodPost, "/api/v1/push/subscriptions",
pushSubscribeBody(t, "https://fcm.googleapis.com/fcm/send/device-over-limit"), cookie)
if rec.Code != http.StatusConflict {
t.Fatalf("подписка сверх лимита = %d, ожидалось 409 (body = %s)", rec.Code, rec.Body.String())
}
if code := errorCodeOf(t, rec); code != "push.too_many_subscriptions" {
t.Fatalf("код ошибки = %q", code)
}
}
// Подписки ограничены и по частоте: перебор устройств не должен превращаться
// в поток запросов (AGENT.md 8.6).
func TestPushSubscribeRateLimited(t *testing.T) {
srv, _ := newPushTestServer(t, true)
cookie := registerAndLogin(t, srv, "push_flood", "push-flood@example.com")
var limited bool
for i := 0; i < 40; i++ {
endpoint := fmt.Sprintf("https://fcm.googleapis.com/fcm/send/flood-%d", i)
rec := doJSON(t, srv, http.MethodPost, "/api/v1/push/subscriptions",
pushSubscribeBody(t, endpoint), cookie)
if rec.Code == http.StatusTooManyRequests {
limited = true
break
}
}
if !limited {
t.Fatal("лимит частоты подписок не сработал")
}
}
func TestPushRoutesRequireSession(t *testing.T) {
srv, _ := newPushTestServer(t, true)
body := pushSubscribeBody(t, "https://fcm.googleapis.com/fcm/send/no-session")
for _, request := range []struct {
method string
path string
body string
}{
{http.MethodGet, "/api/v1/push/config", ""},
{http.MethodGet, "/api/v1/push/subscriptions", ""},
{http.MethodPost, "/api/v1/push/subscriptions", body},
{http.MethodDelete, "/api/v1/push/subscriptions", ""},
} {
rec := doJSON(t, srv, request.method, request.path, request.body)
if rec.Code != http.StatusUnauthorized {
t.Errorf("%s %s без сессии = %d, ожидалось 401", request.method, request.path, rec.Code)
}
}
}
// Публичный ключ в /meta виден до входа: клиент решает, показывать ли раздел.
func TestMetaExposesWebPushFlag(t *testing.T) {
srv, _ := newPushTestServer(t, true)
rec := doJSON(t, srv, http.MethodGet, "/api/v1/meta", "")
if rec.Code != http.StatusOK {
t.Fatalf("GET /meta = %d", rec.Code)
}
payload := decodeResponse[struct {
Features struct {
WebPushEnabled bool `json:"web_push_enabled"`
} `json:"features"`
}](t, rec)
if !payload.Features.WebPushEnabled {
t.Fatal("meta не сообщает о включённом Web Push")
}
}
+426 -3
View File
@@ -3,6 +3,8 @@ package server
import (
"context"
"errors"
"fmt"
"log/slog"
"net/http"
"strings"
"time"
@@ -66,6 +68,32 @@ type dmChannelPayload struct {
} `json:"recipient"`
LastMessageID string `json:"last_message_id,omitempty"`
LastMessageAt string `json:"last_message_at,omitempty"`
// IconFileID — иконка беседы: у группы своя загрузка (D-070), у 1:1 —
// аватар собеседника. Совпадает с полем READY-снапшота (AGENT.md 8.3).
IconFileID string `json:"icon_file_id,omitempty"`
// Групповая беседа (AGENT.md 7.8): имя, участники и владелец. У 1:1
// is_group = false, а данные собеседника лежат в recipient.
IsGroup bool `json:"is_group"`
Name string `json:"name,omitempty"`
MemberCount int `json:"member_count,omitempty"`
OwnerID string `json:"owner_id,omitempty"`
Recipients []dmRecipientPayload `json:"recipients,omitempty"`
// ActiveCall — идущий в беседе звонок (AGENT.md 7.8, Фаза 7): список
// бесед и шапка показывают маркер «идёт звонок» и после перезагрузки, не
// дожидаясь события DM_CALL_UPDATE.
ActiveCall *dmCallPayload `json:"active_call,omitempty"`
}
// dmRecipientPayload — участник групповой беседы.
type dmRecipientPayload struct {
UserID string `json:"user_id"`
Username string `json:"username"`
DisplayName string `json:"display_name"`
AvatarFileID string `json:"avatar_file_id,omitempty"`
Status string `json:"status"`
VisibleStatus string `json:"visible_status"`
IsOwner bool `json:"is_owner"`
IsSelf bool `json:"is_self"`
}
type dmChannelListOutput struct {
@@ -380,7 +408,7 @@ func (s *Server) registerSocialRoutes(api huma.API) {
Timezone: recipient.Timezone,
}
output := &dmChannelOutput{}
output.Body.Channel = s.dmChannelPayload(summary)
output.Body.Channel = s.dmChannelPayload(ctx, summary)
return output, nil
})
@@ -403,10 +431,366 @@ func (s *Server) registerSocialRoutes(api huma.API) {
output := &dmChannelListOutput{}
output.Body.Channels = make([]dmChannelPayload, 0, len(channels))
for _, summary := range channels {
output.Body.Channels = append(output.Body.Channels, s.dmChannelPayload(summary))
output.Body.Channels = append(output.Body.Channels, s.dmChannelPayload(ctx, summary))
}
return output, nil
})
// Групповые личные беседы (AGENT.md 7.8, Фаза 7): создание, добавление и
// выход участников, переименование. Права простые и явные: добавлять может
// любой участник, удалять других и переименовывать — владелец, выйти может
// каждый сам.
huma.Register(api, huma.Operation{
OperationID: "createGroupDirectChannel",
Method: http.MethodPost,
Path: "/users/@me/channels/group",
Summary: "Создать групповую беседу",
Tags: []string{"Friends"},
Security: security,
}, func(ctx context.Context, input *struct {
Body struct {
Name string `json:"name,omitempty" maxLength:"64"`
RecipientIDs []string `json:"recipient_ids"`
}
},
) (*dmChannelOutput, error) {
user, _, err := requireUser(ctx)
if err != nil {
return nil, err
}
members, err := s.groupDMMembers(ctx, user.ID, input.Body.RecipientIDs)
if err != nil {
return nil, err
}
// Группа — это минимум три участника: двое и меньше остаются обычной
// личной беседой (см. store.RemoveDMParticipant).
if len(members) < 2 {
return nil, humaErrorStatus(http.StatusUnprocessableEntity, "validation.failed",
"a group conversation needs at least two other members")
}
name := strings.TrimSpace(input.Body.Name)
if name == "" {
name = s.defaultGroupDMName(ctx, members)
}
if len([]rune(name)) > maxGroupDMNameLength {
return nil, humaErrorStatus(http.StatusUnprocessableEntity, "validation.failed", "group name is too long")
}
channel, err := s.store.CreateGroupDMChannel(ctx, user.ID, name, members)
if err != nil {
return nil, humaError(err)
}
s.dispatchDMCreate(channel.ID, append([]uint64{user.ID}, members...))
summary, err := s.groupDMSummary(ctx, channel, user.ID)
if err != nil {
return nil, err
}
output := &dmChannelOutput{}
output.Body.Channel = s.dmChannelPayload(ctx, summary)
return output, nil
})
huma.Register(api, huma.Operation{
OperationID: "addDirectChannelRecipient",
Method: http.MethodPut,
Path: "/channels/{channel_id}/recipients/{user_id}",
Summary: "Добавить участника в групповую беседу",
Tags: []string{"Friends"},
Security: security,
}, func(ctx context.Context, input *struct {
ChannelID string `path:"channel_id"`
UserID string `path:"user_id"`
},
) (*dmChannelOutput, error) {
user, _, err := requireUser(ctx)
if err != nil {
return nil, err
}
channel, err := s.groupDMForMember(ctx, input.ChannelID, user.ID)
if err != nil {
return nil, err
}
targetID, err := parseID("user_id", input.UserID)
if err != nil {
return nil, err
}
if targetID == user.ID {
return nil, humaErrorStatus(http.StatusUnprocessableEntity, "validation.failed", "you are already a member")
}
if _, err := s.store.GetUser(ctx, targetID); err != nil {
return nil, humaError(err)
}
// Блокировка запрещает и добавление в общую беседу (AGENT.md 7.2).
if blocked, err := s.store.IsBlocked(ctx, user.ID, targetID); err != nil {
return nil, humaError(err)
} else if blocked {
return nil, humaErrorStatus(http.StatusForbidden, "dm.blocked", "personal messages are blocked")
}
count, err := s.store.DMParticipantCount(ctx, channel.ID)
if err != nil {
return nil, humaError(err)
}
if count >= maxGroupDMMembers {
return nil, humaErrorStatus(http.StatusConflict, "dm.members_limit", "group conversation is full")
}
if err := s.store.AddDMParticipant(ctx, channel.ID, targetID); err != nil {
return nil, humaError(err)
}
// Новый участник получает событие создания беседы, остальные — обновление
// состава (AGENT.md 8.3).
if s.gateway != nil {
s.gateway.SendToUser(targetID, "DM_CHANNEL_CREATE", map[string]any{
"channel_id": formatSnowflake(channel.ID),
})
}
s.dispatchDMUpdate(channel, []uint64{}, targetID)
summary, err := s.groupDMSummary(ctx, channel, user.ID)
if err != nil {
return nil, err
}
output := &dmChannelOutput{}
output.Body.Channel = s.dmChannelPayload(ctx, summary)
return output, nil
})
huma.Register(api, huma.Operation{
OperationID: "removeDirectChannelRecipient",
Method: http.MethodDelete,
Path: "/channels/{channel_id}/recipients/{user_id}",
Summary: "Выйти из групповой беседы или удалить участника",
Tags: []string{"Friends"},
Security: security,
}, func(ctx context.Context, input *struct {
ChannelID string `path:"channel_id"`
UserID string `path:"user_id"`
},
) (*okOutput, error) {
user, _, err := requireUser(ctx)
if err != nil {
return nil, err
}
channel, err := s.groupDMForMember(ctx, input.ChannelID, user.ID)
if err != nil {
return nil, err
}
targetID, err := parseID("user_id", input.UserID)
if err != nil {
return nil, err
}
if targetID != user.ID {
// Удалять других может только владелец беседы.
if channel.DMOwnerID == nil || *channel.DMOwnerID != user.ID {
return nil, humaErrorStatus(http.StatusForbidden, "perm.denied",
"only the group owner can remove other members")
}
member, err := s.store.IsDMParticipant(ctx, channel.ID, targetID)
if err != nil {
return nil, humaError(err)
}
if !member {
return nil, humaErrorStatus(http.StatusNotFound, "not_found", "user is not a member of this conversation")
}
}
if err := s.store.RemoveDMParticipant(ctx, channel.ID, targetID); err != nil {
return nil, humaError(err)
}
// Удалённый (или вышедший) теряет доступ: ему уходит CHANNEL_DELETE,
// остальным — обновление состава.
if s.gateway != nil {
s.gateway.SendToUser(targetID, "DM_CHANNEL_DELETE", map[string]any{
"channel_id": formatSnowflake(channel.ID),
})
}
s.dispatchDMUpdate(channel, []uint64{targetID}, 0)
return newOKOutput(), nil
})
huma.Register(api, huma.Operation{
OperationID: "renameDirectChannel",
Method: http.MethodPatch,
Path: "/channels/{channel_id}",
Summary: "Переименовать групповую беседу (владелец)",
Tags: []string{"Friends"},
Security: security,
}, func(ctx context.Context, input *struct {
ChannelID string `path:"channel_id"`
Body struct {
Name string `json:"name" maxLength:"64"`
}
},
) (*dmChannelOutput, error) {
user, _, err := requireUser(ctx)
if err != nil {
return nil, err
}
channel, err := s.groupDMForMember(ctx, input.ChannelID, user.ID)
if err != nil {
return nil, err
}
if channel.DMOwnerID == nil || *channel.DMOwnerID != user.ID {
return nil, humaErrorStatus(http.StatusForbidden, "perm.denied", "only the group owner can rename the conversation")
}
name := strings.TrimSpace(input.Body.Name)
if name == "" || len([]rune(name)) > maxGroupDMNameLength {
return nil, humaErrorStatus(http.StatusUnprocessableEntity, "validation.failed", "group name must be 1-64 characters")
}
if err := s.store.RenameDMChannel(ctx, channel.ID, name); err != nil {
return nil, humaError(err)
}
channel.Name = name
s.dispatchDMUpdate(channel, []uint64{}, 0)
summary, err := s.groupDMSummary(ctx, channel, user.ID)
if err != nil {
return nil, err
}
output := &dmChannelOutput{}
output.Body.Channel = s.dmChannelPayload(ctx, summary)
return output, nil
})
}
// Пределы групповой беседы (AGENT.md 7.8): не больше десяти участников и
// 64 символа в названии — группа не должна превращаться в сервер.
const (
maxGroupDMMembers = 10
maxGroupDMNameLength = 64
)
// groupDMMembers проверяет список приглашённых: пользователи существуют, это
// не сам приглашающий, никто не заблокирован.
func (s *Server) groupDMMembers(ctx context.Context, selfID uint64, rawIDs []string) ([]uint64, error) {
if len(rawIDs) > maxGroupDMMembers-1 {
return nil, humaErrorStatus(http.StatusConflict, "dm.members_limit", "group conversation is full")
}
seen := map[uint64]bool{}
members := make([]uint64, 0, len(rawIDs))
for _, rawID := range rawIDs {
targetID, err := parseID("recipient_ids", rawID)
if err != nil {
return nil, err
}
if targetID == selfID || seen[targetID] {
continue
}
if _, err := s.store.GetUser(ctx, targetID); err != nil {
return nil, humaError(err)
}
if blocked, err := s.store.IsBlocked(ctx, selfID, targetID); err != nil {
return nil, humaError(err)
} else if blocked {
return nil, humaErrorStatus(http.StatusForbidden, "dm.blocked", "personal messages are blocked")
}
seen[targetID] = true
members = append(members, targetID)
}
return members, nil
}
// defaultGroupDMName собирает имя по участникам: «Алиса, Боб и ещё 2».
func (s *Server) defaultGroupDMName(ctx context.Context, members []uint64) string {
names := make([]string, 0, len(members))
for _, memberID := range members {
member, err := s.store.GetUser(ctx, memberID)
if err != nil {
continue
}
name := member.DisplayName
if name == "" {
name = member.Username
}
names = append(names, name)
}
if len(names) == 0 {
return "Групповая беседа"
}
// В имя попадает не больше двух имён: длинный список обрезается.
if len(names) > 2 {
return fmt.Sprintf("%s, %s и ещё %d", names[0], names[1], len(names)-2)
}
return strings.Join(names, ", ")
}
// groupDMForMember проверяет, что канал — личная беседа, а пользователь в ней
// состоит. Посторонним отвечаем 404: существование чужой беседы не подтверждаем.
func (s *Server) groupDMForMember(ctx context.Context, rawChannelID string, userID uint64) (*store.Channel, error) {
channelID, err := parseID("channel_id", rawChannelID)
if err != nil {
return nil, err
}
channel, err := s.store.GetChannel(ctx, channelID)
if err != nil {
return nil, humaError(err)
}
if channel.Type != store.ChannelDM || channel.GuildID != nil {
return nil, humaErrorStatus(http.StatusNotFound, "not_found", "conversation not found")
}
participant, err := s.store.IsDMParticipant(ctx, channelID, userID)
if err != nil {
return nil, humaError(err)
}
if !participant {
return nil, humaErrorStatus(http.StatusNotFound, "not_found", "conversation not found")
}
return channel, nil
}
// groupDMSummary собирает карточку групповой беседы для ответа API.
func (s *Server) groupDMSummary(ctx context.Context, channel *store.Channel, viewerID uint64) (store.DMChannelSummary, error) {
participants, err := s.store.DMParticipantProfiles(ctx, channel.ID, viewerID)
if err != nil {
return store.DMChannelSummary{}, humaError(err)
}
ownerID := channel.DMOwnerID
return store.DMChannelSummary{
Channel: *channel,
IsGroup: true,
GroupName: channel.Name,
MemberCount: len(participants),
Participants: participants,
OwnerID: ownerID,
}, nil
}
// dispatchDMCreate сообщает участникам о новой беседе (AGENT.md 8.3).
func (s *Server) dispatchDMCreate(channelID uint64, userIDs []uint64) {
if s.gateway == nil {
return
}
for _, userID := range userIDs {
s.gateway.SendToUser(userID, "DM_CHANNEL_CREATE", map[string]any{
"channel_id": formatSnowflake(channelID),
})
}
}
// dispatchDMUpdate сообщает участникам об изменении состава или имени: сам
// канал отдаётся в событии, чтобы клиент не делал лишний запрос.
func (s *Server) dispatchDMUpdate(channel *store.Channel, except []uint64, include uint64) {
if s.gateway == nil {
return
}
participants, err := s.store.DMParticipants(context.Background(), channel.ID)
if err != nil {
s.logger.WarnContext(context.Background(), "не удалось прочитать участников беседы",
slog.Any("error", err))
return
}
skip := map[uint64]bool{}
for _, userID := range except {
skip[userID] = true
}
recipients := make([]uint64, 0, len(participants)+1)
for _, userID := range participants {
if !skip[userID] {
recipients = append(recipients, userID)
}
}
if include != 0 {
recipients = append(recipients, include)
}
payload := map[string]any{"channel_id": formatSnowflake(channel.ID)}
for _, userID := range recipients {
s.gateway.SendToUser(userID, "DM_CHANNEL_UPDATE", payload)
}
}
// relationshipTarget находит пользователя по id или логину.
@@ -546,12 +930,49 @@ func (s *Server) relationshipPayload(_ uint64, profile *store.RelationshipProfil
}
// dmChannelPayload собирает личную беседу для API.
func (s *Server) dmChannelPayload(summary store.DMChannelSummary) dmChannelPayload {
func (s *Server) dmChannelPayload(ctx context.Context, summary store.DMChannelSummary) dmChannelPayload {
payload := dmChannelPayload{
ID: formatSnowflake(summary.Channel.ID),
Type: string(store.ChannelDM),
CanSend: true,
CanView: true,
// Звонок приходит вместе с беседой: клиенту не нужен отдельный запрос,
// чтобы показать «идёт звонок» в списке и шапке (AGENT.md 7.8).
ActiveCall: s.liveCallPayload(ctx, summary.Channel.ID),
}
if summary.IsGroup {
payload.IsGroup = true
payload.Name = summary.GroupName
payload.MemberCount = summary.MemberCount
if summary.Channel.IconFileID != nil {
payload.IconFileID = formatSnowflake(*summary.Channel.IconFileID)
}
if summary.OwnerID != nil {
payload.OwnerID = formatSnowflake(*summary.OwnerID)
}
payload.Recipients = make([]dmRecipientPayload, 0, len(summary.Participants))
for _, participant := range summary.Participants {
item := dmRecipientPayload{
UserID: formatSnowflake(participant.UserID),
Username: participant.Username,
DisplayName: participant.DisplayName,
Status: participant.Status,
VisibleStatus: visibleStatus(participant.Status, participant.LastSeenAt),
IsOwner: participant.IsOwner,
IsSelf: participant.IsCurrentUser,
}
if participant.AvatarFileID != nil {
item.AvatarFileID = formatSnowflake(*participant.AvatarFileID)
}
payload.Recipients = append(payload.Recipients, item)
}
if summary.LastMessageAt != nil {
payload.LastMessageAt = summary.LastMessageAt.UTC().Format(timeLayout)
}
if summary.LastMessageID != 0 {
payload.LastMessageID = formatSnowflake(summary.LastMessageID)
}
return payload
}
payload.Recipient.UserID = formatSnowflake(summary.RecipientID)
payload.Recipient.Username = summary.RecipientLogin
@@ -569,6 +990,8 @@ func (s *Server) dmChannelPayload(summary store.DMChannelSummary) dmChannelPaylo
}
if summary.AvatarFileID != nil {
payload.Recipient.AvatarFileID = formatSnowflake(*summary.AvatarFileID)
// У 1:1 иконка беседы — аватар собеседника (как в READY).
payload.IconFileID = formatSnowflake(*summary.AvatarFileID)
}
if summary.LastSeenAt != nil {
payload.Recipient.LastSeenAt = summary.LastSeenAt.UTC().Format(timeLayout)
+218
View File
@@ -0,0 +1,218 @@
package server
import (
"context"
"errors"
"log/slog"
"net/http"
"net/url"
"time"
"github.com/danielgtaylor/huma/v2"
"glchat/internal/config"
"glchat/internal/store"
"glchat/internal/unfurl"
)
// Unfurl ссылок (AGENT.md Фаза 7): сервер загружает заголовок, описание и
// картинку страницы по ссылке из сообщения и отдаёт клиенту карточку превью.
//
// Безопасность важнее полноты: загрузка идёт через internal/unfurl (только
// http/https, запрет внутренних адресов с проверкой на подключении, лимиты
// времени и размера, только HTML). Картинка на сервер не скачивается — клиент
// берёт её по проверенному URL с `referrerpolicy=no-referrer`.
//
// Кэш живёт в БД (`link_previews`): успешные превью — на сутки, «пустые» и
// запрещённые — на час, сбои — на десять минут, чтобы не долбить чужой сайт.
// Время жизни записей кэша по статусу.
const (
unfurlTTLOK = 24 * time.Hour
unfurlTTLEmpty = time.Hour
unfurlTTLBlocked = time.Hour
unfurlTTLError = 10 * time.Minute
)
// linkPreviewer загружает и кэширует превью ссылок.
type linkPreviewer struct {
enabled bool
fetcher *unfurl.Fetcher
store *store.Store
now func() time.Time
}
// newLinkPreviewer собирает загрузчик по конфигу: выключенный unfurl не
// создаёт ни клиента, ни фоновых задач.
func newLinkPreviewer(cfg config.Config, st *store.Store, logger *slog.Logger) *linkPreviewer {
return &linkPreviewer{
enabled: cfg.UnfurlEnabled,
fetcher: unfurl.NewFetcher(unfurl.Options{
Timeout: cfg.UnfurlTimeout,
Logger: logger,
}),
store: st,
now: func() time.Time { return time.Now().UTC() },
}
}
// linkPreviewPayload — карточка превью для клиента.
type linkPreviewPayload struct {
URL string `json:"url"`
Title string `json:"title,omitempty"`
Description string `json:"description,omitempty"`
SiteName string `json:"site_name,omitempty"`
ImageURL string `json:"image_url,omitempty"`
}
type linkPreviewOutput struct {
Body struct {
// Status: ok (карточка есть), empty (метаданных нет), blocked (адрес
// запрещён), error (не удалось загрузить). Карточка показывается
// только при ok.
Status string `json:"status"`
Preview *linkPreviewPayload `json:"preview,omitempty"`
}
}
// registerUnfurlRoutes описывает ручку превью ссылок.
func (s *Server) registerUnfurlRoutes(api huma.API) {
security := []map[string][]string{{"sessionCookie": {}}, {"bearerAuth": {}}}
huma.Register(api, huma.Operation{
OperationID: "getLinkPreview",
Method: http.MethodGet,
Path: "/link-previews",
Summary: "Карточка превью по ссылке из сообщения",
Tags: []string{"Unfurl"},
Security: security,
}, func(ctx context.Context, input *struct {
URL string `query:"url" required:"true" maxLength:"2048"`
},
) (*linkPreviewOutput, error) {
user, _, err := requireUser(ctx)
if err != nil {
return nil, err
}
if !s.unfurl.enabled {
return nil, humaErrorStatus(http.StatusServiceUnavailable, "unfurl.disabled",
"link previews are disabled on this instance")
}
output := &linkPreviewOutput{}
status, preview, err := s.linkPreview(ctx, user.ID, input.URL)
if err != nil {
return nil, err
}
output.Body.Status = status
output.Body.Preview = preview
return output, nil
})
}
// linkPreview отдаёт превью из кэша или загружает его. Статусы совпадают с
// тем, что видит клиент: ok, empty, blocked, error.
func (s *Server) linkPreview(ctx context.Context, userID uint64, rawURL string) (string, *linkPreviewPayload, error) {
normalized, ok := unfurl.Normalize(rawURL)
if !ok {
// Сюда попадают не только «плохие» адреса, но и file://, data: и
// прочие схемы: клиент карточку не показывает, ошибки не показывает тем
// более — просто «нет превью».
return store.LinkPreviewStatusBlocked, nil, nil
}
key := unfurl.URLHash(normalized)
cached, err := s.store.GetLinkPreview(ctx, key)
if err == nil {
return linkPreviewFromCache(cached)
}
if !errors.Is(err, store.ErrNotFound) {
return "", nil, humaError(err)
}
// Новый запрос к чужому сайту — только под лимитом: кэш повторные
// обращения не считает, иначе один пользователь может гонять чужие
// страницы пачками (AGENT.md 8.6).
if allowed, retryAfter := s.unfurlLimiter.Allow(pushLimitKey(userID)); !allowed {
return "", nil, rateLimitedError(retryAfter)
}
preview, fetchErr := s.unfurl.fetcher.Fetch(ctx, normalized)
status := store.LinkPreviewStatusError
switch {
case fetchErr == nil && preview.Empty():
status = store.LinkPreviewStatusEmpty
case fetchErr == nil:
status = store.LinkPreviewStatusOK
case errors.Is(fetchErr, unfurl.ErrBlocked), errors.Is(fetchErr, unfurl.ErrUnsupported):
status = store.LinkPreviewStatusBlocked
}
if fetchErr != nil && status == store.LinkPreviewStatusError {
s.logger.WarnContext(ctx, "link preview failed",
slog.String("host", hostOf(normalized)), slog.Any("error", fetchErr))
}
// Ошибку кэша не поднимаем наверх: превью уже получено (или честно
// отсутствует), а запись в кэш — оптимизация.
now := s.unfurl.now()
params := store.SaveLinkPreviewParams{
URLHash: key,
URL: normalized,
Title: preview.Title,
Description: preview.Description,
SiteName: preview.SiteName,
ImageURL: preview.ImageURL,
Status: status,
FetchedAt: now,
ExpiresAt: now.Add(unfurlTTLFor(status)),
}
if err := s.store.SaveLinkPreview(ctx, params); err != nil {
s.logger.WarnContext(ctx, "link preview cache write failed", slog.Any("error", err))
}
if status != store.LinkPreviewStatusOK {
return status, nil, nil
}
return status, &linkPreviewPayload{
URL: normalized,
Title: preview.Title,
Description: preview.Description,
SiteName: preview.SiteName,
ImageURL: preview.ImageURL,
}, nil
}
// linkPreviewFromCache переводит запись кэша в ответ клиенту.
func linkPreviewFromCache(cached *store.LinkPreview) (string, *linkPreviewPayload, error) {
if cached.Status != store.LinkPreviewStatusOK {
return cached.Status, nil, nil
}
return cached.Status, &linkPreviewPayload{
URL: cached.URL,
Title: cached.Title,
Description: cached.Description,
SiteName: cached.SiteName,
ImageURL: cached.ImageURL,
}, nil
}
// unfurlTTLFor выбирает срок жизни записи кэша по статусу.
func unfurlTTLFor(status string) time.Duration {
switch status {
case store.LinkPreviewStatusOK:
return unfurlTTLOK
case store.LinkPreviewStatusEmpty:
return unfurlTTLEmpty
case store.LinkPreviewStatusBlocked:
return unfurlTTLBlocked
default:
return unfurlTTLError
}
}
// hostOf достаёт хост для логов: полный URL с query туда не попадает.
func hostOf(normalized string) string {
parsed, err := url.Parse(normalized)
if err != nil {
return ""
}
return parsed.Hostname()
}
+241
View File
@@ -0,0 +1,241 @@
package server
import (
"net/http"
"net/http/httptest"
"net/url"
"strconv"
"strings"
"testing"
"time"
"glchat/internal/store"
"glchat/internal/unfurl"
)
// withUnfurl подменяет загрузчик превью на тестовый: httptest-серверы живут
// на петле, поэтому AllowPrivate разрешён только здесь.
func withUnfurl(t *testing.T, srv *Server, st *store.Store) {
t.Helper()
srv.unfurl = &linkPreviewer{
enabled: true,
fetcher: unfurl.NewFetcher(unfurl.Options{AllowPrivate: true}),
store: st,
now: func() time.Time { return time.Now().UTC() },
}
}
// htmlServer отдаёт страницу с метаданными Open Graph.
func htmlServer(t *testing.T, body string) *httptest.Server {
t.Helper()
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
_, _ = w.Write([]byte(body))
}))
t.Cleanup(server.Close)
return server
}
func TestLinkPreviewReturnsCardAndCaches(t *testing.T) {
srv, st := newPushTestServer(t, true)
withUnfurl(t, srv, st)
cookie := registerAndLogin(t, srv, "unfurl_user", "unfurl@example.com")
page := htmlServer(t, `<!doctype html><html><head>
<meta property="og:title" content="Заголовок страницы">
<meta property="og:description" content="Краткое описание">
<meta property="og:site_name" content="Example">
<meta property="og:image" content="/cover.png">
</head><body>текст</body></html>`)
rec := doJSON(t, srv, http.MethodGet,
"/api/v1/link-previews?url="+urlQueryEscape(page.URL+"/page?utm=1"), "", cookie)
if rec.Code != http.StatusOK {
t.Fatalf("превью = %d, body = %s", rec.Code, rec.Body.String())
}
payload := decodeResponse[struct {
Status string `json:"status"`
Preview struct {
URL string `json:"url"`
Title string `json:"title"`
Description string `json:"description"`
SiteName string `json:"site_name"`
ImageURL string `json:"image_url"`
} `json:"preview"`
}](t, rec)
if payload.Status != store.LinkPreviewStatusOK {
t.Fatalf("статус = %q, body = %s", payload.Status, rec.Body.String())
}
if payload.Preview.Title != "Заголовок страницы" || payload.Preview.Description != "Краткое описание" {
t.Fatalf("неожиданная карточка: %+v", payload.Preview)
}
if payload.Preview.SiteName != "Example" {
t.Fatalf("site_name = %q", payload.Preview.SiteName)
}
if !strings.HasPrefix(payload.Preview.ImageURL, page.URL) {
t.Fatalf("относительная картинка не развёрнута: %q", payload.Preview.ImageURL)
}
// Второй запрос обслуживается кэшем: страница уже недоступна.
page.Close()
rec = doJSON(t, srv, http.MethodGet,
"/api/v1/link-previews?url="+urlQueryEscape(page.URL+"/page?utm=1"), "", cookie)
if rec.Code != http.StatusOK {
t.Fatalf("повторный запрос = %d, body = %s", rec.Code, rec.Body.String())
}
cached := decodeResponse[struct {
Status string `json:"status"`
Preview struct {
Title string `json:"title"`
} `json:"preview"`
}](t, rec)
if cached.Status != store.LinkPreviewStatusOK || cached.Preview.Title != "Заголовок страницы" {
t.Fatalf("кэш не отдал превью: %s", rec.Body.String())
}
}
func TestLinkPreviewEmptyPage(t *testing.T) {
srv, st := newPushTestServer(t, true)
withUnfurl(t, srv, st)
cookie := registerAndLogin(t, srv, "unfurl_empty", "unfurl-empty@example.com")
page := htmlServer(t, `<!doctype html><html><head><title></title></head><body>нет метаданных</body></html>`)
rec := doJSON(t, srv, http.MethodGet,
"/api/v1/link-previews?url="+urlQueryEscape(page.URL), "", cookie)
if rec.Code != http.StatusOK {
t.Fatalf("ответ = %d, body = %s", rec.Code, rec.Body.String())
}
payload := decodeResponse[struct {
Status string `json:"status"`
Preview any `json:"preview"`
}](t, rec)
// <title></title> пустой: карточки нет.
if payload.Status != store.LinkPreviewStatusEmpty || payload.Preview != nil {
t.Fatalf("ожидался empty без карточки: %s", rec.Body.String())
}
}
func TestLinkPreviewRejectsInternalAndUnsupported(t *testing.T) {
srv, st := newPushTestServer(t, true)
withUnfurl(t, srv, st)
cookie := registerAndLogin(t, srv, "unfurl_bad", "unfurl-bad@example.com")
// Проверяем боевую конфигурацию: AllowPrivate в тестовом загрузчике
// разрешает петлю, поэтому здесь собираем загрузчик как в продакшене.
srv.unfurl.fetcher = unfurl.NewFetcher(unfurl.Options{})
cases := map[string]string{
"метаданные облака": "https://169.254.169.254/latest/meta-data/",
"частная сеть": "https://10.1.2.3/secret",
"петля": "https://127.0.0.1:8080/api/v1/meta",
"file": "file:///etc/passwd",
"javascript": "javascript:alert(1)",
"data": "data:text/html,<h1>x</h1>",
}
for name, target := range cases {
t.Run(name, func(t *testing.T) {
rec := doJSON(t, srv, http.MethodGet,
"/api/v1/link-previews?url="+urlQueryEscape(target), "", cookie)
if rec.Code != http.StatusOK {
t.Fatalf("ответ = %d, body = %s", rec.Code, rec.Body.String())
}
payload := decodeResponse[struct {
Status string `json:"status"`
Preview any `json:"preview"`
}](t, rec)
if payload.Status != store.LinkPreviewStatusBlocked {
t.Fatalf("статус = %q, ожидался blocked (%s)", payload.Status, rec.Body.String())
}
if payload.Preview != nil {
t.Fatalf("карточка не должна отдаваться: %s", rec.Body.String())
}
})
}
}
func TestLinkPreviewNonHTMLIsError(t *testing.T) {
srv, st := newPushTestServer(t, true)
withUnfurl(t, srv, st)
cookie := registerAndLogin(t, srv, "unfurl_json", "unfurl-json@example.com")
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"title":"я не страница"}`))
}))
defer server.Close()
rec := doJSON(t, srv, http.MethodGet,
"/api/v1/link-previews?url="+urlQueryEscape(server.URL), "", cookie)
payload := decodeResponse[struct {
Status string `json:"status"`
}](t, rec)
if payload.Status != store.LinkPreviewStatusError {
t.Fatalf("статус = %q, ожидался error (%s)", payload.Status, rec.Body.String())
}
}
func TestLinkPreviewDisabled(t *testing.T) {
srv, _ := newPushTestServer(t, true)
srv.unfurl.enabled = false
cookie := registerAndLogin(t, srv, "unfurl_off", "unfurl-off@example.com")
rec := doJSON(t, srv, http.MethodGet,
"/api/v1/link-previews?url=https%3A%2F%2Fexample.com%2F", "", cookie)
if rec.Code != http.StatusServiceUnavailable {
t.Fatalf("ответ = %d, ожидался 503", rec.Code)
}
if code := errorCodeOf(t, rec); code != "unfurl.disabled" {
t.Fatalf("код ошибки = %q", code)
}
}
func TestLinkPreviewRequiresSession(t *testing.T) {
srv, _ := newPushTestServer(t, true)
rec := doJSON(t, srv, http.MethodGet,
"/api/v1/link-previews?url=https%3A%2F%2Fexample.com%2F", "")
if rec.Code != http.StatusUnauthorized {
t.Fatalf("ответ = %d, ожидался 401", rec.Code)
}
}
// Кэш не расходует лимит, а новые загрузки — расходуют: иначе один клиент
// может гонять чужие страницы пачками.
func TestLinkPreviewRateLimit(t *testing.T) {
srv, st := newPushTestServer(t, true)
withUnfurl(t, srv, st)
cookie := registerAndLogin(t, srv, "unfurl_flood", "unfurl-flood@example.com")
page := htmlServer(t, `<html><head><meta property="og:title" content="t"></head></html>`)
limited := false
for i := 0; i < 40; i++ {
target := page.URL + "/page-" + itoa(i)
rec := doJSON(t, srv, http.MethodGet,
"/api/v1/link-previews?url="+urlQueryEscape(target), "", cookie)
if rec.Code == http.StatusTooManyRequests {
limited = true
break
}
}
if !limited {
t.Fatal("лимит новых загрузок превью не сработал")
}
}
func TestMetaExposesUnfurlFlag(t *testing.T) {
srv, _ := newPushTestServer(t, true)
rec := doJSON(t, srv, http.MethodGet, "/api/v1/meta", "")
payload := decodeResponse[struct {
Features struct {
UnfurlEnabled bool `json:"unfurl_enabled"`
} `json:"features"`
}](t, rec)
if !payload.Features.UnfurlEnabled {
t.Fatal("meta не сообщает о включённом unfurl")
}
}
// urlQueryEscape кодирует URL для query-параметра.
func urlQueryEscape(value string) string { return url.QueryEscape(value) }
// itoa нужен для уникальных адресов страниц в тесте лимита.
func itoa(value int) string { return strconv.Itoa(value) }

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