feat(push): Web Push — VAPID, подписки устройств и отправка (AGENT.md 7.16)

Уведомления в браузере и на телефоне: сервер сам решает, кому их слать, и
подписывает запрос VAPID-ключом, поэтому уведомление приходит, даже когда
клиент закрыт.

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

Тесты: правила и VAPID, доставка с поддельным push-эндпоинтом (шифрование,
заголовки VAPID/TTL/Topic, очистка мёртвых подписок), ручки и лимиты, отправка
при личном сообщении и упоминании, пропуск онлайн-получателей, миграция на
чистой БД.
This commit is contained in:
2026-09-26 16:14:20 +03:00
parent f9464130cd
commit 7afd23d6d4
27 changed files with 3157 additions and 7 deletions
+7
View File
@@ -51,6 +51,13 @@ OAUTH_DISCORD_CLIENT_SECRET=
# Необязательно: внешний адрес инстанса для redirect_uri (пусто — берётся из DOMAIN).
OAUTH_REDIRECT_BASE=
# --- Web Push (Фаза 7): уведомления в браузере и на телефоне ---
# Приватный VAPID-ключ (P-256) в PKCS#8 DER (base64, одной строкой). Пусто —
# Web Push выключен. Ключ генерирует установщик; вручную — `glchat vapid-keys`.
VAPID_PRIVATE_KEY=
# Контакт администратора для push-сервисов (mailto:admin@<домен> или https://…).
VAPID_SUBJECT=
AGE_RECIPIENT= # публичный age-ключ для шифрования бэкапов
BACKUP_KEEP_DAYS= # сколько дней хранить бэкапы (профиль задаёт значение)
BACKUP_OFFSITE_TARGET= # необязательно: rsync-цель (user@host:/path) или rclone:remote:path
+38 -2
View File
@@ -11,6 +11,7 @@ import (
"strconv"
"time"
webpush "github.com/SherClockHolmes/webpush-go"
"github.com/pquerna/otp/totp"
"glchat/internal/auth"
@@ -41,6 +42,8 @@ func runCLI(args []string) int {
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
@@ -399,8 +402,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 байт)\n",
stats.Sessions, stats.Audit, stats.Files, stats.Unknown,
stats.PushSubscriptions, stats.Bytes)
return err
})
}
@@ -415,3 +420,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
}
+1 -1
View File
@@ -43,7 +43,7 @@ func main() {
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", "cleanup", "reindex", "vapid-keys":
os.Exit(runCLI(os.Args[1:]))
}
}
+10
View File
@@ -45,6 +45,7 @@ glchat — управление инстансом
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> сменить домен
@@ -227,6 +228,14 @@ cmd_reindex() {
${COMPOSE} exec -T app glchat reindex
}
# cmd_vapid_keys печатает новую пару VAPID-ключей (Фаза 7): строки из вывода
# добавляются в .env инстанса вручную — смена ключа отзывает все подписки,
# поэтому автоматически её никто не делает.
cmd_vapid_keys() {
require_stack
${COMPOSE} exec -T app glchat vapid-keys "$@"
}
# cmd_cleanup запускает чистку данных вручную: те же правила, что у
# периодического обслуживания (AGENT.md 6.4).
cmd_cleanup() {
@@ -261,6 +270,7 @@ main() {
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 "$@" ;;
+47
View File
@@ -638,6 +638,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
@@ -693,6 +711,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="$(oauth_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
# OAuth (Фаза 7) не генерируется: клиентские id/секреты выдаёт провайдер.
# Значение берём из окружения, иначе сохраняем прежнее из .env — иначе
# `--reconfigure` молча выключил бы вход через провайдеров.
@@ -886,6 +923,12 @@ OAUTH_DISCORD_CLIENT_SECRET=@OAUTH_DISCORD_CLIENT_SECRET@
# Необязательно: внешний адрес инстанса для redirect_uri (пусто — из домена).
OAUTH_REDIRECT_BASE=@OAUTH_REDIRECT_BASE@
# --- 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@
@@ -966,6 +1009,10 @@ OAUTH_DISCORD_CLIENT_ID=$(env_quote "$OAUTH_DISCORD_CLIENT_ID")
OAUTH_DISCORD_CLIENT_SECRET=$(env_quote "$OAUTH_DISCORD_CLIENT_SECRET")
OAUTH_REDIRECT_BASE=$(env_quote "$OAUTH_REDIRECT_BASE")
# 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:-}
+2
View File
@@ -16,6 +16,7 @@ require (
)
require (
github.com/SherClockHolmes/webpush-go v1.4.0 // indirect
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
@@ -28,6 +29,7 @@ require (
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/net v0.59.0 // indirect
golang.org/x/sync v0.22.0 // indirect
golang.org/x/sys v0.48.0 // indirect
)
+69
View File
@@ -1,3 +1,5 @@
github.com/SherClockHolmes/webpush-go v1.4.0 h1:ocnzNKWN23T9nvHi6IfyrQjkIc0oJWv1B1pULsf9i3s=
github.com/SherClockHolmes/webpush-go v1.4.0/go.mod h1:XSq8pKX11vNV8MJEMwjrlTkxhAj1zKfxmyhdV7Pd6UA=
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc h1:biVzkmvwrH8WK8raXaxBx6fRVTlJILwEwQGL1I/ByEI=
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
github.com/coder/websocket v1.8.15 h1:6B2JPeOGlpff2Uz6vOEH1Vzpi0iUz20A+lPVhPHtNUA=
@@ -17,8 +19,10 @@ github.com/go-webauthn/webauthn v0.18.2 h1:0BeftmEHU7i3Dv0VFwBtidy/ba37Vcdjvqst9
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=
@@ -52,20 +56,85 @@ 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/oauth2 v0.37.0 h1:JUlcxA8oAtauLfiH8FX2/FkAWHAdi0QtGCGc+hofE98=
golang.org/x/oauth2 v0.37.0/go.mod h1:IxwZNxUULJmpBFf9K/9NTMSIfZZuvuTy1gGxhigP/58=
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=
+12
View File
@@ -73,6 +73,11 @@ type Config struct {
// OAuthRedirectBase — внешний адрес инстанса для redirect_uri (пусто —
// берётся BaseURL из домена). Нужен, если клиент ходит по другому адресу.
OAuthRedirectBase string
// Web Push (AGENT.md 7.16, Фаза 7): приватный VAPID-ключ (PKCS#8 DER в
// base64, генерирует установщик) и контакт администратора для push-сервисов
// (mailto:… или https://…). Пустой ключ — Web Push выключен.
VAPIDPrivateKey string
VAPIDSubject string
}
func Load() (Config, error) {
@@ -119,6 +124,9 @@ func Load() (Config, error) {
OAuthDiscordClientID: env("OAUTH_DISCORD_CLIENT_ID", ""),
OAuthDiscordClientSecret: env("OAUTH_DISCORD_CLIENT_SECRET", ""),
OAuthRedirectBase: env("OAUTH_REDIRECT_BASE", ""),
VAPIDPrivateKey: env("VAPID_PRIVATE_KEY", ""),
VAPIDSubject: env("VAPID_SUBJECT", ""),
}
if cfg.FilesDomain == "" {
cfg.FilesDomain = "files." + cfg.Domain
@@ -168,6 +176,10 @@ func (c Config) VoiceEnabled() bool {
// OAuthEnabled сообщает, настроен ли хотя бы один OAuth-провайдер (AGENT.md 7.1).
func (c Config) OAuthEnabled() bool { return len(c.OAuthProviders()) > 0 }
// 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`, а не ломает вход.
@@ -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;
+104
View File
@@ -0,0 +1,104 @@
package database
import (
"context"
"path/filepath"
"testing"
)
// TestPushSubscriptionsMigration проверяет миграцию 00021 на чистой базе:
// таблица подписок Web Push, уникальность эндпоинта, внешний ключ на users и
// индекс по пользователю (AGENT.md 7.16, Фаза 7).
func TestPushSubscriptionsMigration(t *testing.T) {
ctx := context.Background()
db, err := Open(ctx, Options{
Path: filepath.Join(t.TempDir(), "glchat.db"),
ReadPool: 2,
Migrate: true,
})
if err != nil {
t.Fatalf("Open() вернул ошибку: %v", err)
}
defer func() {
if err := db.Close(); err != nil {
t.Errorf("Close(): %v", err)
}
}()
version, err := db.SchemaVersion(ctx)
if err != nil {
t.Fatalf("SchemaVersion(): %v", err)
}
if version < 21 {
t.Fatalf("schema_version = %d, ожидалось >= 21", version)
}
columns := map[string]bool{}
rows, err := db.Reader.QueryContext(ctx, `SELECT name FROM pragma_table_info('push_subscriptions')`)
if err != nil {
t.Fatalf("pragma_table_info: %v", err)
}
defer rows.Close()
for rows.Next() {
var name string
if err := rows.Scan(&name); err != nil {
t.Fatalf("scan: %v", err)
}
columns[name] = true
}
if err := rows.Err(); err != nil {
t.Fatalf("rows: %v", err)
}
for _, expected := range []string{
"id", "user_id", "endpoint", "p256dh", "auth", "user_agent",
"created_at", "last_success_at", "last_failure_at", "failure_count",
} {
if !columns[expected] {
t.Errorf("в push_subscriptions нет колонки %q", expected)
}
}
var indexes int
if err := db.Reader.QueryRowContext(ctx,
`SELECT COUNT(*) FROM sqlite_master WHERE type = 'index' AND name = 'push_subscriptions_user_idx'`,
).Scan(&indexes); err != nil {
t.Fatalf("чтение индекса: %v", err)
}
if indexes != 1 {
t.Errorf("индекс push_subscriptions_user_idx не создан")
}
userID := int64(1000)
if _, err := db.Writer.ExecContext(ctx,
`INSERT INTO users (id, username, username_lower, display_name, email_enc, email_index,
password_hash, locale, created_at, updated_at)
VALUES (?, 'push', 'push', 'Push', 'enc', 'idx', 'hash', 'ru', '2026-01-01T00:00:00.000Z', '2026-01-01T00:00:00.000Z')`,
userID); err != nil {
t.Fatalf("вставка пользователя: %v", err)
}
insert := `INSERT INTO push_subscriptions (id, user_id, endpoint, p256dh, auth, created_at)
VALUES (?, ?, ?, 'key', 'auth', '2026-01-01T00:00:00.000Z')`
if _, err := db.Writer.ExecContext(ctx, insert, 1, userID, "https://push.example.com/one"); err != nil {
t.Fatalf("вставка подписки: %v", err)
}
// Эндпоинт уникален: повторная подписка с того же устройства обновляет
// запись, а не создаёт вторую.
if _, err := db.Writer.ExecContext(ctx, insert, 2, userID, "https://push.example.com/one"); err == nil {
t.Fatal("повторный эндпоинт принят, ожидалась ошибка уникальности")
}
// Внешний ключ: подписка без пользователя невозможна.
if _, err := db.Writer.ExecContext(ctx, insert, 3, 999999, "https://push.example.com/two"); err == nil {
t.Fatal("подписка без пользователя принята, ожидалась ошибка внешнего ключа")
}
// Удаление пользователя убирает его подписки.
if _, err := db.Writer.ExecContext(ctx, `DELETE FROM users WHERE id = ?`, userID); err != nil {
t.Fatalf("удаление пользователя: %v", err)
}
var remaining int
if err := db.Reader.QueryRowContext(ctx, `SELECT COUNT(*) FROM push_subscriptions`).Scan(&remaining); err != nil {
t.Fatalf("подсчёт подписок: %v", err)
}
if remaining != 0 {
t.Errorf("после удаления пользователя осталось подписок: %d", remaining)
}
}
+38
View File
@@ -0,0 +1,38 @@
package gateway
// Присутствие в Gateway: кто прямо сейчас держит соединение. Нужно Web Push
// (AGENT.md 7.16, Фаза 7): если у получателя есть активная сессия, уведомление
// покажет сам клиент по своим правилам, и дублировать его на телефон не нужно.
//
// Файл отдельный, чтобы не трогать горячий gateway.go: логика читает те же
// поля и под тем же мьютексом, что и рассылка.
// IsUserOnline сообщает, есть ли у пользователя хотя бы одно соединение.
func (s *Service) IsUserOnline(userID uint64) bool {
if s == nil || userID == 0 {
return false
}
s.mu.RLock()
defer s.mu.RUnlock()
for _, session := range s.sessions {
if session.userID == userID {
return true
}
}
return false
}
// OnlineUsers возвращает количество уникальных пользователей с соединением —
// используется в тестах и диагностике.
func (s *Service) OnlineUsers() int {
if s == nil {
return 0
}
s.mu.RLock()
defer s.mu.RUnlock()
seen := make(map[uint64]struct{}, len(s.sessions))
for _, session := range s.sessions {
seen[session.userID] = struct{}{}
}
return len(seen)
}
+146
View File
@@ -0,0 +1,146 @@
package httpx
import (
"errors"
"fmt"
"net"
"net/http"
"net/url"
"strings"
"syscall"
"time"
)
// Проверки адресов для исходящих запросов (AGENT.md 9.2, 9.7): сервер сам
// ходит в интернет только там, где это осознанно (Web Push, превью ссылок),
// поэтому внутренние подсети должны быть закрыты и для IP-литералов, и для
// имён, которые в них разрешаются (DNS rebinding).
var (
// ErrAddressBlocked — адрес ведёт во внутреннюю сеть или не является IP.
ErrAddressBlocked = errors.New("address is not allowed")
// ErrURLUnsupported — схема, хост или формат URL недопустимы.
ErrURLUnsupported = errors.New("url is not supported")
)
// IsBlockedIP сообщает, что адрес нельзя запрашивать: loopback, приватные
// диапазоны, link-local (включая метаданные облака 169.254.169.254),
// multicast, unspecified и IPv4-mapped варианты приватных адресов.
func IsBlockedIP(ip net.IP) bool {
if ip == nil {
return true
}
// IPv4-mapped (::ffff:10.0.0.1) разворачиваем в IPv4: иначе проверки
// 4-байтовых диапазонов его не увидят.
if v4 := ip.To4(); v4 != nil {
ip = v4
}
if ip.IsLoopback() || ip.IsPrivate() || ip.IsLinkLocalUnicast() ||
ip.IsLinkLocalMulticast() || ip.IsMulticast() || ip.IsUnspecified() {
return true
}
// 0.0.0.0/8 и 100.64.0.0/10 (CGNAT), 192.0.0.0/24 — заведомо не публичные
// адреса назначения.
if v4 := ip.To4(); v4 != nil {
return v4[0] == 0 ||
(v4[0] == 100 && v4[1] >= 64 && v4[1] <= 127) ||
(v4[0] == 192 && v4[1] == 0 && v4[2] == 0)
}
// IPv6: unique local (fc00::/7) и link-local уже покрыты стандартными
// проверками, отдельно закрываем 2001:db8::/32 (диапазон документации).
return len(ip) == net.IPv6len && ip[0] == 0x20 && ip[1] == 0x01 && ip[2] == 0x0d && ip[3] == 0xb8
}
// SafeDialControl — функция для net.Dialer.Control: проверяет уже разрешённый
// адрес в момент подключения, поэтому подмена DNS между проверкой и запросом
// ничего не даёт.
func SafeDialControl(_ string, address string, _ syscall.RawConn) error {
host, _, err := net.SplitHostPort(address)
if err != nil {
host = address
}
ip := net.ParseIP(strings.Trim(host, "[]"))
if ip == nil {
return fmt.Errorf("%w: %q is not an ip address", ErrAddressBlocked, address)
}
if IsBlockedIP(ip) {
return fmt.Errorf("%w: %s", ErrAddressBlocked, ip)
}
return nil
}
// SafeTransport собирает транспорт для исходящих запросов: без прокси из
// окружения, с проверкой адреса на подключении, с таймаутами и без keep-alive
// на чужих хостах.
func SafeTransport(timeout time.Duration) *http.Transport {
if timeout <= 0 {
timeout = 5 * time.Second
}
return &http.Transport{
Proxy: nil,
DialContext: (&net.Dialer{
Timeout: timeout,
KeepAlive: 30 * time.Second,
Control: SafeDialControl,
}).DialContext,
TLSHandshakeTimeout: timeout,
ResponseHeaderTimeout: timeout,
ExpectContinueTimeout: time.Second,
MaxIdleConns: 4,
MaxIdleConnsPerHost: 2,
IdleConnTimeout: 30 * time.Second,
ForceAttemptHTTP2: true,
}
}
// SafeClient возвращает HTTP-клиент с проверкой адресов и общим таймаутом.
func SafeClient(timeout time.Duration) *http.Client {
if timeout <= 0 {
timeout = 5 * time.Second
}
return &http.Client{
Transport: SafeTransport(timeout),
Timeout: timeout,
CheckRedirect: func(req *http.Request, via []*http.Request) error {
if len(via) >= 3 {
return fmt.Errorf("%w: too many redirects", ErrAddressBlocked)
}
// Каждый хоп проверяем теми же правилами, что и первый запрос:
// редирект во внутреннюю сеть — обычный приём SSRF.
if _, err := ValidatePublicURL(req.URL.String(), false); err != nil {
return err
}
return nil
},
}
}
// ValidatePublicURL проверяет URL для исходящего запроса: разрешены только
// http/https (http — если allowHTTP), без userinfo и без литерального
// внутреннего адреса. Имя хоста окончательно проверяется при подключении
// (SafeDialControl): до резолва доверять ему нельзя.
func ValidatePublicURL(raw string, allowHTTP bool) (*url.URL, error) {
trimmed := strings.TrimSpace(raw)
if trimmed == "" || len(trimmed) > 2048 {
return nil, fmt.Errorf("%w: empty or too long", ErrURLUnsupported)
}
parsed, err := url.Parse(trimmed)
if err != nil {
return nil, fmt.Errorf("%w: %w", ErrURLUnsupported, err)
}
scheme := strings.ToLower(parsed.Scheme)
if scheme != "https" && (!allowHTTP || scheme != "http") {
return nil, fmt.Errorf("%w: scheme %q", ErrURLUnsupported, parsed.Scheme)
}
if parsed.User != nil {
return nil, fmt.Errorf("%w: userinfo is not allowed", ErrURLUnsupported)
}
host := parsed.Hostname()
if host == "" {
return nil, fmt.Errorf("%w: host is empty", ErrURLUnsupported)
}
if ip := net.ParseIP(host); ip != nil && IsBlockedIP(ip) {
return nil, fmt.Errorf("%w: %s", ErrAddressBlocked, ip)
}
return parsed, nil
}
+141
View File
@@ -0,0 +1,141 @@
package httpx
import (
"net"
"net/http"
"net/url"
"strings"
"testing"
"time"
)
// TestIsBlockedIP покрывает адреса, по которым сервер не должен ходить сам
// (AGENT.md 9.2, 9.7): внутренние сети, метаданные облака, multicast.
func TestIsBlockedIP(t *testing.T) {
blocked := []string{
"127.0.0.1", "127.9.9.9", "::1",
"10.0.0.1", "172.16.0.1", "172.31.255.255", "192.168.1.1",
"169.254.169.254", "fe80::1",
"0.0.0.0", "::", "0.1.2.3",
"224.0.0.1", "ff02::1",
"100.64.0.1", "100.127.255.255",
"192.0.0.1",
"fc00::1", "fd12:3456::1",
"::ffff:10.0.0.1", "::ffff:127.0.0.1",
"2001:db8::1",
}
for _, raw := range blocked {
ip := net.ParseIP(raw)
if ip == nil {
t.Fatalf("не разобран адрес %q", raw)
}
if !IsBlockedIP(ip) {
t.Errorf("IsBlockedIP(%s) = false, ожидалось true", raw)
}
}
allowed := []string{"8.8.8.8", "1.1.1.1", "138.16.226.159", "2606:4700::1111", "100.63.255.255"}
for _, raw := range allowed {
ip := net.ParseIP(raw)
if ip == nil {
t.Fatalf("не разобран адрес %q", raw)
}
if IsBlockedIP(ip) {
t.Errorf("IsBlockedIP(%s) = true, ожидалось false", raw)
}
}
if !IsBlockedIP(nil) {
t.Error("IsBlockedIP(nil) = false, ожидалось true")
}
}
func TestValidatePublicURL(t *testing.T) {
cases := []struct {
name string
raw string
allowHTTP bool
wantErr bool
}{
{name: "https", raw: "https://example.com/page?a=1"},
{name: "http запрещён", raw: "http://example.com/", wantErr: true},
{name: "http разрешён флагом", raw: "http://example.com/", allowHTTP: true},
{name: "file", raw: "file:///etc/passwd", wantErr: true},
{name: "gopher", raw: "gopher://example.com/", wantErr: true},
{name: "javascript", raw: "javascript:alert(1)", wantErr: true},
{name: "userinfo", raw: "https://user:pass@example.com/", wantErr: true},
{name: "пусто", raw: " ", wantErr: true},
{name: "loopback", raw: "https://127.0.0.1/x", wantErr: true},
{name: "метаданные", raw: "https://169.254.169.254/latest/meta-data/", wantErr: true},
{name: "приватная сеть", raw: "https://10.1.2.3/", wantErr: true},
{name: "ipv6 loopback", raw: "https://[::1]/", wantErr: true},
{name: "слишком длинный", raw: "https://example.com/" + strings.Repeat("a", 2100), wantErr: true},
}
for _, testCase := range cases {
t.Run(testCase.name, func(t *testing.T) {
parsed, err := ValidatePublicURL(testCase.raw, testCase.allowHTTP)
if testCase.wantErr {
if err == nil {
t.Fatalf("ValidatePublicURL(%q) = %v, ожидалась ошибка", testCase.raw, parsed)
}
return
}
if err != nil {
t.Fatalf("ValidatePublicURL(%q) вернул ошибку: %v", testCase.raw, err)
}
})
}
}
// TestSafeDialControl проверяет, что проверка идёт по уже разрешённому адресу:
// именно так закрывается DNS rebinding.
func TestSafeDialControl(t *testing.T) {
blocked := []string{"127.0.0.1:443", "[::1]:443", "169.254.169.254:80", "10.0.0.5:8080"}
for _, address := range blocked {
if err := SafeDialControl("tcp", address, nil); err == nil {
t.Errorf("SafeDialControl пропустил %s", address)
}
}
if err := SafeDialControl("tcp", "93.184.216.34:443", nil); err != nil {
t.Fatalf("SafeDialControl отклонил публичный адрес: %v", err)
}
// Имя вместо адреса означает, что резолв не произошёл: доверять нельзя.
if err := SafeDialControl("tcp", "example.com:443", nil); err == nil {
t.Fatal("SafeDialControl принял неразрешённое имя хоста")
}
}
func TestSafeClientBlocksUnsafeRedirects(t *testing.T) {
client := SafeClient(time.Second)
origin := &http.Request{URL: &url.URL{Scheme: "https", Host: "example.com"}}
via := []*http.Request{origin}
redirect := &http.Request{URL: &url.URL{Scheme: "http", Host: "127.0.0.1:8080", Path: "/steal"}}
if err := client.CheckRedirect(redirect, via); err == nil {
t.Fatal("CheckRedirect пропустил переход на внутренний адрес")
}
// Схема http запрещена всегда: редирект не должен понижать защиту.
redirect = &http.Request{URL: &url.URL{Scheme: "http", Host: "example.com"}}
if err := client.CheckRedirect(redirect, via); err == nil {
t.Fatal("CheckRedirect пропустил переход на http")
}
redirect = &http.Request{URL: &url.URL{Scheme: "https", Host: "example.com", Path: "/next"}}
if err := client.CheckRedirect(redirect, via); err != nil {
t.Fatalf("CheckRedirect отклонил обычный https-редирект: %v", err)
}
// Больше трёх переходов — отказ (защита от цепочек).
many := []*http.Request{origin, origin, origin, origin}
if err := client.CheckRedirect(redirect, many); err == nil {
t.Fatal("CheckRedirect пропустил слишком длинную цепочку")
}
}
func TestSafeTransportHasNoProxy(t *testing.T) {
transport := SafeTransport(time.Second)
if transport.Proxy != nil {
t.Fatal("транспорт не должен использовать прокси из окружения")
}
if transport.DialContext == nil {
t.Fatal("транспорт должен задавать DialContext с проверкой адреса")
}
}
+1 -1
View File
@@ -49,7 +49,7 @@ func New(cfg config.Config) Response {
AntiBotEnabled: false,
VoiceEnabled: cfg.VoiceEnabled(),
VoiceURL: cfg.LiveKitURL,
WebPushEnabled: false,
WebPushEnabled: cfg.WebPushEnabled(),
OAuthEnabled: cfg.OAuthEnabled(),
PasskeysEnabled: cfg.WebAuthnEnabled(),
OAuthProviders: cfg.OAuthProviders(),
+343
View File
@@ -0,0 +1,343 @@
// Package push отправляет Web Push уведомления браузерам и телефонам
// (AGENT.md 7.16, Фаза 7): VAPID-подпись, шифрование полезной нагрузки по
// RFC 8291 и очередь доставки, чтобы HTTP-ручки не ждали push-сервисы.
//
// Решение «уведомлять или нет» принимает сервер (internal/push/rules.go) по
// тем же правилам, что нативные уведомления desktop-обёртки
// (web/src/lib/desktopNotifications.ts), а показывает уведомление service
// worker клиента (web/public/sw.js).
package push
import (
"context"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/x509"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"log/slog"
"net/http"
"strings"
"sync"
"time"
webpush "github.com/SherClockHolmes/webpush-go"
"glchat/internal/config"
"glchat/internal/httpx"
"glchat/internal/store"
)
const (
// DefaultTTL — сколько push-сервис хранит недоставленное уведомление.
DefaultTTL = 12 * time.Hour
// MaxPayload — предел полезной нагрузки: запись RFC 8291 — 4096 байт,
// оставляем запас на шифрование и служебные поля.
MaxPayload = 3000
// DeliverTimeout — сколько ждём push-сервис на одну доставку.
DeliverTimeout = 10 * time.Second
// MaxFailures — после стольких неудач подряд подписка удаляется.
MaxFailures = 5
// Воркеры и очередь: push не должен ни блокировать запросы, ни плодить
// горутины на каждого получателя.
workerCount = 4
queueSize = 256
)
// VAPIDSubject по умолчанию: push-сервисы требуют контакт администратора.
const defaultSubjectPrefix = "mailto:admin@"
// Payload — то, что получает service worker (web/public/sw.js).
type Payload struct {
Title string `json:"title"`
Body string `json:"body"`
// Kind — "mention" или "direct": клиент по нему выбирает иконку и текст.
Kind string `json:"kind"`
GuildID string `json:"guild_id,omitempty"`
GuildName string `json:"guild_name,omitempty"`
ChannelID string `json:"channel_id"`
ChannelName string `json:"channel_name,omitempty"`
AuthorID string `json:"author_id,omitempty"`
AuthorName string `json:"author_name,omitempty"`
MessageID string `json:"message_id,omitempty"`
// URL — путь, который открывает клик по уведомлению.
URL string `json:"url"`
// Tag — ключ схлопывания: уведомления одной комнаты заменяют друг друга.
Tag string `json:"tag"`
}
// Service — отправитель Web Push. Нулевое значение безопасно: методы
// проверяют nil, поэтому «push выключен» не требует ветвлений у вызывающего.
type Service struct {
logger *slog.Logger
store *store.Store
publicKey string
privateKey string
subject string
ttl int
client webpush.HTTPClient
startOnce sync.Once
queue chan delivery
closed chan struct{}
closeOnce sync.Once
wg sync.WaitGroup
}
type delivery struct {
subscription store.PushSubscription
payload Payload
}
// New собирает сервис из конфига. Если VAPID-ключ не задан — возвращает nil
// без ошибки: инстанс без push работает как обычно. Некорректный ключ —
// ошибка: молча выключенный push выглядел бы как «уведомления не приходят».
func New(cfg config.Config, st *store.Store, logger *slog.Logger) (*Service, error) {
if logger == nil {
logger = slog.New(slog.DiscardHandler)
}
if strings.TrimSpace(cfg.VAPIDPrivateKey) == "" {
return nil, nil
}
privateKey, publicKey, err := ParseVAPIDKeys(cfg.VAPIDPrivateKey)
if err != nil {
return nil, fmt.Errorf("parse VAPID key: %w", err)
}
subject := strings.TrimSpace(cfg.VAPIDSubject)
if subject == "" {
subject = defaultSubjectPrefix + cfg.Domain
}
return &Service{
logger: logger,
store: st,
publicKey: publicKey,
privateKey: privateKey,
subject: subject,
ttl: int(DefaultTTL.Seconds()),
client: httpx.SafeClient(DeliverTimeout),
queue: make(chan delivery, queueSize),
closed: make(chan struct{}),
}, nil
}
// Enabled сообщает, настроен ли Web Push на инстансе.
func (s *Service) Enabled() bool { return s != nil && s.privateKey != "" }
// PublicKey — публичный VAPID-ключ (base64url) для `applicationServerKey`.
func (s *Service) PublicKey() string {
if s == nil {
return ""
}
return s.publicKey
}
// Enqueue ставит доставку в очередь. Возвращает false, если push выключен или
// очередь переполнена: уведомление теряется осознанно, но запрос не ждёт.
func (s *Service) Enqueue(subscription store.PushSubscription, payload Payload) bool {
if !s.Enabled() {
return false
}
select {
case <-s.closed:
return false
default:
}
s.start()
select {
case s.queue <- delivery{subscription: subscription, payload: payload}:
return true
default:
s.logger.Warn("push queue is full, notification dropped",
slog.String("user_id", formatID(subscription.UserID)))
return false
}
}
// Close останавливает воркеры: вызывается при завершении процесса и в тестах.
func (s *Service) Close() {
if s == nil {
return
}
s.closeOnce.Do(func() { close(s.closed) })
s.wg.Wait()
}
func (s *Service) start() {
s.startOnce.Do(func() {
for i := 0; i < workerCount; i++ {
s.wg.Add(1)
go s.work()
}
})
}
func (s *Service) work() {
defer s.wg.Done()
for {
select {
case <-s.closed:
return
case job := <-s.queue:
s.deliver(context.Background(), job)
}
}
}
// deliver отправляет одно уведомление и обслуживает ответ push-сервиса:
// успех отмечается, 404/410 удаляют подписку, остальные ошибки копят счётчик.
func (s *Service) deliver(ctx context.Context, job delivery) {
payload, err := json.Marshal(job.payload)
if err != nil {
s.logger.WarnContext(ctx, "push payload marshal failed", slog.Any("error", err))
return
}
if len(payload) > MaxPayload {
s.logger.WarnContext(ctx, "push payload is too large",
slog.Int("bytes", len(payload)), slog.String("user_id", formatID(job.subscription.UserID)))
return
}
ctx, cancel := context.WithTimeout(ctx, DeliverTimeout)
defer cancel()
response, err := webpush.SendNotificationWithContext(ctx, payload, &webpush.Subscription{
Endpoint: job.subscription.Endpoint,
Keys: webpush.Keys{
P256dh: job.subscription.P256dh,
Auth: job.subscription.Auth,
},
}, &webpush.Options{
HTTPClient: s.client,
Subscriber: s.subject,
TTL: s.ttl,
Urgency: webpush.UrgencyNormal,
Topic: topicFor(job.payload),
VAPIDPublicKey: s.publicKey,
VAPIDPrivateKey: s.privateKey,
})
if err != nil {
s.noteFailure(ctx, job.subscription, 0, err)
return
}
defer func() { _ = response.Body.Close() }()
switch {
case response.StatusCode >= 200 && response.StatusCode < 300:
if err := s.store.TouchPushSubscription(ctx, job.subscription.ID); err != nil {
s.logger.WarnContext(ctx, "push subscription touch failed", slog.Any("error", err))
}
case response.StatusCode == http.StatusNotFound || response.StatusCode == http.StatusGone:
// Подписка отозвана браузером или push-сервисом — хранить нечего.
if err := s.store.DeletePushSubscriptionByID(ctx, job.subscription.ID); err != nil {
s.logger.WarnContext(ctx, "push subscription delete failed", slog.Any("error", err))
}
default:
s.noteFailure(ctx, job.subscription, response.StatusCode, nil)
}
}
// noteFailure копит неудачи и убирает подписку, которая не работает подряд.
func (s *Service) noteFailure(ctx context.Context, subscription store.PushSubscription, status int, cause error) {
count, err := s.store.FailPushSubscription(ctx, subscription.ID)
if err != nil {
s.logger.WarnContext(ctx, "push subscription failure note failed", slog.Any("error", err))
return
}
attributes := []any{
slog.String("user_id", formatID(subscription.UserID)),
slog.Int("status", status),
slog.Int("failures", count),
}
if cause != nil {
attributes = append(attributes, slog.Any("error", cause))
}
s.logger.WarnContext(ctx, "web push delivery failed", attributes...)
if count >= MaxFailures {
if err := s.store.DeletePushSubscriptionByID(ctx, subscription.ID); err != nil {
s.logger.WarnContext(ctx, "push subscription delete failed", slog.Any("error", err))
return
}
s.logger.InfoContext(ctx, "stale push subscription removed",
slog.String("user_id", formatID(subscription.UserID)))
}
}
// topicFor схлопывает уведомления одной комнаты: свежее заменяет предыдущее,
// иначе упоминания в активной переписке заваливают телефон.
func topicFor(payload Payload) string {
if payload.ChannelID == "" {
return ""
}
return "channel-" + payload.ChannelID
}
// ParseVAPIDKeys принимает приватный ключ в двух видах и возвращает пару в
// формате, которого ждёт web-push (base64url без выравнивания):
// - PKCS#8 DER в base64 — так ключ генерирует установщик (`openssl pkcs8`);
// - «сырой» 32-байтовый скаляр в base64url — вывод GenerateVAPIDKeys().
//
// Публичный ключ всегда выводится из приватного: хранить его отдельно
// незачем, а рассинхронизация пары ломала бы подписку молча.
func ParseVAPIDKeys(encoded string) (privateKey, publicKey string, err error) {
trimmed := strings.TrimSpace(encoded)
raw, decodeErr := decodeBase64(trimmed)
if decodeErr != nil {
return "", "", fmt.Errorf("decode: %w", decodeErr)
}
var key *ecdsa.PrivateKey
switch {
case len(raw) == 32:
// «Сырой» скаляр: так ключ печатает GenerateVAPIDKeys().
parsed, parseErr := ecdsa.ParseRawPrivateKey(elliptic.P256(), raw)
if parseErr != nil {
return "", "", fmt.Errorf("parse raw private key: %w", parseErr)
}
key = parsed
default:
parsed, parseErr := x509.ParsePKCS8PrivateKey(raw)
if parseErr != nil {
return "", "", fmt.Errorf("parse PKCS#8: %w", parseErr)
}
ecdsaKey, ok := parsed.(*ecdsa.PrivateKey)
if !ok {
return "", "", errors.New("key is not an ECDSA private key")
}
if ecdsaKey.Curve != elliptic.P256() {
return "", "", fmt.Errorf("curve %s is not P-256", ecdsaKey.Curve.Params().Name)
}
key = ecdsaKey
}
// Bytes() у ecdsa отдаёт фиксированную длину и несжатую точку (SEC 1):
// именно в таком виде ключи ждёт web-push, и никаких big.Int снаружи.
scalar, err := key.Bytes()
if err != nil {
return "", "", fmt.Errorf("encode private key: %w", err)
}
point, err := key.PublicKey.Bytes()
if err != nil {
return "", "", fmt.Errorf("encode public key: %w", err)
}
return base64.RawURLEncoding.EncodeToString(scalar),
base64.RawURLEncoding.EncodeToString(point), nil
}
// decodeBase64 принимает base64 (standard/raw/url) — установщик пишет
// стандартный base64, а ключи из библиотек приходят в base64url.
func decodeBase64(value string) ([]byte, error) {
encodings := []*base64.Encoding{
base64.StdEncoding, base64.RawStdEncoding,
base64.URLEncoding, base64.RawURLEncoding,
}
var lastErr error
for _, encoding := range encodings {
decoded, err := encoding.DecodeString(value)
if err == nil {
return decoded, nil
}
lastErr = err
}
return nil, lastErr
}
func formatID(id uint64) string { return fmt.Sprintf("%d", id) }
+369
View File
@@ -0,0 +1,369 @@
package push
import (
"bytes"
"context"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/x509"
"encoding/base64"
"encoding/json"
"log/slog"
"net/http"
"net/http/httptest"
"path/filepath"
"strings"
"sync"
"testing"
"time"
webpush "github.com/SherClockHolmes/webpush-go"
"glchat/internal/config"
"glchat/internal/database"
"glchat/internal/store"
)
// newPushTestStore открывает временную БД с миграциями и создаёт пользователя:
// подписки ссылаются на users по внешнему ключу.
func newPushTestStore(t *testing.T) (*store.Store, uint64) {
t.Helper()
ctx := context.Background()
db, err := database.Open(ctx, database.Options{
Path: filepath.Join(t.TempDir(), "glchat.db"),
ReadPool: 2,
Migrate: true,
})
if err != nil {
t.Fatalf("открыть тестовую БД: %v", err)
}
t.Cleanup(func() {
if err := db.Close(); err != nil {
t.Errorf("закрыть тестовую БД: %v", err)
}
})
st := store.New(db)
user, err := st.CreateUser(ctx, store.CreateUserParams{
Username: "push_user",
DisplayName: "Push",
EmailEnc: "enc",
EmailIndex: "idx",
PasswordHash: "hash",
})
if err != nil {
t.Fatalf("создать пользователя: %v", err)
}
return st, user.ID
}
// testVAPIDKey возвращает приватный ключ в том же виде, в каком его пишет
// установщик: PKCS#8 DER в base64 (одной строкой).
func testVAPIDKey(t *testing.T) string {
t.Helper()
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
t.Fatalf("сгенерировать ключ: %v", err)
}
der, err := x509.MarshalPKCS8PrivateKey(key)
if err != nil {
t.Fatalf("marshal PKCS#8: %v", err)
}
return base64.StdEncoding.EncodeToString(der)
}
func testConfig(t *testing.T) config.Config {
return config.Config{
Domain: "gl.example.com",
VAPIDPrivateKey: testVAPIDKey(t),
VAPIDSubject: "mailto:admin@example.com",
}
}
// testSubscription сохраняет в БД подписку с указанным эндпоинтом. Ключи
// настоящие (P-256 и 16 байт auth): иначе шифрование RFC 8291 не соберётся.
func testSubscription(t *testing.T, st *store.Store, userID uint64, endpoint string) store.PushSubscription {
t.Helper()
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
t.Fatalf("сгенерировать ключ подписки: %v", err)
}
point, err := key.PublicKey.Bytes()
if err != nil {
t.Fatalf("публичный ключ подписки: %v", err)
}
auth := make([]byte, 16)
if _, err := rand.Read(auth); err != nil {
t.Fatalf("auth: %v", err)
}
subscription, err := st.SavePushSubscription(context.Background(), store.SavePushSubscriptionParams{
UserID: userID,
Endpoint: endpoint,
P256dh: base64.RawURLEncoding.EncodeToString(point),
Auth: base64.RawURLEncoding.EncodeToString(auth),
UserAgent: "test",
})
if err != nil {
t.Fatalf("сохранить подписку: %v", err)
}
return *subscription
}
func TestNewDisabledWithoutKey(t *testing.T) {
st, _ := newPushTestStore(t)
service, err := New(config.Config{Domain: "example.com"}, st, slog.New(slog.DiscardHandler))
if err != nil {
t.Fatalf("New без ключа вернул ошибку: %v", err)
}
if service != nil {
t.Fatal("без VAPID-ключа сервис должен быть nil")
}
if service.Enabled() {
t.Fatal("nil-сервис не может быть включён")
}
if service.PublicKey() != "" {
t.Fatal("у выключенного сервиса нет публичного ключа")
}
if service.Enqueue(store.PushSubscription{}, Payload{}) {
t.Fatal("выключенный сервис не должен принимать доставку")
}
service.Close()
}
func TestNewRejectsBrokenKey(t *testing.T) {
st, _ := newPushTestStore(t)
if _, err := New(config.Config{VAPIDPrivateKey: "not-a-key"}, st, nil); err == nil {
t.Fatal("некорректный ключ должен давать ошибку, а не тихо выключать push")
}
}
func TestParseVAPIDKeysAcceptsBothFormats(t *testing.T) {
// Формат библиотеки: «сырой» скаляр в base64url.
rawPrivate, rawPublic, err := webpush.GenerateVAPIDKeys()
if err != nil {
t.Fatalf("GenerateVAPIDKeys: %v", err)
}
privateKey, publicKey, err := ParseVAPIDKeys(rawPrivate)
if err != nil {
t.Fatalf("ParseVAPIDKeys(raw): %v", err)
}
if privateKey != rawPrivate || publicKey != rawPublic {
t.Fatalf("пары не совпали: %s/%s против %s/%s", privateKey, publicKey, rawPrivate, rawPublic)
}
// Формат установщика: PKCS#8 DER в base64.
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
t.Fatalf("GenerateKey: %v", err)
}
der, err := x509.MarshalPKCS8PrivateKey(key)
if err != nil {
t.Fatalf("MarshalPKCS8PrivateKey: %v", err)
}
privateKey, publicKey, err = ParseVAPIDKeys(base64.StdEncoding.EncodeToString(der))
if err != nil {
t.Fatalf("ParseVAPIDKeys(pkcs8): %v", err)
}
decodedPublic, err := base64.RawURLEncoding.DecodeString(publicKey)
if err != nil {
t.Fatalf("публичный ключ не base64url: %v", err)
}
expectedPublic, err := key.PublicKey.Bytes()
if err != nil {
t.Fatalf("публичный ключ ключа: %v", err)
}
if !bytes.Equal(decodedPublic, expectedPublic) {
t.Fatalf("публичный ключ не совпал с приватным: %d байт", len(decodedPublic))
}
expectedPrivate, err := key.Bytes()
if err != nil {
t.Fatalf("приватный ключ: %v", err)
}
decodedPrivate, err := base64.RawURLEncoding.DecodeString(privateKey)
if err != nil {
t.Fatalf("приватный ключ не base64url: %v", err)
}
if !bytes.Equal(decodedPrivate, expectedPrivate) {
t.Fatal("приватный ключ не совпал с исходным")
}
if _, _, err := ParseVAPIDKeys("!!!not base64!!!"); err == nil {
t.Fatal("мусор должен отвергаться")
}
if _, _, err := ParseVAPIDKeys(base64.StdEncoding.EncodeToString([]byte("слишком короткий ключ"))); err == nil {
t.Fatal("неверная длина ключа должна отвергаться")
}
}
func TestDeliverSuccessMarksSubscription(t *testing.T) {
st, userID := newPushTestStore(t)
var (
mu sync.Mutex
seen http.Header
method string
)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
mu.Lock()
seen = r.Header.Clone()
method = r.Method
mu.Unlock()
w.WriteHeader(http.StatusCreated)
}))
defer server.Close()
service, err := New(testConfig(t), st, slog.New(slog.DiscardHandler))
if err != nil {
t.Fatalf("New: %v", err)
}
// Эндпоинт тестовый (http + 127.0.0.1), поэтому клиент подменяем: в бою
// адрес проверяет httpx.SafeClient.
service.client = server.Client()
subscription := testSubscription(t, st, userID, server.URL)
service.deliver(context.Background(), delivery{
subscription: subscription,
payload: Payload{Title: "Упоминание", Body: "привет", ChannelID: "10", Tag: "channel-10"},
})
mu.Lock()
defer mu.Unlock()
if method != http.MethodPost {
t.Fatalf("метод = %q, ожидался POST", method)
}
if seen.Get("Content-Encoding") != "aes128gcm" {
t.Fatalf("Content-Encoding = %q", seen.Get("Content-Encoding"))
}
if seen.Get("TTL") != "43200" {
t.Fatalf("TTL = %q, ожидалось 43200", seen.Get("TTL"))
}
if seen.Get("Topic") != "channel-10" {
t.Fatalf("Topic = %q", seen.Get("Topic"))
}
if !strings.HasPrefix(seen.Get("Authorization"), "vapid t=") {
t.Fatalf("нет VAPID-подписи: %q", seen.Get("Authorization"))
}
updated, err := st.GetPushSubscriptionByEndpoint(context.Background(), server.URL)
if err != nil {
t.Fatalf("прочитать подписку: %v", err)
}
if updated.LastSuccessAt == nil {
t.Fatal("успешная доставка не отмечена")
}
if updated.FailureCount != 0 {
t.Fatalf("счётчик неудач = %d, ожидался 0", updated.FailureCount)
}
service.Close()
}
func TestDeliverRemovesRevokedSubscription(t *testing.T) {
for _, status := range []int{http.StatusNotFound, http.StatusGone} {
st, userID := newPushTestStore(t)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(status)
}))
service, err := New(testConfig(t), st, slog.New(slog.DiscardHandler))
if err != nil {
t.Fatalf("New: %v", err)
}
service.client = server.Client()
subscription := testSubscription(t, st, userID, server.URL)
service.deliver(context.Background(), delivery{subscription: subscription, payload: Payload{Title: "x"}})
if _, err := st.GetPushSubscriptionByEndpoint(context.Background(), server.URL); err == nil {
t.Fatalf("подписка с ответом %d должна быть удалена", status)
}
service.Close()
server.Close()
}
}
func TestDeliverDropsSubscriptionAfterRepeatedFailures(t *testing.T) {
st, userID := newPushTestStore(t)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
}))
defer server.Close()
service, err := New(testConfig(t), st, slog.New(slog.DiscardHandler))
if err != nil {
t.Fatalf("New: %v", err)
}
service.client = server.Client()
subscription := testSubscription(t, st, userID, server.URL)
for i := 0; i < MaxFailures; i++ {
service.deliver(context.Background(), delivery{subscription: subscription, payload: Payload{Title: "x"}})
}
if _, err := st.GetPushSubscriptionByEndpoint(context.Background(), server.URL); err == nil {
t.Fatalf("после %d неудач подписка должна быть удалена", MaxFailures)
}
service.Close()
}
// Очередь не блокирует запрос: Enqueue возвращает управление сразу, а воркеры
// доставляют уведомление в фоне.
func TestEnqueueDeliversInBackground(t *testing.T) {
st, userID := newPushTestStore(t)
delivered := make(chan struct{}, 1)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
select {
case delivered <- struct{}{}:
default:
}
w.WriteHeader(http.StatusOK)
}))
defer server.Close()
service, err := New(testConfig(t), st, slog.New(slog.DiscardHandler))
if err != nil {
t.Fatalf("New: %v", err)
}
service.client = server.Client()
subscription := testSubscription(t, st, userID, server.URL)
if !service.Enqueue(subscription, Payload{Title: "Упоминание", Body: "привет", ChannelID: "10"}) {
t.Fatal("Enqueue отказал при пустой очереди")
}
select {
case <-delivered:
case <-time.After(5 * time.Second):
t.Fatal("уведомление не доставлено за 5 секунд")
}
service.Close()
}
// Публичный ключ из конфига годится для applicationServerKey браузера.
func TestPublicKeyIsBase64URLPoint(t *testing.T) {
st, _ := newPushTestStore(t)
service, err := New(testConfig(t), st, slog.New(slog.DiscardHandler))
if err != nil {
t.Fatalf("New: %v", err)
}
decoded, err := base64.RawURLEncoding.DecodeString(service.PublicKey())
if err != nil {
t.Fatalf("публичный ключ не base64url: %v", err)
}
if len(decoded) != 65 {
t.Fatalf("длина публичного ключа = %d, ожидалось 65", len(decoded))
}
service.Close()
}
// Полезная нагрузка сериализуется в JSON с полями, которые читает service
// worker клиента (web/public/sw.js).
func TestPayloadJSONShape(t *testing.T) {
payload := Payload{
Title: "Упоминание в #общий", Body: "привет", Kind: "mention",
GuildID: "1", ChannelID: "10", MessageID: "99", Tag: "channel-10", URL: "/app/1/10",
}
encoded, err := json.Marshal(payload)
if err != nil {
t.Fatalf("marshal: %v", err)
}
for _, field := range []string{`"title"`, `"body"`, `"kind"`, `"channel_id"`, `"tag"`, `"url"`} {
if !strings.Contains(string(encoded), field) {
t.Errorf("в payload нет поля %s: %s", field, encoded)
}
}
}
+235
View File
@@ -0,0 +1,235 @@
package push
import (
"strings"
"unicode"
"glchat/internal/store"
)
// Правила уведомлений: сервер повторяет логику desktop-уведомлений клиента
// (web/src/lib/desktopNotifications.ts, docs/client-tauri.md §5), потому что
// push уходит на устройство, где приложение не запущено и решить не может.
//
// Отличия от клиента ровно два и оба вынужденные:
// - «окно в фокусе и комната открыта» сервер не знает, поэтому вместо этого
// проверяется наличие активного соединения шлюза: если оно есть,
// уведомление покажет сам клиент по своим правилам;
// - «уведомления включены» на устройстве — это наличие подписки: отписался,
// значит выключил.
// previewLimit — сколько символов сообщения показываем в уведомлении.
const previewLimit = 140
// NotificationInput — данные, от которых зависит решение об уведомлении.
type NotificationInput struct {
// MessageType — тип сообщения (AGENT.md 7.6).
MessageType store.MessageType
Content string
Mentions []uint64
// AuthorID — автор сообщения (0 у системных сообщений и вебхуков).
AuthorID uint64
// MeID — получатель: свои сообщения не уведомляют.
MeID uint64
// IsDirect — личная беседа (в т.ч. групповая).
IsDirect bool
// ChannelName — имя комнаты для заголовка (у беседы — имя или пусто).
ChannelName string
// ChannelID — комната сообщения: по ней строится ссылка и ключ схлопывания.
ChannelID uint64
// MessageID — сообщение: клик открывает комнату и подсвечивает его.
MessageID uint64
// GuildID — сервер комнаты (0 — личная беседа).
GuildID uint64
// GuildName — имя сервера для текста уведомления.
GuildName string
// AuthorName — отображаемое имя автора.
AuthorName string
// Online — у получателя есть активное соединение шлюза.
Online bool
}
// NotificationFor возвращает уведомление или nil, если показывать не нужно.
// Чистая функция: её поведение проверяется тестами без сети.
func NotificationFor(input NotificationInput) *Payload {
// Системные сообщения (входы, баны) уведомлений не требуют.
if input.MessageType == store.MessageSystem {
return nil
}
if input.AuthorID != 0 && input.AuthorID == input.MeID {
return nil
}
mentionsMe := false
for _, mentioned := range input.Mentions {
if mentioned == input.MeID {
mentionsMe = true
break
}
}
if !mentionsMe && !input.IsDirect {
return nil
}
// Получатель в приложении: уведомление покажет клиент (и только если окно
// неактивно) — второй раз с телефона дёргать не нужно.
if input.Online {
return nil
}
body := previewText(input.Content)
if body == "" && len(input.Mentions) == 0 {
return nil
}
if body == "" {
// Вложение без текста: так же, как в клиенте.
body = "Вложение"
}
kind := "mention"
title := "Упоминание"
if input.IsDirect {
kind = "direct"
title = "Личное сообщение"
if input.ChannelName != "" {
title = "Личное сообщение — " + input.ChannelName
}
} else if input.ChannelName != "" {
title = "Упоминание в #" + input.ChannelName
}
channel := formatOptionalID(input.ChannelID)
tag := ""
url := "/app"
switch {
case input.GuildID != 0 && input.ChannelID != 0:
url = "/app/" + formatID(input.GuildID) + "/" + channel
tag = "channel-" + channel
case input.ChannelID != 0:
// Личная беседа: раздел друзей выбирает беседу по идентификатору.
url = "/app/friends/" + channel
tag = "channel-" + channel
}
return &Payload{
Title: title,
Body: body,
Kind: kind,
GuildID: formatOptionalID(input.GuildID),
GuildName: input.GuildName,
ChannelID: channel,
ChannelName: input.ChannelName,
AuthorID: formatOptionalID(input.AuthorID),
AuthorName: input.AuthorName,
MessageID: formatOptionalID(input.MessageID),
URL: url,
Tag: tag,
}
}
// previewText убирает Markdown-разметку и лишние переводы строк — та же
// подготовка, что в web/src/lib/desktopNotifications.ts.
func previewText(content string) string {
plain := stripCodeBlocks(content)
plain = stripInlineCode(plain)
plain = stripLinks(plain)
plain = strings.Map(func(r rune) rune {
if strings.ContainsRune("*_~>#", r) {
return -1
}
return r
}, plain)
plain = collapseSpaces(plain)
runes := []rune(plain)
if len(runes) <= previewLimit {
return plain
}
return strings.TrimRight(string(runes[:previewLimit-1]), " ") + "…"
}
// stripCodeBlocks убирает блоки ```…``` целиком, оставляя пометку.
func stripCodeBlocks(content string) string {
var builder strings.Builder
rest := content
for {
start := strings.Index(rest, "```")
if start < 0 {
builder.WriteString(rest)
return builder.String()
}
builder.WriteString(rest[:start])
builder.WriteString(" [код] ")
end := strings.Index(rest[start+3:], "```")
if end < 0 {
return builder.String()
}
rest = rest[start+3+end+3:]
}
}
// stripInlineCode убирает обратные кавычки, оставляя их содержимое.
func stripInlineCode(content string) string {
var builder strings.Builder
for {
start := strings.Index(content, "`")
if start < 0 {
builder.WriteString(content)
return builder.String()
}
builder.WriteString(content[:start])
rest := content[start+1:]
end := strings.Index(rest, "`")
if end < 0 {
builder.WriteString(rest)
return builder.String()
}
builder.WriteString(rest[:end])
content = rest[end+1:]
}
}
// stripLinks оставляет от ссылки её текст: [текст](url) → текст.
func stripLinks(content string) string {
var builder strings.Builder
rest := content
for {
open := strings.Index(rest, "](")
if open < 0 {
builder.WriteString(rest)
return builder.String()
}
start := strings.LastIndex(rest[:open], "[")
if start < 0 {
builder.WriteString(rest[:open+2])
rest = rest[open+2:]
continue
}
builder.WriteString(rest[:start])
builder.WriteString(rest[start+1 : open])
end := strings.Index(rest[open+2:], ")")
if end < 0 {
return builder.String()
}
rest = rest[open+2+end+1:]
}
}
// collapseSpaces схлопывает любые пробельные последовательности в один пробел.
func collapseSpaces(value string) string {
var builder strings.Builder
space := false
for _, r := range value {
if unicode.IsSpace(r) {
if !space && builder.Len() > 0 {
builder.WriteRune(' ')
}
space = true
continue
}
space = false
builder.WriteRune(r)
}
return strings.TrimSpace(builder.String())
}
// formatOptionalID печатает идентификатор в десятичном виде, пусто для нуля.
func formatOptionalID(id uint64) string {
if id == 0 {
return ""
}
return formatID(id)
}
+134
View File
@@ -0,0 +1,134 @@
package push
import (
"strings"
"testing"
"glchat/internal/store"
)
// input — сообщение по умолчанию: обычное сообщение в комнате сервера.
// Набор проверок повторяет web/tests/desktopNotifications.test.ts: правила
// push и нативных уведомлений обязаны совпадать.
func input(overrides func(*NotificationInput)) NotificationInput {
value := NotificationInput{
MessageType: store.MessageDefault,
Content: "привет",
Mentions: nil,
AuthorID: 2,
MeID: 1,
IsDirect: false,
ChannelName: "общий",
ChannelID: 10,
MessageID: 99,
GuildID: 1,
GuildName: "Сервер",
AuthorName: "Аня",
}
if overrides != nil {
overrides(&value)
}
return value
}
func TestNotificationForMention(t *testing.T) {
payload := NotificationFor(input(func(in *NotificationInput) { in.Mentions = []uint64{1} }))
if payload == nil {
t.Fatal("упоминание не дало уведомления")
}
if payload.Title != "Упоминание в #общий" || payload.Body != "привет" {
t.Fatalf("неожиданный текст: %+v", payload)
}
if payload.Kind != "mention" || payload.ChannelID != "10" || payload.Tag != "channel-10" {
t.Fatalf("неожиданные поля: %+v", payload)
}
if payload.URL != "/app/1/10" {
t.Fatalf("ссылка = %q, ожидалось /app/1/10", payload.URL)
}
}
func TestNotificationForDirect(t *testing.T) {
payload := NotificationFor(input(func(in *NotificationInput) {
in.IsDirect = true
in.GuildID = 0
in.GuildName = ""
in.ChannelName = "Аня"
}))
if payload == nil {
t.Fatal("личное сообщение не дало уведомления")
}
if payload.Title != "Личное сообщение — Аня" || payload.Kind != "direct" {
t.Fatalf("неожиданный текст: %+v", payload)
}
if payload.URL != "/app/friends/10" {
t.Fatalf("ссылка = %q, ожидалось /app/friends/10", payload.URL)
}
}
func TestNotificationForSilentCases(t *testing.T) {
cases := map[string]NotificationInput{
"обычное сообщение в комнате": input(nil),
"своё сообщение": input(func(in *NotificationInput) {
in.IsDirect = true
in.AuthorID = in.MeID
}),
"системное сообщение": input(func(in *NotificationInput) {
in.IsDirect = true
in.MessageType = store.MessageSystem
}),
"получатель в клиенте": input(func(in *NotificationInput) {
in.IsDirect = true
in.Mentions = []uint64{1}
in.Online = true
}),
"упоминание другого пользователя": input(func(in *NotificationInput) {
in.Mentions = []uint64{777}
}),
"пустое сообщение без упоминаний": input(func(in *NotificationInput) {
in.IsDirect = true
in.Content = ""
}),
}
for name, value := range cases {
if payload := NotificationFor(value); payload != nil {
t.Errorf("%s: уведомление не должно отправляться (%+v)", name, payload)
}
}
}
// Упоминание с пустым текстом (одно вложение) — как в клиенте: «Вложение».
func TestNotificationForAttachmentOnly(t *testing.T) {
payload := NotificationFor(input(func(in *NotificationInput) {
in.IsDirect = true
in.Content = ""
in.Mentions = []uint64{1}
}))
if payload == nil || payload.Body != "Вложение" {
t.Fatalf("ожидалось «Вложение», получили %+v", payload)
}
}
func TestPreviewText(t *testing.T) {
if got := previewText("**жирный**\n\n[ссылка](https://example.com) `код`"); got != "жирный ссылка код" {
t.Fatalf("previewText = %q", got)
}
if got := previewText("```go\nfunc main() {}\n``` после"); got != "[код] после" {
t.Fatalf("блок кода = %q", got)
}
long := previewText(strings.Repeat("а", 400))
if len([]rune(long)) > previewLimit {
t.Fatalf("длина превью %d превышает лимит %d", len([]rune(long)), previewLimit)
}
if !strings.HasSuffix(long, "…") {
t.Fatalf("длинный текст не обрезан: %q", long)
}
}
func TestFormatOptionalID(t *testing.T) {
if got := formatOptionalID(0); got != "" {
t.Fatalf("нулевой идентификатор = %q, ожидалась пустая строка", got)
}
if got := formatOptionalID(42); got != "42" {
t.Fatalf("идентификатор = %q", got)
}
}
+19
View File
@@ -40,8 +40,17 @@ type Stats struct {
Unknown int64
// Bytes — сколько освободили на диске.
Bytes int64
// PushSubscriptions — мёртвые подписки Web Push (Фаза 7).
PushSubscriptions int64
}
// Мёртвые подписки Web Push (Фаза 7): подписка, которая не доставлялась
// неделю и накопила неудачи, больше не нужна.
const (
stalePushAfterDays = 7
stalePushMaxFailures = 5
)
// Runner выполняет чистку по расписанию.
type Runner struct {
store *store.Store
@@ -118,6 +127,15 @@ 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
stats2 := stats
if len(errs) > 0 {
return stats2, errors.Join(errs...)
@@ -235,6 +253,7 @@ 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("bytes", stats.Bytes))
}
}
+3
View File
@@ -222,6 +222,9 @@ func (s *Server) registerMessageRoutes(api huma.API) {
} else {
s.dispatchChannelEvent(ctx, channelID, "MESSAGE_CREATE", payload)
}
// Web Push (Фаза 7): упоминания и личные беседы уходят на устройства
// тех, кто не в клиенте — тем же правилам, что у desktop-уведомлений.
s.notifyPushAboutMessage(ctx, message, channel, displayNameOf(user))
output := &messageOutput{}
output.Body.Message = payload
return output, nil
+255
View File
@@ -0,0 +1,255 @@
package server
import (
"context"
"crypto/ecdsa"
"crypto/elliptic"
"encoding/base64"
"net/http"
"strconv"
"strings"
"github.com/danielgtaylor/huma/v2"
"glchat/internal/httpx"
"glchat/internal/store"
)
// Web Push (AGENT.md 7.16, Фаза 7): подписка устройства, отписка и параметры
// для клиента. VAPID-ключ приватный живёт в конфиге инстанса, наружу уходит
// только публичный — он и нужен браузеру как `applicationServerKey`.
// Лимиты подписок: устройств на пользователя и длины полей от клиента.
const (
maxPushSubscriptionsPerUser = 10
maxPushEndpointLength = 1024
maxPushKeyLength = 128
maxPushUserAgentLength = 200
)
type pushConfigOutput struct {
Body struct {
Enabled bool `json:"enabled"`
PublicKey string `json:"public_key,omitempty"`
}
}
type pushSubscriptionPayload struct {
ID string `json:"id"`
Endpoint string `json:"endpoint"`
UserAgent string `json:"user_agent,omitempty"`
CreatedAt string `json:"created_at"`
}
type pushSubscriptionListOutput struct {
Body struct {
Subscriptions []pushSubscriptionPayload `json:"subscriptions"`
// Limit — сколько устройств можно подписать (для интерфейса).
Limit int `json:"limit"`
}
}
type pushOKOutput struct {
Body struct {
OK bool `json:"ok"`
}
}
// registerPushRoutes описывает ручки Web Push.
func (s *Server) registerPushRoutes(api huma.API) {
security := []map[string][]string{{"sessionCookie": {}}, {"bearerAuth": {}}}
huma.Register(api, huma.Operation{
OperationID: "getPushConfig",
Method: http.MethodGet,
Path: "/push/config",
Summary: "Параметры Web Push для клиента",
Tags: []string{"Push"},
Security: security,
}, func(ctx context.Context, _ *struct{}) (*pushConfigOutput, error) {
if _, _, err := requireUser(ctx); err != nil {
return nil, err
}
output := &pushConfigOutput{}
output.Body.Enabled = s.push.Enabled()
output.Body.PublicKey = s.push.PublicKey()
return output, nil
})
huma.Register(api, huma.Operation{
OperationID: "listPushSubscriptions",
Method: http.MethodGet,
Path: "/push/subscriptions",
Summary: "Подписки устройств текущего пользователя",
Tags: []string{"Push"},
Security: security,
}, func(ctx context.Context, _ *struct{}) (*pushSubscriptionListOutput, error) {
user, _, err := requireUser(ctx)
if err != nil {
return nil, err
}
subscriptions, err := s.store.ListPushSubscriptions(ctx, user.ID)
if err != nil {
return nil, humaError(err)
}
output := &pushSubscriptionListOutput{}
output.Body.Limit = maxPushSubscriptionsPerUser
output.Body.Subscriptions = make([]pushSubscriptionPayload, 0, len(subscriptions))
for i := range subscriptions {
output.Body.Subscriptions = append(output.Body.Subscriptions, pushSubscriptionPayload{
ID: formatSnowflake(subscriptions[i].ID),
Endpoint: subscriptions[i].Endpoint,
UserAgent: subscriptions[i].UserAgent,
CreatedAt: s.store.Timestamp(subscriptions[i].CreatedAt),
})
}
return output, nil
})
huma.Register(api, huma.Operation{
OperationID: "subscribePush",
Method: http.MethodPost,
Path: "/push/subscriptions",
Summary: "Подписать устройство на Web Push",
Tags: []string{"Push"},
Security: security,
}, func(ctx context.Context, input *struct {
UserAgent string `header:"User-Agent"`
Body struct {
Endpoint string `json:"endpoint" maxLength:"1024" minLength:"8"`
Keys struct {
P256dh string `json:"p256dh" maxLength:"128"`
Auth string `json:"auth" maxLength:"128"`
} `json:"keys"`
}
},
) (*pushOKOutput, error) {
user, _, err := requireUser(ctx)
if err != nil {
return nil, err
}
if !s.push.Enabled() {
return nil, humaErrorStatus(http.StatusServiceUnavailable, "push.disabled",
"web push is not configured on this instance")
}
if allowed, retryAfter := s.pushLimiter.Allow(pushLimitKey(user.ID)); !allowed {
return nil, rateLimitedError(retryAfter)
}
endpoint, err := validatePushEndpoint(input.Body.Endpoint)
if err != nil {
return nil, err
}
if err := validatePushKey("p256dh", input.Body.Keys.P256dh, 65); err != nil {
return nil, err
}
if err := validatePushKey("auth", input.Body.Keys.Auth, 16); err != nil {
return nil, err
}
count, err := s.store.CountPushSubscriptions(ctx, user.ID)
if err != nil {
return nil, humaError(err)
}
// Повторная подписка с того же устройства обновляет запись, поэтому
// лимит проверяем только для новой.
if count >= maxPushSubscriptionsPerUser {
existing, err := s.store.GetPushSubscriptionByEndpoint(ctx, endpoint)
if err != nil || existing.UserID != user.ID {
return nil, humaErrorStatus(http.StatusConflict, "push.too_many_subscriptions",
"too many subscribed devices")
}
}
if _, err := s.store.SavePushSubscription(ctx, store.SavePushSubscriptionParams{
UserID: user.ID,
Endpoint: endpoint,
P256dh: input.Body.Keys.P256dh,
Auth: input.Body.Keys.Auth,
UserAgent: truncate(input.UserAgent, maxPushUserAgentLength),
}); err != nil {
return nil, humaError(err)
}
output := &pushOKOutput{}
output.Body.OK = true
return output, nil
})
huma.Register(api, huma.Operation{
OperationID: "unsubscribePush",
Method: http.MethodDelete,
Path: "/push/subscriptions",
Summary: "Отписать устройство (или все) от Web Push",
Tags: []string{"Push"},
Security: security,
}, func(ctx context.Context, input *struct {
Endpoint string `query:"endpoint,omitempty" maxLength:"1024"`
},
) (*pushOKOutput, error) {
user, _, err := requireUser(ctx)
if err != nil {
return nil, err
}
endpoint := strings.TrimSpace(input.Endpoint)
if endpoint != "" {
if _, err := validatePushEndpoint(endpoint); err != nil {
return nil, err
}
}
if _, err := s.store.DeletePushSubscription(ctx, user.ID, endpoint); err != nil {
return nil, humaError(err)
}
output := &pushOKOutput{}
output.Body.OK = true
return output, nil
})
}
// validatePushEndpoint проверяет эндпоинт подписки: http не допускаем, а
// литеральный внутренний адрес отсекаем сразу — иначе сервер сам себе
// организует SSRF, отправляя подписанный VAPID-запрос во внутреннюю сеть.
func validatePushEndpoint(raw string) (string, error) {
trimmed := strings.TrimSpace(raw)
if trimmed == "" || len(trimmed) > maxPushEndpointLength {
return "", humaErrorStatus(http.StatusUnprocessableEntity, "push.invalid_endpoint",
"push endpoint is empty or too long")
}
if _, err := httpx.ValidatePublicURL(trimmed, false); err != nil {
return "", humaErrorStatus(http.StatusUnprocessableEntity, "push.invalid_endpoint",
"push endpoint must be a public https url")
}
return trimmed, nil
}
// validatePushKey проверяет ключ подписки: base64url, длину и — для p256dh —
// что это действительно точка P-256. Без второй проверки мусор от клиента
// доходил бы до шифрования и падал уже в очереди доставки.
func validatePushKey(name, value string, wantBytes int) error {
trimmed := strings.TrimSpace(value)
if trimmed == "" || len(trimmed) > maxPushKeyLength {
return humaErrorStatus(http.StatusUnprocessableEntity, "push.invalid_keys",
name+" key is missing or too long")
}
decoded, err := base64.RawURLEncoding.DecodeString(strings.TrimRight(trimmed, "="))
if err != nil || len(decoded) != wantBytes {
return humaErrorStatus(http.StatusUnprocessableEntity, "push.invalid_keys",
name+" key must be base64url of "+strconv.Itoa(wantBytes)+" bytes")
}
if name == "p256dh" {
if _, err := ecdsa.ParseUncompressedPublicKey(elliptic.P256(), decoded); err != nil {
return humaErrorStatus(http.StatusUnprocessableEntity, "push.invalid_keys",
"p256dh key is not a P-256 point")
}
}
return nil
}
// pushLimitKey — ключ лимита подписок: 10 запросов в минуту на пользователя.
func pushLimitKey(userID uint64) string { return "user:" + formatSnowflake(userID) }
// truncate обрезает пользовательский текст до лимита (User-Agent устройства).
func truncate(value string, limit int) string {
trimmed := strings.TrimSpace(value)
runes := []rune(trimmed)
if len(runes) <= limit {
return trimmed
}
return string(runes[:limit])
}
+354
View File
@@ -0,0 +1,354 @@
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",
}
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")
}
}
+128
View File
@@ -0,0 +1,128 @@
package server
import (
"context"
"log/slog"
"glchat/internal/push"
"glchat/internal/store"
)
// Web Push о новых сообщениях (AGENT.md 7.16, Фаза 7).
//
// Правила те же, что у нативных уведомлений desktop-обёртки
// (web/src/lib/desktopNotifications.ts): упоминания и личные беседы, без своих
// и системных сообщений. Отличие одно: сервер не знает про фокус окна, поэтому
// вместо «комната открыта и окно активно» проверяется наличие соединения
// шлюза — если пользователь в приложении, уведомление покажет клиент.
// pushSender — то, что серверу нужно от Web Push. Интерфейс позволяет
// подменить отправителя в тестах: доставка идёт в фоне и наружу не видна.
type pushSender interface {
Enabled() bool
PublicKey() string
Enqueue(subscription store.PushSubscription, payload push.Payload) bool
}
// displayNameOf — имя автора для уведомления: display_name, а при пустом —
// логин (у новых аккаунтов display_name заполняется логином, но вебхуки и
// ручные данные могут его не иметь).
func displayNameOf(user *store.User) string {
if user == nil {
return ""
}
if user.DisplayName != "" {
return user.DisplayName
}
return user.Username
}
// notifyPushAboutMessage ставит уведомления в очередь push-сервиса.
//
// authorName — отображаемое имя автора: в личной беседе 1:1 оно и есть имя
// «комнаты» (отдельного названия у неё нет).
func (s *Server) notifyPushAboutMessage(ctx context.Context, message *store.Message, channel *store.Channel, authorName string) {
if !s.push.Enabled() || channel == nil {
return
}
recipients := s.pushRecipients(ctx, message, channel)
if len(recipients) == 0 {
return
}
authorID := uint64(0)
if message.AuthorID != nil {
authorID = *message.AuthorID
}
guildID := uint64(0)
guildName := ""
if channel.GuildID != nil {
guildID = *channel.GuildID
if guild, err := s.store.GetGuild(ctx, guildID); err == nil {
guildName = guild.Name
}
}
channelName := s.pushChannelName(channel, authorName)
for _, recipient := range recipients {
if recipient == 0 || recipient == authorID {
continue
}
// Получатель в приложении: уведомление покажет клиент, а не телефон.
if s.gateway.IsUserOnline(recipient) {
continue
}
// Лимит на получателя: массовые упоминания не должны превращаться в
// поток push-сообщений (AGENT.md 8.6).
if allowed, _ := s.pushNotifyLimiter.Allow(pushLimitKey(recipient)); !allowed {
continue
}
payload := push.NotificationFor(push.NotificationInput{
MessageType: message.Type,
Content: message.Content,
Mentions: message.Mentions,
AuthorID: authorID,
MeID: recipient,
IsDirect: channel.GuildID == nil,
ChannelName: channelName,
ChannelID: message.ChannelID,
MessageID: message.ID,
GuildID: guildID,
GuildName: guildName,
AuthorName: authorName,
})
if payload == nil {
continue
}
subscriptions, err := s.store.ListPushSubscriptions(ctx, recipient)
if err != nil {
s.logger.WarnContext(ctx, "push subscriptions read failed", slog.Any("error", err))
continue
}
for i := range subscriptions {
s.push.Enqueue(subscriptions[i], *payload)
}
}
}
// pushRecipients перечисляет, кому адресовано уведомление: в комнате сервера —
// упомянутым, в личной беседе — всем остальным участникам.
func (s *Server) pushRecipients(ctx context.Context, message *store.Message, channel *store.Channel) []uint64 {
if channel.GuildID != nil {
return message.Mentions
}
participants, err := s.store.DMParticipants(ctx, channel.ID)
if err != nil {
s.logger.WarnContext(ctx, "dm participants read failed", slog.Any("error", err))
return nil
}
return participants
}
// pushChannelName подбирает название для заголовка уведомления: у групповой
// беседы — её имя, у 1:1 своего имени нет, поэтому берём имя собеседника
// (автора сообщения).
func (s *Server) pushChannelName(channel *store.Channel, authorName string) string {
if channel.GuildID == nil && channel.DMOwnerID == nil {
return authorName
}
return channel.Name
}
+221
View File
@@ -0,0 +1,221 @@
package server
import (
"context"
"net/http"
"net/http/httptest"
"strings"
"testing"
"glchat/internal/push"
"glchat/internal/store"
)
// recordingPush — подмена отправителя: запоминает, что сервер поставил в
// очередь. Реальная доставка идёт в фоне и в тесте не наблюдаема.
type recordingPush struct {
enabled bool
publicKey string
jobs []recordedPush
}
type recordedPush struct {
subscription store.PushSubscription
payload push.Payload
}
func (r *recordingPush) Enabled() bool { return r.enabled }
func (r *recordingPush) PublicKey() string { return r.publicKey }
func (r *recordingPush) Enqueue(subscription store.PushSubscription, payload push.Payload) bool {
r.jobs = append(r.jobs, recordedPush{subscription: subscription, payload: payload})
return true
}
// createTestDM открывает личную беседу между автором и указанным участником
// и возвращает идентификатор комнаты.
func createTestDM(t *testing.T, srv *Server, cookie *http.Cookie, userID string) string {
t.Helper()
rec := doJSON(t, srv, http.MethodPost, "/api/v1/users/@me/channels",
`{"recipient_id":"`+userID+`"}`, cookie)
if rec.Code != http.StatusOK {
t.Fatalf("открыть личную беседу = %d, body = %s", rec.Code, rec.Body.String())
}
payload := decodeResponse[struct {
Channel struct {
ID string `json:"id"`
} `json:"channel"`
}](t, rec)
if payload.Channel.ID == "" {
t.Fatalf("беседа без идентификатора: %s", rec.Body.String())
}
return payload.Channel.ID
}
// savePushSubscription заводит подписку устройства напрямую в БД.
func savePushSubscription(t *testing.T, st *store.Store, userID uint64, endpoint string) store.PushSubscription {
t.Helper()
subscription, err := st.SavePushSubscription(context.Background(), store.SavePushSubscriptionParams{
UserID: userID,
Endpoint: endpoint,
P256dh: "p256dh",
Auth: "auth",
})
if err != nil {
t.Fatalf("save subscription: %v", err)
}
return *subscription
}
// TestPushNotifiesDirectMessage: личное сообщение уходит на устройство
// получателя, если он не в клиенте (AGENT.md 7.16, Фаза 7).
func TestPushNotifiesDirectMessage(t *testing.T) {
srv, st := newPushTestServer(t, true)
recorder := &recordingPush{enabled: true, publicKey: "key"}
srv.push = recorder
authorCookie := registerAndLogin(t, srv, "push_author", "push-author@example.com")
registerAndLogin(t, srv, "push_target", "push-target@example.com")
targetID := mustUserID(t, srv, "push-target@example.com")
savePushSubscription(t, st, targetID, "https://fcm.googleapis.com/fcm/send/target")
channelID := createTestDM(t, srv, authorCookie, formatSnowflake(targetID))
rec := doJSON(t, srv, http.MethodPost, "/api/v1/channels/"+channelID+"/messages",
`{"content":"привет, это личное"}`, authorCookie)
if rec.Code != http.StatusOK {
t.Fatalf("отправка сообщения = %d, body = %s", rec.Code, rec.Body.String())
}
if len(recorder.jobs) != 1 {
t.Fatalf("поставлено уведомлений %d, ожидалось 1", len(recorder.jobs))
}
job := recorder.jobs[0]
if job.payload.Kind != "direct" || job.payload.Body != "привет, это личное" {
t.Fatalf("неожиданное уведомление: %+v", job.payload)
}
if job.payload.ChannelID != channelID {
t.Fatalf("комната уведомления %q, ожидалась %q", job.payload.ChannelID, channelID)
}
if job.payload.URL != "/app/friends/"+channelID {
t.Fatalf("ссылка = %q", job.payload.URL)
}
if job.subscription.Endpoint != "https://fcm.googleapis.com/fcm/send/target" {
t.Fatalf("подписка = %q", job.subscription.Endpoint)
}
}
// TestPushNotifiesMention: упоминание в комнате сервера уходит получателю;
// обычное сообщение — нет.
func TestPushNotifiesMention(t *testing.T) {
fixture := newMessagingFixture(t)
recorder := &recordingPush{enabled: true, publicKey: "key"}
fixture.srv.push = recorder
savePushSubscription(t, fixture.srv.store, guildIDOf(t, fixture.memberID), "https://fcm.googleapis.com/fcm/send/member")
// Обычное сообщение: упоминаний нет — push не нужен.
rec := doJSON(t, fixture.srv, http.MethodPost, "/api/v1/channels/"+fixture.openChannel+"/messages",
`{"content":"просто сообщение"}`, fixture.ownerCookie)
if rec.Code != http.StatusOK {
t.Fatalf("отправка сообщения = %d, body = %s", rec.Code, rec.Body.String())
}
if len(recorder.jobs) != 0 {
t.Fatalf("без упоминания поставлено уведомлений %d", len(recorder.jobs))
}
// Упоминание: уведомление уходит со ссылкой на комнату сервера.
rec = doJSON(t, fixture.srv, http.MethodPost, "/api/v1/channels/"+fixture.openChannel+"/messages",
`{"content":"<@`+fixture.memberID+`> посмотри"}`, fixture.ownerCookie)
if rec.Code != http.StatusOK {
t.Fatalf("отправка упоминания = %d, body = %s", rec.Code, rec.Body.String())
}
if len(recorder.jobs) != 1 {
t.Fatalf("поставлено уведомлений %d, ожидалось 1", len(recorder.jobs))
}
payload := recorder.jobs[0].payload
if payload.Kind != "mention" {
t.Fatalf("вид уведомления = %q", payload.Kind)
}
if !strings.Contains(payload.Body, "посмотри") {
t.Fatalf("тело уведомления = %q", payload.Body)
}
if payload.ChannelID != fixture.openChannel {
t.Fatalf("комната уведомления %q, ожидалась %q", payload.ChannelID, fixture.openChannel)
}
if payload.URL != "/app/"+fixture.guildID+"/"+fixture.openChannel {
t.Fatalf("ссылка = %q", payload.URL)
}
if payload.Title == "" || !strings.Contains(payload.Title, "#") {
t.Fatalf("заголовок уведомления = %q", payload.Title)
}
}
// TestPushSkipsOwnMessages: свои сообщения уведомлений не порождают — как в
// desktopNotifications.ts.
func TestPushSkipsOwnMessages(t *testing.T) {
srv, st := newPushTestServer(t, true)
recorder := &recordingPush{enabled: true, publicKey: "key"}
srv.push = recorder
cookie := registerAndLogin(t, srv, "push_self", "push-self@example.com")
selfID := mustUserID(t, srv, "push-self@example.com")
savePushSubscription(t, st, selfID, "https://fcm.googleapis.com/fcm/send/self")
otherCookie := registerAndLogin(t, srv, "push_other", "push-other@example.com")
otherID := mustUserID(t, srv, "push-other@example.com")
channelID := createTestDM(t, srv, cookie, formatSnowflake(otherID))
// Отправляем другому, но получатель — тоже автор? Нет: подписка только у
// автора, а сообщение уходит второму участнику — уведомлений быть не должно.
rec := doJSON(t, srv, http.MethodPost, "/api/v1/channels/"+channelID+"/messages",
`{"content":"<@`+formatSnowflake(selfID)+`> себе"}`, cookie)
if rec.Code != http.StatusOK {
t.Fatalf("отправка = %d, body = %s", rec.Code, rec.Body.String())
}
if len(recorder.jobs) != 0 {
t.Fatalf("своё сообщение дало уведомления: %+v", recorder.jobs)
}
_ = otherCookie
}
// TestPushSkipsOnlineUsers: если получатель в клиенте (есть соединение шлюза),
// уведомление покажет сам клиент — push не отправляем.
func TestPushSkipsOnlineUsers(t *testing.T) {
srv, st := newPushTestServer(t, true)
recorder := &recordingPush{enabled: true, publicKey: "key"}
srv.push = recorder
authorCookie := registerAndLogin(t, srv, "push_online_author", "push-online-author@example.com")
targetCookie := registerAndLogin(t, srv, "push_online_target", "push-online-target@example.com")
targetID := mustUserID(t, srv, "push-online-target@example.com")
savePushSubscription(t, st, targetID, "https://fcm.googleapis.com/fcm/send/online")
channelID := createTestDM(t, srv, authorCookie, formatSnowflake(targetID))
// Настоящее соединение шлюза за получателя: он «в клиенте».
httpServer := httptest.NewServer(srv.Handler())
t.Cleanup(httpServer.Close)
dialGateway(t, httpServer, targetCookie)
rec := doJSON(t, srv, http.MethodPost, "/api/v1/channels/"+channelID+"/messages",
`{"content":"он в клиенте"}`, authorCookie)
if rec.Code != http.StatusOK {
t.Fatalf("отправка = %d, body = %s", rec.Code, rec.Body.String())
}
if len(recorder.jobs) != 0 {
t.Fatalf("пользователю в клиенте отправлен push: %+v", recorder.jobs)
}
}
// TestPushSkippedWhenDisabled: без VAPID-ключа сервер не трогает подписки.
func TestPushSkippedWhenDisabled(t *testing.T) {
fixture := newMessagingFixture(t)
recorder := &recordingPush{enabled: false}
fixture.srv.push = recorder
savePushSubscription(t, fixture.srv.store, guildIDOf(t, fixture.memberID), "https://fcm.googleapis.com/fcm/send/off")
rec := doJSON(t, fixture.srv, http.MethodPost, "/api/v1/channels/"+fixture.openChannel+"/messages",
`{"content":"<@`+fixture.memberID+`> привет"}`, fixture.ownerCookie)
if rec.Code != http.StatusOK {
t.Fatalf("отправка = %d, body = %s", rec.Code, rec.Body.String())
}
if len(recorder.jobs) != 0 {
t.Fatalf("выключенный push всё равно отправил: %+v", recorder.jobs)
}
}
+25 -3
View File
@@ -20,6 +20,7 @@ import (
"glchat/internal/httpx"
"glchat/internal/meta"
"glchat/internal/permissions"
"glchat/internal/push"
"glchat/internal/source"
"glchat/internal/store"
"glchat/internal/sysinfo"
@@ -71,6 +72,12 @@ type Server struct {
// 10 запросов в минуту на IP — с запасом на пару begin/finish.
passkeyLimiter *httpx.RateLimiter
oauthLimiter *httpx.RateLimiter
// pushLimiter ограничивает подписки на Web Push, pushNotifyLimiter — сами
// уведомления на получателя (Фаза 7, AGENT.md 7.16, 8.6).
pushLimiter *httpx.RateLimiter
pushNotifyLimiter *httpx.RateLimiter
// push отправляет Web Push; nil, если ключи не настроены.
push pushSender
// slowmode — время последней отправки в комнату для режима медленной
// отправки; словарь ограничен по размеру (AGENT.md 7.5).
slowmodeMu sync.Mutex
@@ -130,9 +137,13 @@ func New(cfg config.Config, db *database.DB, logger *slog.Logger, deps Deps) *Se
reactionLimiter: httpx.NewRateLimiter(20, 20),
passkeyLimiter: httpx.NewRateLimiterWindow(10, time.Minute, 10),
oauthLimiter: httpx.NewRateLimiterWindow(10, time.Minute, 10),
slowmode: map[string]time.Time{},
presence: map[uint64]time.Time{},
webhookSeen: map[string]time.Time{},
pushLimiter: httpx.NewRateLimiterWindow(10, time.Minute, 10),
// Уведомления на получателя: 20 в минуту с запасом 20 — массовые
// упоминания не должны заваливать устройство.
pushNotifyLimiter: httpx.NewRateLimiter(20, 20),
slowmode: map[string]time.Time{},
presence: map[uint64]time.Time{},
webhookSeen: map[string]time.Time{},
// Дашборд опрашивает метрики раз в секунду: 120/мин с запасом.
metricsLimiter: httpx.NewRateLimiter(120, 30),
sysinfoPaths: sysinfo.DefaultPaths(),
@@ -147,6 +158,16 @@ func New(cfg config.Config, db *database.DB, logger *slog.Logger, deps Deps) *Se
case deps.Store != nil:
s.perms = permissions.NewCalculator(source.New(deps.Store))
}
// Web Push (Фаза 7): неверный VAPID-ключ не должен мешать инстансу
// подняться — пишем предупреждение и работаем без push.
if deps.Store != nil {
sender, err := push.New(cfg, deps.Store, logger)
if err != nil {
logger.Error("web push is disabled", slog.Any("error", err))
} else {
s.push = sender
}
}
router := chi.NewRouter()
router.Route("/api/v1", func(apiRouter chi.Router) {
@@ -182,6 +203,7 @@ func New(cfg config.Config, db *database.DB, logger *slog.Logger, deps Deps) *Se
s.registerChannelBackgroundRoutes(apiRouter)
s.registerInviteBackgroundRoutes(apiRouter)
s.registerVoiceWebhook(apiRouter)
s.registerPushRoutes(s.api)
}
apiRouter.Get("/openapi.json", s.handleOpenAPI)
})
+201
View File
@@ -0,0 +1,201 @@
package store
import (
"context"
"database/sql"
"time"
)
// Web Push: подписки браузеров на уведомления (AGENT.md 7.16, Фаза 7).
//
// Эндпоинт выдаёт push-сервис (FCM, Mozilla, Apple), ключи p256dh/auth нужны
// для шифрования payload по RFC 8291 и приходят из PushSubscription.toJSON().
// Всё, что приходит от клиента, ограничено по длине, а ключи дополнительно
// проверяются в HTTP-слое: в базе не должно оказаться произвольного текста.
// PushSubscription — подписка одного устройства.
type PushSubscription struct {
ID uint64
UserID uint64
Endpoint string
P256dh string
Auth string
UserAgent string
CreatedAt time.Time
LastSuccessAt *time.Time
LastFailureAt *time.Time
FailureCount int
}
// SavePushSubscriptionParams — параметры подписки от клиента.
type SavePushSubscriptionParams struct {
UserID uint64
Endpoint string
P256dh string
Auth string
UserAgent string
}
const pushSubscriptionColumns = `id, user_id, endpoint, p256dh, auth, user_agent, created_at,
last_success_at, last_failure_at, failure_count`
// SavePushSubscription сохраняет подписку устройства: повторная подписка с тем
// же эндпоинтом обновляет ключи и сбрасывает счётчик неудач. Эндпоинт
// уникален глобально, поэтому подписка «переезжает» к последнему владельцу —
// так бывает, когда на одном устройстве сменился аккаунт.
func (s *Store) SavePushSubscription(ctx context.Context, params SavePushSubscriptionParams) (*PushSubscription, error) {
_, err := s.writer.ExecContext(ctx, `
INSERT INTO push_subscriptions (id, user_id, endpoint, p256dh, auth, user_agent, created_at, failure_count)
VALUES (?, ?, ?, ?, ?, ?, ?, 0)
ON CONFLICT (endpoint) DO UPDATE SET
user_id = excluded.user_id,
p256dh = excluded.p256dh,
auth = excluded.auth,
user_agent = excluded.user_agent,
failure_count = 0,
last_failure_at = NULL`,
int64(s.NextID()), int64(params.UserID), params.Endpoint, params.P256dh, params.Auth,
params.UserAgent, s.Now(),
)
if err != nil {
return nil, mapError(err)
}
return s.GetPushSubscriptionByEndpoint(ctx, params.Endpoint)
}
// GetPushSubscriptionByEndpoint возвращает подписку по эндпоинту.
func (s *Store) GetPushSubscriptionByEndpoint(ctx context.Context, endpoint string) (*PushSubscription, error) {
row := s.reader.QueryRowContext(ctx,
`SELECT `+pushSubscriptionColumns+` FROM push_subscriptions WHERE endpoint = ?`, endpoint)
return scanPushSubscription(row)
}
// ListPushSubscriptions перечисляет подписки пользователя (свежие — первыми).
func (s *Store) ListPushSubscriptions(ctx context.Context, userID uint64) ([]PushSubscription, error) {
rows, err := s.reader.QueryContext(ctx,
`SELECT `+pushSubscriptionColumns+` FROM push_subscriptions
WHERE user_id = ? ORDER BY created_at DESC`, int64(userID))
if err != nil {
return nil, err
}
defer rows.Close()
subscriptions := make([]PushSubscription, 0, 4)
for rows.Next() {
subscription, err := scanPushSubscription(rows)
if err != nil {
return nil, err
}
subscriptions = append(subscriptions, *subscription)
}
return subscriptions, rows.Err()
}
// CountPushSubscriptions считает подписки пользователя: лимит устройств.
func (s *Store) CountPushSubscriptions(ctx context.Context, userID uint64) (int, error) {
var count int
if err := s.reader.QueryRowContext(ctx,
`SELECT COUNT(*) FROM push_subscriptions WHERE user_id = ?`, int64(userID)).Scan(&count); err != nil {
return 0, mapError(err)
}
return count, nil
}
// DeletePushSubscription удаляет подписку по эндпоинту. Если эндпоинт пуст —
// удаляются все подписки пользователя (отписка «со всех устройств»).
func (s *Store) DeletePushSubscription(ctx context.Context, userID uint64, endpoint string) (int64, error) {
query := `DELETE FROM push_subscriptions WHERE user_id = ?`
args := []any{int64(userID)}
if endpoint != "" {
query += ` AND endpoint = ?`
args = append(args, endpoint)
}
result, err := s.writer.ExecContext(ctx, query, args...)
if err != nil {
return 0, mapError(err)
}
return result.RowsAffected()
}
// DeletePushSubscriptionByID удаляет подписку по идентификатору: так убираются
// записи, признанные мёртвыми (push-сервис ответил 404/410).
func (s *Store) DeletePushSubscriptionByID(ctx context.Context, id uint64) error {
_, err := s.writer.ExecContext(ctx, `DELETE FROM push_subscriptions WHERE id = ?`, int64(id))
return mapError(err)
}
// TouchPushSubscription отмечает успешную доставку.
func (s *Store) TouchPushSubscription(ctx context.Context, id uint64) error {
_, err := s.writer.ExecContext(ctx,
`UPDATE push_subscriptions SET last_success_at = ?, failure_count = 0 WHERE id = ?`,
s.Now(), int64(id))
return mapError(err)
}
// FailPushSubscription отмечает неудачную доставку и возвращает новое число
// подряд идущих неудач: по нему HTTP-слой решает, удалять ли подписку.
func (s *Store) FailPushSubscription(ctx context.Context, id uint64) (int, error) {
if _, err := s.writer.ExecContext(ctx,
`UPDATE push_subscriptions
SET failure_count = failure_count + 1, last_failure_at = ?
WHERE id = ?`, s.Now(), int64(id)); err != nil {
return 0, mapError(err)
}
var count int
if err := s.reader.QueryRowContext(ctx,
`SELECT failure_count FROM push_subscriptions WHERE id = ?`, int64(id)).Scan(&count); err != nil {
return 0, mapError(err)
}
return count, nil
}
// DeleteStalePushSubscriptions убирает подписки, которые давно не доставлялись:
// молчащие эндпоинты не должны копиться вечно (AGENT.md 6.4).
func (s *Store) DeleteStalePushSubscriptions(ctx context.Context, before time.Time, maxFailures int) (int64, error) {
result, err := s.writer.ExecContext(ctx, `
DELETE FROM push_subscriptions
WHERE failure_count >= ? AND COALESCE(last_failure_at, created_at) <= ?`,
maxFailures, s.Timestamp(before))
if err != nil {
return 0, mapError(err)
}
return result.RowsAffected()
}
// DeletePushSubscriptionsForUser убирает все подписки пользователя: вызывается
// при удалении аккаунта (каскад в БД страхует, но так понятнее по коду).
func (s *Store) DeletePushSubscriptionsForUser(ctx context.Context, userID uint64) (int64, error) {
result, err := s.writer.ExecContext(ctx,
`DELETE FROM push_subscriptions WHERE user_id = ?`, int64(userID))
if err != nil {
return 0, mapError(err)
}
return result.RowsAffected()
}
func scanPushSubscription(scanner interface{ Scan(...any) error }) (*PushSubscription, error) {
var (
subscription PushSubscription
createdAt string
lastSuccessAt sql.NullString
lastFailureAt sql.NullString
)
err := scanner.Scan(
&subscription.ID, &subscription.UserID, &subscription.Endpoint, &subscription.P256dh,
&subscription.Auth, &subscription.UserAgent, &createdAt,
&lastSuccessAt, &lastFailureAt, &subscription.FailureCount,
)
if err != nil {
return nil, mapError(err)
}
subscription.CreatedAt = parseTimestamp(createdAt)
if lastSuccessAt.Valid {
value := parseTimestamp(lastSuccessAt.String)
subscription.LastSuccessAt = &value
}
if lastFailureAt.Valid {
value := parseTimestamp(lastFailureAt.String)
subscription.LastFailureAt = &value
}
return &subscription, nil
}
+225
View File
@@ -0,0 +1,225 @@
package store
import (
"context"
"errors"
"path/filepath"
"testing"
"time"
"glchat/internal/database"
)
// newPushTestStore открывает временную БД с миграциями: подписки Web Push
// ссылаются на пользователя внешним ключом, поэтому нужен реальный аккаунт.
func newPushTestStore(t *testing.T) (*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 := New(db)
user, err := st.CreateUser(ctx, CreateUserParams{
Username: "push_store_user",
EmailEnc: "enc",
EmailIndex: "idx",
PasswordHash: "hash",
})
if err != nil {
t.Fatalf("создать пользователя: %v", err)
}
return st, user.ID
}
func TestPushSubscriptionLifecycle(t *testing.T) {
ctx := context.Background()
st, userID := newPushTestStore(t)
saved, err := st.SavePushSubscription(ctx, SavePushSubscriptionParams{
UserID: userID,
Endpoint: "https://fcm.googleapis.com/fcm/send/one",
P256dh: "p256dh-1",
Auth: "auth-1",
UserAgent: "Chrome/140",
})
if err != nil {
t.Fatalf("SavePushSubscription: %v", err)
}
if saved.ID == 0 || saved.FailureCount != 0 || saved.LastSuccessAt != nil {
t.Fatalf("неожиданная подписка: %+v", saved)
}
byEndpoint, err := st.GetPushSubscriptionByEndpoint(ctx, saved.Endpoint)
if err != nil {
t.Fatalf("GetPushSubscriptionByEndpoint: %v", err)
}
if byEndpoint.UserID != userID || byEndpoint.P256dh != "p256dh-1" || byEndpoint.UserAgent != "Chrome/140" {
t.Fatalf("подписка прочитана неверно: %+v", byEndpoint)
}
// Повторная подписка с того же устройства обновляет ключи и сбрасывает
// счётчик неудач: браузер мог перевыпустить ключи шифрования.
if _, err := st.FailPushSubscription(ctx, saved.ID); err != nil {
t.Fatalf("FailPushSubscription: %v", err)
}
updated, err := st.SavePushSubscription(ctx, SavePushSubscriptionParams{
UserID: userID,
Endpoint: saved.Endpoint,
P256dh: "p256dh-2",
Auth: "auth-2",
UserAgent: "Chrome/141",
})
if err != nil {
t.Fatalf("повторный SavePushSubscription: %v", err)
}
if updated.ID != saved.ID {
t.Fatalf("upsert создал новую запись: %d вместо %d", updated.ID, saved.ID)
}
if updated.P256dh != "p256dh-2" || updated.FailureCount != 0 {
t.Fatalf("upsert не обновил запись: %+v", updated)
}
count, err := st.CountPushSubscriptions(ctx, userID)
if err != nil || count != 1 {
t.Fatalf("CountPushSubscriptions = %d (%v), ожидалось 1", count, err)
}
// Успешная доставка отмечается и обнуляет счётчик неудач.
if _, err := st.FailPushSubscription(ctx, saved.ID); err != nil {
t.Fatalf("FailPushSubscription: %v", err)
}
if err := st.TouchPushSubscription(ctx, saved.ID); err != nil {
t.Fatalf("TouchPushSubscription: %v", err)
}
touched, err := st.GetPushSubscriptionByEndpoint(ctx, saved.Endpoint)
if err != nil {
t.Fatalf("GetPushSubscriptionByEndpoint: %v", err)
}
if touched.LastSuccessAt == nil || touched.FailureCount != 0 {
t.Fatalf("успех не отмечен: %+v", touched)
}
if affected, err := st.DeletePushSubscription(ctx, userID, saved.Endpoint); err != nil || affected != 1 {
t.Fatalf("DeletePushSubscription = %d (%v), ожидалось 1", affected, err)
}
if _, err := st.GetPushSubscriptionByEndpoint(ctx, saved.Endpoint); !errors.Is(err, ErrNotFound) {
t.Fatalf("после удаления ошибка = %v, ожидалась ErrNotFound", err)
}
}
func TestPushSubscriptionLimitAndBulkDelete(t *testing.T) {
ctx := context.Background()
st, userID := newPushTestStore(t)
for i := 0; i < 3; i++ {
if _, err := st.SavePushSubscription(ctx, SavePushSubscriptionParams{
UserID: userID,
Endpoint: "https://push.example.com/" + string(rune('a'+i)),
P256dh: "key",
Auth: "auth",
}); err != nil {
t.Fatalf("SavePushSubscription %d: %v", i, err)
}
}
subscriptions, err := st.ListPushSubscriptions(ctx, userID)
if err != nil {
t.Fatalf("ListPushSubscriptions: %v", err)
}
if len(subscriptions) != 3 {
t.Fatalf("подписок %d, ожидалось 3", len(subscriptions))
}
// Пустой эндпоинт — отписка со всех устройств.
if affected, err := st.DeletePushSubscription(ctx, userID, ""); err != nil || affected != 3 {
t.Fatalf("массовая отписка = %d (%v), ожидалось 3", affected, err)
}
if count, err := st.CountPushSubscriptions(ctx, userID); err != nil || count != 0 {
t.Fatalf("после отписки подписок %d (%v)", count, err)
}
}
func TestPushSubscriptionFailuresAndStaleCleanup(t *testing.T) {
ctx := context.Background()
st, userID := newPushTestStore(t)
subscription, err := st.SavePushSubscription(ctx, SavePushSubscriptionParams{
UserID: userID,
Endpoint: "https://push.example.com/stale",
P256dh: "key",
Auth: "auth",
})
if err != nil {
t.Fatalf("SavePushSubscription: %v", err)
}
for i := 1; i <= 3; i++ {
count, err := st.FailPushSubscription(ctx, subscription.ID)
if err != nil {
t.Fatalf("FailPushSubscription: %v", err)
}
if count != i {
t.Fatalf("счётчик неудач = %d, ожидался %d", count, i)
}
}
// Свежая неудача: подписка ещё не считается мёртвой.
if removed, err := st.DeleteStalePushSubscriptions(ctx, time.Now().UTC().Add(-time.Hour), 3); err != nil || removed != 0 {
t.Fatalf("удалено свежих подписок %d (%v), ожидалось 0", removed, err)
}
// Неудачная доставка была давно — подписка убирается.
if removed, err := st.DeleteStalePushSubscriptions(ctx, time.Now().UTC().Add(time.Hour), 3); err != nil || removed != 1 {
t.Fatalf("удалено мёртвых подписок %d (%v), ожидалось 1", removed, err)
}
}
func TestPushSubscriptionsRemovedWithUser(t *testing.T) {
ctx := context.Background()
st, userID := newPushTestStore(t)
if _, err := st.SavePushSubscription(ctx, SavePushSubscriptionParams{
UserID: userID,
Endpoint: "https://push.example.com/cascade",
P256dh: "key",
Auth: "auth",
}); err != nil {
t.Fatalf("SavePushSubscription: %v", err)
}
// Мягкое удаление пользователя сессии отзывает, но подписки остаются:
// их убирает явный вызов, иначе push уходил бы удалённому аккаунту.
if _, err := st.DeletePushSubscriptionsForUser(ctx, userID); err != nil {
t.Fatalf("DeletePushSubscriptionsForUser: %v", err)
}
if count, err := st.CountPushSubscriptions(ctx, userID); err != nil || count != 0 {
t.Fatalf("после удаления пользователя подписок %d (%v)", count, err)
}
}
func TestDeletePushSubscriptionByID(t *testing.T) {
ctx := context.Background()
st, userID := newPushTestStore(t)
subscription, err := st.SavePushSubscription(ctx, SavePushSubscriptionParams{
UserID: userID,
Endpoint: "https://push.example.com/by-id",
P256dh: "key",
Auth: "auth",
})
if err != nil {
t.Fatalf("SavePushSubscription: %v", err)
}
if err := st.DeletePushSubscriptionByID(ctx, subscription.ID); err != nil {
t.Fatalf("DeletePushSubscriptionByID: %v", err)
}
if _, err := st.GetPushSubscriptionByEndpoint(ctx, subscription.Endpoint); !errors.Is(err, ErrNotFound) {
t.Fatalf("после удаления ошибка = %v, ожидалась ErrNotFound", err)
}
}