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:
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user