Compare commits
72 Commits
e97f260113
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| d2443ae460 | |||
| a48b865bc9 | |||
| c88f774749 | |||
| 00d86bb70c | |||
| a835903938 | |||
| 1fd9734fff | |||
| 8814fe7cf4 | |||
| 8cf6bedf58 | |||
| b9d458e133 | |||
| e7c9c93443 | |||
| 2d013df0bb | |||
| b53cf9d6e7 | |||
| 7512857363 | |||
| c0137007ce | |||
| 12dc41ee7c | |||
| 78e9c3c2d6 | |||
| 2b9bf55f16 | |||
| 431013c3e3 | |||
| 0b55fbdfe2 | |||
| 50709396cd | |||
| 96e0cd5918 | |||
| 605b2038f1 | |||
| 8696d85514 | |||
| c429cbab30 | |||
| 5320618d31 | |||
| 3dc200c196 | |||
| 09b4f6878f | |||
| 6afbdf621b | |||
| 324d2aac72 | |||
| 45a5eaae64 | |||
| f45cc21a9c | |||
| a9b1073877 | |||
| 7afd23d6d4 | |||
| f9464130cd | |||
| 50265c66b8 | |||
| cca64451e2 | |||
| e305b417e8 | |||
| 11b491d6d3 | |||
| cebce0ae3b | |||
| cd1d662572 | |||
| 8e096ca6b2 | |||
| 4175b1436e | |||
| ad7b3d21bb | |||
| ee334a77bc | |||
| 73dd51b121 | |||
| 5a419112e0 | |||
| 8c2bb3e18c | |||
| 5176b277a6 | |||
| 71c0107b87 | |||
| cf36488e6f | |||
| 70eae99873 | |||
| 5f21c9d069 | |||
| 61cad0fdfc | |||
| 19d2501ab0 | |||
| 4d39002a1f | |||
| 72a51f083d | |||
| 085454d555 | |||
| 72c5189e8f | |||
| 498201b413 | |||
| eeececaff8 | |||
| dcafb10046 | |||
| 05409d0914 | |||
| a7b4e3318e | |||
| 4a299155e7 | |||
| 751ecc6d45 | |||
| ea1b4a7a90 | |||
| a6cec5919a | |||
| cffcec72b8 | |||
| 594a5c0af5 | |||
| ab855fbd4f | |||
| e4bbc27a7c | |||
| 63466fb75b |
@@ -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
|
||||
|
||||
@@ -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 \
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -55,10 +55,4 @@ ${DOMAIN} {
|
||||
}
|
||||
}
|
||||
|
||||
${FILES_DOMAIN} {
|
||||
encode zstd gzip
|
||||
import security_headers
|
||||
handle {
|
||||
reverse_proxy app:8080
|
||||
}
|
||||
}
|
||||
${GLCHAT_FILES_BLOCK}
|
||||
@@ -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: конец блока --------------------------------------------------
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 "$@" ;;
|
||||
|
||||
@@ -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. По умолчанию не выполняем её, чтобы
|
||||
|
||||
@@ -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}" \
|
||||
|
||||
@@ -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`). Публичный ключ уже вшит в сборку и меняется только
|
||||
вместе с ключом: старые сборки отвергнут манифест, подписанный новым ключом.
|
||||
@@ -0,0 +1,4 @@
|
||||
# Generated by Cargo
|
||||
# will have compiled files and executables
|
||||
/target/
|
||||
/gen/schemas
|
||||
@@ -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
|
||||
@@ -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"]
|
||||
}
|
||||
|
After Width: | Height: | Size: 9.7 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 3.4 KiB |
|
After Width: | Height: | Size: 7.2 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 1.9 KiB |
|
After Width: | Height: | Size: 3.8 KiB |
|
After Width: | Height: | Size: 5.0 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 4.2 KiB |
@@ -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}"))
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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, ¤t) {
|
||||
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, ¤t, cli.minimized) {
|
||||
log::error!("{error}");
|
||||
}
|
||||
if let Err(error) = shortcuts::apply(app, ¤t) {
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
// Отключает лишнее консольное окно на Windows в релизной сборке.
|
||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||
|
||||
fn main() {
|
||||
glchat_desktop_lib::run();
|
||||
}
|
||||
@@ -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")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
¬ifications,
|
||||
&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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
"публичный ключ подписи должен быть вшит в сборку"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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(¤t) == 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),
|
||||
"тряска без смещений ничего не показывает"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
@@ -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
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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 }
|
||||
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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 ""
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 с проверкой адреса")
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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) }
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
}
|
||||
|
||||
|
||||
@@ -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) }
|
||||
@@ -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)})
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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])
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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) }
|
||||