7afd23d6d4
Уведомления в браузере и на телефоне: сервер сам решает, кому их слать, и подписывает запрос 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, очистка мёртвых подписок), ручки и лимиты, отправка при личном сообщении и упоминании, пропуск онлайн-получателей, миграция на чистой БД.
222 lines
9.7 KiB
Go
222 lines
9.7 KiB
Go
package server
|
|
|
|
import (
|
|
"context"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
|
|
"glchat/internal/push"
|
|
"glchat/internal/store"
|
|
)
|
|
|
|
// recordingPush — подмена отправителя: запоминает, что сервер поставил в
|
|
// очередь. Реальная доставка идёт в фоне и в тесте не наблюдаема.
|
|
type recordingPush struct {
|
|
enabled bool
|
|
publicKey string
|
|
jobs []recordedPush
|
|
}
|
|
|
|
type recordedPush struct {
|
|
subscription store.PushSubscription
|
|
payload push.Payload
|
|
}
|
|
|
|
func (r *recordingPush) Enabled() bool { return r.enabled }
|
|
func (r *recordingPush) PublicKey() string { return r.publicKey }
|
|
func (r *recordingPush) Enqueue(subscription store.PushSubscription, payload push.Payload) bool {
|
|
r.jobs = append(r.jobs, recordedPush{subscription: subscription, payload: payload})
|
|
return true
|
|
}
|
|
|
|
// createTestDM открывает личную беседу между автором и указанным участником
|
|
// и возвращает идентификатор комнаты.
|
|
func createTestDM(t *testing.T, srv *Server, cookie *http.Cookie, userID string) string {
|
|
t.Helper()
|
|
rec := doJSON(t, srv, http.MethodPost, "/api/v1/users/@me/channels",
|
|
`{"recipient_id":"`+userID+`"}`, cookie)
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("открыть личную беседу = %d, body = %s", rec.Code, rec.Body.String())
|
|
}
|
|
payload := decodeResponse[struct {
|
|
Channel struct {
|
|
ID string `json:"id"`
|
|
} `json:"channel"`
|
|
}](t, rec)
|
|
if payload.Channel.ID == "" {
|
|
t.Fatalf("беседа без идентификатора: %s", rec.Body.String())
|
|
}
|
|
return payload.Channel.ID
|
|
}
|
|
|
|
// savePushSubscription заводит подписку устройства напрямую в БД.
|
|
func savePushSubscription(t *testing.T, st *store.Store, userID uint64, endpoint string) store.PushSubscription {
|
|
t.Helper()
|
|
subscription, err := st.SavePushSubscription(context.Background(), store.SavePushSubscriptionParams{
|
|
UserID: userID,
|
|
Endpoint: endpoint,
|
|
P256dh: "p256dh",
|
|
Auth: "auth",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("save subscription: %v", err)
|
|
}
|
|
return *subscription
|
|
}
|
|
|
|
// TestPushNotifiesDirectMessage: личное сообщение уходит на устройство
|
|
// получателя, если он не в клиенте (AGENT.md 7.16, Фаза 7).
|
|
func TestPushNotifiesDirectMessage(t *testing.T) {
|
|
srv, st := newPushTestServer(t, true)
|
|
recorder := &recordingPush{enabled: true, publicKey: "key"}
|
|
srv.push = recorder
|
|
|
|
authorCookie := registerAndLogin(t, srv, "push_author", "push-author@example.com")
|
|
registerAndLogin(t, srv, "push_target", "push-target@example.com")
|
|
targetID := mustUserID(t, srv, "push-target@example.com")
|
|
savePushSubscription(t, st, targetID, "https://fcm.googleapis.com/fcm/send/target")
|
|
|
|
channelID := createTestDM(t, srv, authorCookie, formatSnowflake(targetID))
|
|
rec := doJSON(t, srv, http.MethodPost, "/api/v1/channels/"+channelID+"/messages",
|
|
`{"content":"привет, это личное"}`, authorCookie)
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("отправка сообщения = %d, body = %s", rec.Code, rec.Body.String())
|
|
}
|
|
|
|
if len(recorder.jobs) != 1 {
|
|
t.Fatalf("поставлено уведомлений %d, ожидалось 1", len(recorder.jobs))
|
|
}
|
|
job := recorder.jobs[0]
|
|
if job.payload.Kind != "direct" || job.payload.Body != "привет, это личное" {
|
|
t.Fatalf("неожиданное уведомление: %+v", job.payload)
|
|
}
|
|
if job.payload.ChannelID != channelID {
|
|
t.Fatalf("комната уведомления %q, ожидалась %q", job.payload.ChannelID, channelID)
|
|
}
|
|
if job.payload.URL != "/app/friends/"+channelID {
|
|
t.Fatalf("ссылка = %q", job.payload.URL)
|
|
}
|
|
if job.subscription.Endpoint != "https://fcm.googleapis.com/fcm/send/target" {
|
|
t.Fatalf("подписка = %q", job.subscription.Endpoint)
|
|
}
|
|
}
|
|
|
|
// TestPushNotifiesMention: упоминание в комнате сервера уходит получателю;
|
|
// обычное сообщение — нет.
|
|
func TestPushNotifiesMention(t *testing.T) {
|
|
fixture := newMessagingFixture(t)
|
|
recorder := &recordingPush{enabled: true, publicKey: "key"}
|
|
fixture.srv.push = recorder
|
|
savePushSubscription(t, fixture.srv.store, guildIDOf(t, fixture.memberID), "https://fcm.googleapis.com/fcm/send/member")
|
|
|
|
// Обычное сообщение: упоминаний нет — push не нужен.
|
|
rec := doJSON(t, fixture.srv, http.MethodPost, "/api/v1/channels/"+fixture.openChannel+"/messages",
|
|
`{"content":"просто сообщение"}`, fixture.ownerCookie)
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("отправка сообщения = %d, body = %s", rec.Code, rec.Body.String())
|
|
}
|
|
if len(recorder.jobs) != 0 {
|
|
t.Fatalf("без упоминания поставлено уведомлений %d", len(recorder.jobs))
|
|
}
|
|
|
|
// Упоминание: уведомление уходит со ссылкой на комнату сервера.
|
|
rec = doJSON(t, fixture.srv, http.MethodPost, "/api/v1/channels/"+fixture.openChannel+"/messages",
|
|
`{"content":"<@`+fixture.memberID+`> посмотри"}`, fixture.ownerCookie)
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("отправка упоминания = %d, body = %s", rec.Code, rec.Body.String())
|
|
}
|
|
if len(recorder.jobs) != 1 {
|
|
t.Fatalf("поставлено уведомлений %d, ожидалось 1", len(recorder.jobs))
|
|
}
|
|
payload := recorder.jobs[0].payload
|
|
if payload.Kind != "mention" {
|
|
t.Fatalf("вид уведомления = %q", payload.Kind)
|
|
}
|
|
if !strings.Contains(payload.Body, "посмотри") {
|
|
t.Fatalf("тело уведомления = %q", payload.Body)
|
|
}
|
|
if payload.ChannelID != fixture.openChannel {
|
|
t.Fatalf("комната уведомления %q, ожидалась %q", payload.ChannelID, fixture.openChannel)
|
|
}
|
|
if payload.URL != "/app/"+fixture.guildID+"/"+fixture.openChannel {
|
|
t.Fatalf("ссылка = %q", payload.URL)
|
|
}
|
|
if payload.Title == "" || !strings.Contains(payload.Title, "#") {
|
|
t.Fatalf("заголовок уведомления = %q", payload.Title)
|
|
}
|
|
}
|
|
|
|
// TestPushSkipsOwnMessages: свои сообщения уведомлений не порождают — как в
|
|
// desktopNotifications.ts.
|
|
func TestPushSkipsOwnMessages(t *testing.T) {
|
|
srv, st := newPushTestServer(t, true)
|
|
recorder := &recordingPush{enabled: true, publicKey: "key"}
|
|
srv.push = recorder
|
|
|
|
cookie := registerAndLogin(t, srv, "push_self", "push-self@example.com")
|
|
selfID := mustUserID(t, srv, "push-self@example.com")
|
|
savePushSubscription(t, st, selfID, "https://fcm.googleapis.com/fcm/send/self")
|
|
otherCookie := registerAndLogin(t, srv, "push_other", "push-other@example.com")
|
|
otherID := mustUserID(t, srv, "push-other@example.com")
|
|
|
|
channelID := createTestDM(t, srv, cookie, formatSnowflake(otherID))
|
|
// Отправляем другому, но получатель — тоже автор? Нет: подписка только у
|
|
// автора, а сообщение уходит второму участнику — уведомлений быть не должно.
|
|
rec := doJSON(t, srv, http.MethodPost, "/api/v1/channels/"+channelID+"/messages",
|
|
`{"content":"<@`+formatSnowflake(selfID)+`> себе"}`, cookie)
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("отправка = %d, body = %s", rec.Code, rec.Body.String())
|
|
}
|
|
if len(recorder.jobs) != 0 {
|
|
t.Fatalf("своё сообщение дало уведомления: %+v", recorder.jobs)
|
|
}
|
|
_ = otherCookie
|
|
}
|
|
|
|
// TestPushSkipsOnlineUsers: если получатель в клиенте (есть соединение шлюза),
|
|
// уведомление покажет сам клиент — push не отправляем.
|
|
func TestPushSkipsOnlineUsers(t *testing.T) {
|
|
srv, st := newPushTestServer(t, true)
|
|
recorder := &recordingPush{enabled: true, publicKey: "key"}
|
|
srv.push = recorder
|
|
|
|
authorCookie := registerAndLogin(t, srv, "push_online_author", "push-online-author@example.com")
|
|
targetCookie := registerAndLogin(t, srv, "push_online_target", "push-online-target@example.com")
|
|
targetID := mustUserID(t, srv, "push-online-target@example.com")
|
|
savePushSubscription(t, st, targetID, "https://fcm.googleapis.com/fcm/send/online")
|
|
channelID := createTestDM(t, srv, authorCookie, formatSnowflake(targetID))
|
|
|
|
// Настоящее соединение шлюза за получателя: он «в клиенте».
|
|
httpServer := httptest.NewServer(srv.Handler())
|
|
t.Cleanup(httpServer.Close)
|
|
dialGateway(t, httpServer, targetCookie)
|
|
|
|
rec := doJSON(t, srv, http.MethodPost, "/api/v1/channels/"+channelID+"/messages",
|
|
`{"content":"он в клиенте"}`, authorCookie)
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("отправка = %d, body = %s", rec.Code, rec.Body.String())
|
|
}
|
|
if len(recorder.jobs) != 0 {
|
|
t.Fatalf("пользователю в клиенте отправлен push: %+v", recorder.jobs)
|
|
}
|
|
}
|
|
|
|
// TestPushSkippedWhenDisabled: без VAPID-ключа сервер не трогает подписки.
|
|
func TestPushSkippedWhenDisabled(t *testing.T) {
|
|
fixture := newMessagingFixture(t)
|
|
recorder := &recordingPush{enabled: false}
|
|
fixture.srv.push = recorder
|
|
savePushSubscription(t, fixture.srv.store, guildIDOf(t, fixture.memberID), "https://fcm.googleapis.com/fcm/send/off")
|
|
|
|
rec := doJSON(t, fixture.srv, http.MethodPost, "/api/v1/channels/"+fixture.openChannel+"/messages",
|
|
`{"content":"<@`+fixture.memberID+`> привет"}`, fixture.ownerCookie)
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("отправка = %d, body = %s", rec.Code, rec.Body.String())
|
|
}
|
|
if len(recorder.jobs) != 0 {
|
|
t.Fatalf("выключенный push всё равно отправил: %+v", recorder.jobs)
|
|
}
|
|
}
|