Commit Graph

87 Commits

Author SHA1 Message Date
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 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 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 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 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 05409d0914 fix(gateway): смена пароля не закрывает сессию текущего устройства
ChangePassword отзывает остальные сессии, но обработчик закрывал соединения
всех устройств пользователя — включая то, с которого пароль сменили: страница
теряла шлюз в момент успешного ответа, и подтверждение «Пароль изменён» не
показывалось (пункт 12 матрицы 11.6, меняли пароль через интерфейс).

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

Тест: TestInvalidateUserExceptKeepsCurrentSession (второе устройство получает
INVALID_SESSION, текущее продолжает отвечать на heartbeat).
2026-09-22 21:58:23 +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 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 c7446a5d4c feat(security): security.txt отдаётся, npm audit и SBOM в AppSec
- /.well-known/security.txt уходил в SPA-заглушку (200 text/html): добавлена
  копия в web/public и явный тип text/plain в статике (RFC 9116, AGENT.md 11.3);
- make security дополнен `npm audit --omit=dev --audit-level=high`;
- новая цель `make sbom` — CycloneDX-отчёт зависимостей (trivy, если есть).
2026-09-22 20:39:44 +03:00
grendervill 7ec9dbfdee fix(realtime): уход на вход после отзыва сессии и бан в открытом сервере
- invalidateCurrentUser перечитывает профиль, а не только удаляет кэш: без
  запроса AuthGuard не видит 401 и второе устройство остаётся в приложении
  после logout-all или смены пароля (AGENT.md 11.6, пункт 12);
- сброс сессии и уход из последнего сервера инвалидируют список серверов:
  пустой снапшот подставлял устаревший REST-ответ, и сервер возвращался в
  рейку (видно и после кика);
- joinGuild проверяет бан сервера: публичный сервер больше не обходится
  нажатием «Войти» вместо приглашения (AGENT.md 7.17, 7.20), тест расширен;
- e2e/voice-ten приведён к линту, console.log разрешён в e2e (диагностика
  прогона), артефакты Playwright исключены из prettier.
2026-09-22 20:35:13 +03:00
grendervill 3bac21ad24 fix(instance): публичная информация отдаёт voice_enabled
Поле никогда не заполнялось, поэтому /api/v1/instance всегда сообщал
voice_enabled:false, хотя голос настроен (клиент читает признак из /meta,
но контракт 6.5 обещает его и здесь).
2026-09-22 20:15:12 +03:00
grendervill e5af7b1f83 style: форматирование после правок событий роли и комнат 2026-09-22 20:11:33 +03:00
grendervill d241714a6d fix(realtime): события ролей, комнат и отзыв сессий без перезагрузки
Дефекты, найденные e2e-матрицей AGENT.md 11.6 (пункты 1, 2, 7, 12):

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

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

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

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

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

Тесты: 4 Go-теста (бан блокирует вход и сессии, защита админов, поиск,
уборка бана при удалении), web-тест панели, живая проверка на стенде 19/19.
2026-09-22 18:59:53 +03:00
grendervill af03cfb063 feat(security): проверка Origin, строгий CSP и лимиты загрузок
- middleware OriginCheck: state-changing запросы с чужого Origin отклоняются
  (AGENT.md 9.7); запросы без Origin пропускаются — cookie уже SameSite=Lax
- CSP перечисляет директивы явно (script-src/worker-src/manifest-src 'self',
  style-src с inline для React, img-src с внешними https для аватаров вебхуков,
  connect-src с доменом файлов и LiveKit); добавлены Permissions-Policy,
  Cross-Origin-Opener-Policy и X-Permitted-Cross-Domain-Policies
- лимиты по AGENT.md 8.6: загрузки 10/мин и 100/сутки (вложения и аватары),
  реакции 20/мин; администратор инстанса лимиты обходит
- step-up: смена прав роли (в теле PATCH) и удаление сервера (перед вызовом
  /auth/step-up) требуют свежего подтверждения личности
- тесты: Origin (свой/чужой/GET), состав CSP, лимит реакций, step-up на права
  роли; исправлен вызов NewRateLimiter (второй аргумент — burst, не окно)
2026-09-22 00:40:10 +03:00
grendervill 1cb949a7a4 feat(ops): проверка бэкапа и уборка файлов без записи
- glchat verify-backup: расшифровка age, integrity_check, foreign_key_check,
  схема и состав данных, сверка с манифестом; живые данные не трогает
- чистка удаляет файлы на диске, у которых не осталось записи в базе (так
  оставались вложения удалённых серверов), и пустые подкаталоги; работает
  через os.Root, чтобы символическая ссылка не вывела за каталог файлов
- удаление сервера теперь чистит и содержимое файлов, а не только записи
- install.sh пишет значения .env с пробелами в кавычках: файл подключается
  через `set -a && . .env`, иначе имя главного сервера выполнялось как команда
- тесты: Go на уборку файлов без записи (свежие не трогаются, пустые
  каталоги удаляются)
2026-09-21 23:33:04 +03:00
grendervill ef871bcd96 feat(instance): админ-панель с лимитами медиа и действиями над пользователями
- лимиты медиа живут в настройках инстанса (миграция 00016): аватары,
  оформление сервера, эмодзи, звуки и галерея; все загрузки берут предел
  оттуда, значения по умолчанию — из AGENT.md 7.7
- действия администратора: временный пароль (показывается один раз, сессии
  отзываются), выход со всех устройств, мягкое удаление пользователя
  (сообщения и аудит остаются), переименование и удаление любого сервера
- админ-панель разбита на вкладки: обзор и здоровье, лимиты, пользователи,
  серверы, аудит; смена администраторов подтверждается личностью (step-up)
- тесты: Go (действия администратора, лимит эмодзи из настроек) и Vitest
  (вкладки, сброс пароля, выход, удаление, переименование сервера)
2026-09-21 22:37:47 +03:00
grendervill dd4432452c fix(web): манифест PWA отдаётся с правильным типом
Go определяет .webmanifest как text/plain, из-за чего браузер мог отказаться
ставить приложение: тип задаём явно вместе с js/css/svg.
2026-09-21 19:45:25 +03:00
grendervill 5afcfee744 feat(ops): чистка данных, reindex и PWA с офлайн-режимом
- internal/retention: истёкшие сессии, аудит старше RETENTION_AUDIT_DAYS и
  файлы без ссылок старше RETENTION_ORPHAN_HOURS; сиротой файл считается
  только если на него не ссылается ничего (вложения, аватары, оформление
  сервера, комнат, ролей, эмодзи, звуки, вебхуки, приглашения)
- обслуживание запускается в приложении по RETENTION_INTERVAL_SECONDS;
  команды glchat cleanup и glchat reindex делают то же вручную
- PWA: манифест с иконками и shortcuts, офлайн-страница, сервис-воркер
  (кэш только /assets/*, приватные запросы не кэшируются)
- тесты: Go на чистку (сирота удаляется, используемые файлы и свежие сироты
  остаются, истёкшая сессия уходит), Vitest на манифест и регистрацию воркера
2026-09-21 19:42:12 +03:00
grendervill 72537e9ff9 fix(invites): картинки оформления отдаются без сессии для страницы приглашения
- GET /files/{id} без сессии отдаёт только оформление сервера и картинку
  приглашения (guild_icon, guild_banner, guild_splash, invite_background):
  их показывают до входа (AGENT.md 7.9), остальное по-прежнему требует сессии
- optionalUser резолвит сессию без ответа об ошибке; путь файла проверяется на
  принадлежность каталогу данных (AGENT.md 9.2)
- аватар пригласившего показывается только вошедшим
- тесты: Go (splash анонимно, вложение закрыто) и правки живого скрипта
2026-09-21 19:30:25 +03:00
grendervill 566e1cf981 feat(invites): страница приглашения со splash и переопределением
- миграция 00015: переопределение splash у приглашения (AGENT.md 7.9)
- GET /invites/{code} доступен без сессии: гость по ссылке видит карточку
  сервера, описание, число участников и пригласившего
- POST/DELETE /invites/{code}/background: своя картинка страницы под правом
  создателя или MANAGE_GUILD, файл удаляется при замене и снятии
- клиент: маршрут /invite/:code с фоном (переопределение → splash → баннер),
  карточкой сервера, кнопками «Присоединиться»/«Открыть сервер» и входом с
  возвратом на страницу (?next=)
- тесты: Go (карточка, загрузка, права, снятие) и Vitest (splash, гость,
  присоединение, недействительная ссылка)
2026-09-21 19:25:06 +03:00
grendervill ed98c78c9e feat(social): блокировка пользователей
- PUT/DELETE /users/@me/blocks/{id}: блокировка снимает дружбу и заявки в обе
  стороны, разблокировка возвращает возможность писать (AGENT.md 7.2)
- блокировка запрещает личные сообщения (ошибка dm.blocked), открытие беседы и
  заявки в друзья с любой стороны
- клиент: раздел «Заблокированные» в друзьях со снятием блокировки, действие
  «Заблокировать» в меню строки друга и в меню модерации участника
- тесты: Go (блокировка, заявки, беседа, сообщение) и Vitest (список, снятие,
  блокировка из меню)
2026-09-21 19:21:00 +03:00
grendervill afcdf9c1c4 fix(guilds): фон комнаты в READY и без дублей категорий в шаблонах
- ReadyChannel отдаёт background_file_id: после перезагрузки фон комнаты больше
  не теряется до REST-запроса (AGENT.md 7.5)
- шаблон больше не создаёт категории дважды: они перечислены в шаблоне и
  раньше создавались ещё и по ссылке из комнаты
- тест: структура «Сообщества» ровно 7 комнат без повторов
2026-09-21 18:16:18 +03:00
grendervill b43452aa04 feat(profile): системные бейджи рядом с ником
- участники сервера отдают список бейджей из badges_json (AGENT.md 7.2):
  список расширяемый, назначается только системой
- клиент: каталог бейджей (щит администратора инстанса, корона владельца,
  ранний сторонник, проверенный), неизвестные показываются нейтральной меткой
- бейджи видны в ленте, списке участников и друзьях вместо текстовой плашки
- тесты: Go (бейджи в списке участников) и Vitest (каталог и отрисовка)
2026-09-21 18:10:47 +03:00
grendervill 0f0060e8a4 feat(channels): фон комнаты и статичные превью при reduced-motion
- миграция 00014: background_file_id у комнаты (AGENT.md 7.5, 7.7)
- API: POST/DELETE /channels/{id}/background под MANAGE_CHANNEL_BACKGROUND,
  фон отдаётся в списке комнат и рассылается событием CHANNEL_UPDATE
- клиент: фон рисуется за лентой с затемнением, блок «Фон комнаты» в настройках
  комнаты (загрузка, замена, удаление)
- AnimatedImage: при «уменьшить движение» показывает статичный первый кадр,
  нарисованный на canvas, вместо анимации
- тесты: Go (загрузка, права, снятие) и Vitest (лента, настройки, reduced-motion)
2026-09-21 18:08:21 +03:00
grendervill 91f415e36d feat(guilds): встроенные шаблоны серверов
- каталог шаблонов «Пустой», «Сообщество», «Игровой» (AGENT.md 7.3): роли,
  категории, текстовые и голосовые комнаты без сообщений
- POST /guilds принимает template_id, неизвестный шаблон отклоняется; аудит
  фиксирует выбранный шаблон
- GET /guild-templates отдаёт каталог с предпросмотром структуры
- клиент: выбор шаблона в окне создания сервера
- тесты: Go (структура каждого шаблона) и Vitest (выбор шаблона)
2026-09-21 18:04:52 +03:00
grendervill a36ee9527b feat(cosmetics): оформление «для друзей» в друзьях и личных беседах
- API: список друзей отдаёт вычисленное оформление «для друзей» одним запросом
  на весь список (AGENT.md 7.2)
- клиент: рамка, иконка, цвет и эффект ника в списке друзей, в ленте личных
  бесед — из профиля собеседника
- тесты: Go на список друзей, Vitest на отрисовку в друзьях
2026-09-21 01:48:56 +03:00
grendervill 75b71da7b7 fix(roles): роли на себя выдавать можно, самомодерация запрещена
Владелец сервера или участник с MANAGE_ROLES не мог выдать роль себе: общий
запрет самомодерации (кик, бан, тайм-аут) распространялся и на оформление.
Проверка иерархии для ролей вынесена отдельно: себя менять можно, владельца и
администратора инстанса — по-прежнему нет.
2026-09-21 01:40:17 +03:00
grendervill 0646faae2a fix(cosmetics): область стиля в ответе и ожидание данных в проверке
- GET /users/@me/styles отдаёт запись для каждой области, даже если стиль ещё
  не задан: иначе клиент не мог выбрать пер-серверную область
- живая проверка ждёт загрузки галереи и вариантов рамок
- уточнён приоритет: эффект роли важнее личного стиля (AGENT.md 7.2)
2026-09-21 01:32:48 +03:00
grendervill 48e30a1897 feat(cosmetics): оформление профиля — галерея, роли и личные стили
- миграция 00013: guild_cosmetics (рамки и иконки) и user_styles («для друзей»
  и пер-серверные стили) (AGENT.md 7.2, 7.4)
- store: CRUD галереи, личные стили и вычисление оформления поэлементно
  (роль → пер-серверный стиль → «для друзей» → дефолт) с откатом, когда роль
  больше не выдаёт элемент
- API: галерея сервера под MANAGE_ROLES, оформление роли в create/update,
  GET/PUT/DELETE /users/@me/styles с доступными элементами по областям
- клиент: редактор личного стиля с предпросмотром, галерея оформления в
  настройках сервера, оформление роли в разделе «Роли»
- рендер: рамка под аватаром, иконка рядом с ником, цвет и встроенные эффекты
  ника (CSS, при reduced-motion статично) в ленте и списке участников
2026-09-21 01:28:48 +03:00
grendervill 2e038a1d3f feat(webhooks): вебхуки комнат — бэкенд, клиент и настройки комнаты
- миграция 00012: таблица webhooks, снимок имени и аватара в messages (AGENT.md 7.11)
- API: список/создание/правка/удаление и пересоздание токена (MANAGE_WEBHOOKS,
  step-up при создании, аудит), загрузка аватара отдельной multipart-ручкой
- исполнение POST /webhooks/{id}/{token} без сессии: content, username,
  avatar_url, файлы создателя вебхука, лимит 30/мин на вебхук
- клиент: пункт настроек «Комната» со списком вебхуков, копированием ссылки,
  пересозданием токена и удалением
- сообщения вебхуков: имя, аватар и значок в ленте вместо «неизвестного автора»
- fix: неполный ответ REST больше не затирает данные READY-снапшота
2026-09-21 00:26:50 +03:00
grendervill 4ea2965892 fix(web): баннер сервера из снапшота, приглашение, встроенные звуки
- READY-снапшот больше не теряет banner/splash/accent: шапка сервера
  показывает баннер сразу после подключения (AGENT.md 7.4)
- GUILD_UPDATE несёт всё оформление, клиент применяет его без перезагрузки
- кнопка приглашения в шапке сервера со ссылкой и копированием
- встроенные звуки интерфейса на Web Audio с разблокировкой по жесту
- object URL превью через безопасные обёртки (jsdom и урезанные webview)
2026-09-21 00:04:49 +03:00
grendervill 02a5b99418 feat(guilds): оформление сервера — иконка, баннер, splash и акцентный цвет
Фаза 4 (AGENT.md 7.4): сервер можно оформить, поля в схеме были с Фазы 1, но
ручек и отдачи в API не было.

- `POST/DELETE /guilds/{id}/appearance/{icon|banner|splash}` (multipart,
  MANAGE_GUILD, только изображения до 8 МБ): файл сохраняется назначением
  `guild_icon|guild_banner|guild_splash`, предыдущий удаляется, пишется аудит
  и уходит `GUILD_UPDATE`; ответ — обновлённый сервер;
- `PATCH /guilds/{id}` принимает `accent_color` (0 — цвет темы);
- оформление отдаётся в READY, в списке серверов, в детальной карточке и в
  предпросмотре приглашения — клиенту не нужен отдельный запрос;
- анимированные GIF/APNG/WebP сохраняются как есть: изображения не
  перекодируются, поэтому анимация не теряется;
- тесты: загрузка и очистка всех трёх видов, замена файла, запрет без
  MANAGE_GUILD и без сессии, отказ для не-изображения, акцентный цвет.
2026-09-20 23:26:46 +03:00
grendervill dff4a25332 fix(moderation): администратор инстанса тоже не может модерировать себя
Проверка иерархии пропускала все запреты для администратора инстанса, включая
запрет на модерацию себя: он мог забанить или исключить самого себя и потерять
доступ к серверу (поймано при проверке интерфейса на стенде — самобан снял
участие в «Главном сервере»).

Теперь проверка `targetID == actor.ID` выполняется до обхода иерархии, для
администратора инстанса остаются доступны любые другие цели. Регрессионный
тест: самобан и самоисключение администратора инстанса дают 403.
2026-09-20 22:57:45 +03:00
grendervill 4fb3509128 feat(moderation): баны сервера, тайм-ауты и фильтры журнала аудита
Фаза 4 (AGENT.md 7.17, 7.18): модерация участников.

- миграция 00011: таблица `guild_bans` (бан переживает исключение участника);
- `PUT /guilds/{id}/bans/{user_id}` — бан с причиной и права `BAN_MEMBERS`:
  исключает участника, отключает его от голосовой комнаты, пишет системное
  сообщение, аудит и события `MEMBER_REMOVE`/`GUILD_DELETE`;
- `DELETE /guilds/{id}/bans/{user_id}` — снятие бана, `GET .../bans` — список
  с автором и причиной (только модераторам);
- вход по приглашению для забаненного отклоняется с `guild.banned`;
- тайм-аут уже был в схеме, но не работал: кэш прав комнат не сбрасывался при
  изменениях сервера, поэтому участник с тайм-аутом продолжал писать. Ключ
  кэша комнат теперь включает сервер, `InvalidateGuild` чистит и его;
- тайм-аут уходит в `MEMBER_UPDATE` (и снимается событием), истёкшие
  тайм-ауты не показываются в профиле участника;
- журнал аудита получил фильтры `action`, `actor_id`, `target_id`, `before_id`;
- тесты: полный цикл бана (включая запрет входа и разбан), права и иерархия,
  блокировка сообщений тайм-аутом, запрет тайм-аута на себя, фильтры аудита.
2026-09-20 22:38:48 +03:00
grendervill 5668d779df feat(instance): метрики всей машины вторым рядом дашборда
Дашборд показывал только контейнер (лимиты профиля), поэтому не было видно,
сколько ресурсов у хоста всего.

- `sysinfo.NewHostSampler` и `ReadHostMemory` всегда читают `/proc/stat` и
  `/proc/meminfo`, игнорируя cgroup;
- в ответе метрик появились `host_cpu` и `host_memory` (`source: host`);
- недоступные источники попадают в проверку `metrics`, а не ломают ручку.
2026-09-20 22:10:09 +03:00
grendervill ce6a566f83 fix(sysinfo): переносимое приведение размера блока statfs
`statfs.Bsize` в Linux — int64, в Darwin — uint32: сборка образа для linux/amd64
падала на приведении к uint32. Приведение к int64 безопасно на обеих
платформах; проверено `GOOS=linux go vet` по всем пакетам.
2026-09-20 21:28:08 +03:00
grendervill 4c3736b5fb feat(instance): метрики и healthcheck инстанса для дашборда
Раздел «Инстанс» получает живые показатели сервера (AGENT.md 7.19).

- `internal/sysinfo`: загрузка процессора по cgroup v2 (`cpu.stat`/`cpu.max`)
  с откатом на `/proc/stat`, память по лимиту контейнера либо `/proc/meminfo`,
  место на разделе данных и размер базы вместе с журналом WAL;
- `GET /api/v1/instance/metrics` (только администратор инстанса): метрики,
  состояние (`ok|warn|failed`), проверки app/database/disk/storage/voice/
  gateway, версия и время работы; недоступные источники не ломают ответ, а
  попадают в проверку `metrics`;
- тяжёлые проверки (БД, SFU, запись) кэшируются на 5 секунд: ручку можно
  опрашивать раз в секунду;
- `voice.IssueRoomList` и `AdminClient.Ping` проверяют доступность RoomService
  и валидность ключей LiveKit (та же связка, что ломала модерацию);
- у метрик свой лимит частоты, они исключены из общего лимита API
  (`RateLimiter.MiddlewareExcept`), иначе открытый раздел съедал бы половину
  бюджета запросов;
- тесты: `internal/sysinfo` (cgroup, /proc, память, диск, размеры файлов) и
  `internal/server/api_metrics_test.go` (доступ, состав метрик, кэш, 429).
2026-09-20 21:26:17 +03:00
grendervill 4ba449670a fix(voice): читать ответ GetParticipant в формате LiveKit 1.x
RoomService отвечает объектом участника на верхнем уровне, а не в обёртке
`{"participant": …}`: дорожка микрофона не находилась и серверный мьют молча
пропускался (AGENT.md 7.14). Обёртку тоже принимаем — на случай смены формы
ответа в новых версиях.

- `participantEnvelope` разбирает оба варианта;
- тесты `internal/voice/admin_test.go`: выбор дорожки микрофона (не камеры),
  тело MutePublishedTrack, отсутствие микрофона, обёрнутый ответ,
  RemoveParticipant и выключенный клиент; в каждом вызове проверяется
  Bearer-токен, привязанный к комнате.
2026-09-20 19:59:04 +03:00
grendervill a6d23a295e fix(voice): административный токен RoomService привязан к комнате
LiveKit принимает право `roomAdmin` только вместе с именем комнаты: без
`video.room` RoomService отвечает 401 `permissions denied` — серверный мьют и
отключение участника не доходили до SFU, хотя API отвечал 200 (AGENT.md 7.14).

- `voice.IssueAdmin(room)` кладёт в грахт `room` и `roomAdmin` и отклоняет
  пустое имя комнаты;
- `AdminClient.call` выпускает токен под конкретную комнату каждого вызова
  (GetParticipant, MutePublishedTrack, RemoveParticipant, ListParticipants);
- тест на состав грахта и отказ при пустой комнате;
- e2e: администратор и гость работают в отдельных api-контекстах (код 2FA
  одноразовый), нажатие «Войти» в комнату не ждёт стабильности исчезающей
  кнопки.
2026-09-20 19:54:21 +03:00
grendervill 2538e07ec3 fix(voice): отдавать клиенту базовый адрес LiveKit без сегмента /rtc
LiveKit SDK сам дописывает сигнальный путь (`/rtc`, `/rtc/v1`) к базовому
адресу, поэтому `wss://host/rtc` из конфига превращался в `wss://host/rtc/rtc`
и комната навсегда оставалась в состоянии «Подключение…» (AGENT.md 7.14).

- `voice.ResolveLiveKitURL` снимает завершающий сегмент `/rtc`, сохраняя
  префикс прокси (`wss://host/livekit/rtc` → `wss://host/livekit`);
- `POST /channels/{id}/voice/join` отдаёт адрес через `VoiceURLForClient` и
  отвечает `voice.disabled`, если адрес не разрешился;
- тесты: таблица адресов, ожидание `wss://gl.test` в голосовом сценарии.
2026-09-20 19:29:19 +03:00
grendervill 1552146aa1 feat(voice): модерация через RoomService, саундборд и звуковая палитра
AGENT.md 7.13, 7.14:

- `internal/voice/admin.go` — клиент RoomService (Twirp/JSON, без SDK):
  GetParticipant, ListParticipants, MutePublishedTrack, RemoveParticipant с
  административным токеном; внутренний адрес SFU задаётся LIVEKIT_API_URL;
- серверный мьют теперь применяется и на стороне SFU (клиент не обойдёт),
  кик из голосовой (DELETE /guilds/{id}/voice-states/{user_id}) удаляет
  участника из комнаты через RemoveParticipant, перемещение — тоже;
- саундборд и звуковая палитра: таблица `guild_sounds` (миграция 00010),
  загрузка MP3/OGG/WAV/WebM/M4A до 512 КБ с MANAGE_SOUNDS, отдельные лимиты
  30 + 30, переименование, удаление, список с фильтром по виду;
- проигрывание POST /guilds/{id}/sounds/{sound_id}/play: нужно право
  USE_SOUNDBOARD и присутствие в голосовой комнате, событие SOUNDBOARD_PLAY
  уходит только участникам этой комнаты (звук играют клиенты), лимит 3 звука
  за 10 секунд; набор звуков приходит в READY и обновляется событием
  GUILD_SOUNDS_UPDATE;
- тесты: права на загрузку, требование события для звука интерфейса, запрет
  проигрывания вне комнаты, лимит частоты, переименование и удаление.
2026-09-20 19:01:14 +03:00
grendervill 0db4c63023 feat(voice): журнал обработанных вебхуков LiveKit 2026-09-20 18:45:14 +03:00