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
+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)
}