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