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