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