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,354 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
"crypto/rand"
|
||||
"crypto/x509"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"glchat/internal/auth"
|
||||
"glchat/internal/config"
|
||||
"glchat/internal/database"
|
||||
"glchat/internal/gateway"
|
||||
"glchat/internal/httpx"
|
||||
"glchat/internal/permissions"
|
||||
"glchat/internal/source"
|
||||
"glchat/internal/store"
|
||||
)
|
||||
|
||||
// newPushTestServer поднимает тестовый сервер с настроенным VAPID-ключом:
|
||||
// newTestServer из server_test.go о push ничего не знает, а ручки обязаны
|
||||
// работать и с ключом, и без него.
|
||||
func newPushTestServer(t *testing.T, withKeys bool) (*Server, *store.Store) {
|
||||
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("open test database: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
if err := db.Close(); err != nil {
|
||||
t.Errorf("close test database: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
cfg := config.Config{
|
||||
DataDir: t.TempDir(),
|
||||
Domain: "gl.mhspx.su",
|
||||
WebRoot: filepath.Join("testdata", "web"),
|
||||
FilesDomain: "files.gl.mhspx.su",
|
||||
InstanceName: "glchat",
|
||||
ListenAddr: "127.0.0.1:0",
|
||||
Version: "v0.1.0-test",
|
||||
Commit: "deadbee",
|
||||
BuildDate: "2026-09-19T00:00:00Z",
|
||||
MaxUploadSize: 26214400,
|
||||
TLSEnabled: true,
|
||||
SessionPepper: "test-pepper",
|
||||
MasterKey: "00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff",
|
||||
Argon2MemoryKiB: 1024,
|
||||
Argon2Iterations: 1,
|
||||
Argon2Parallelism: 1,
|
||||
LogLevel: "error",
|
||||
LogFormat: "json",
|
||||
}
|
||||
if withKeys {
|
||||
cfg.VAPIDPrivateKey = testVAPIDPrivateKey(t)
|
||||
cfg.VAPIDSubject = "mailto:admin@gl.mhspx.su"
|
||||
}
|
||||
logger := slog.New(slog.DiscardHandler)
|
||||
st := store.New(db)
|
||||
authService, err := auth.New(context.Background(), cfg, st, logger)
|
||||
if err != nil {
|
||||
t.Fatalf("initialize authentication: %v", err)
|
||||
}
|
||||
calculator := permissions.NewCalculator(source.New(st))
|
||||
gatewayService := gateway.New(st, authService, gateway.NewSnapshot(st, calculator), logger, cfg.AllowedOrigins())
|
||||
return New(cfg, db, logger, Deps{
|
||||
Store: st, Auth: authService, Gateway: gatewayService, Permissions: calculator,
|
||||
}), st
|
||||
}
|
||||
|
||||
func testVAPIDPrivateKey(t *testing.T) string {
|
||||
t.Helper()
|
||||
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
if err != nil {
|
||||
t.Fatalf("generate key: %v", err)
|
||||
}
|
||||
der, err := x509.MarshalPKCS8PrivateKey(key)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal PKCS#8: %v", err)
|
||||
}
|
||||
return base64.StdEncoding.EncodeToString(der)
|
||||
}
|
||||
|
||||
// testPushKeys возвращает корректные ключи подписки (65-байтовая точка P-256
|
||||
// и 16 байт auth) в base64url — как их отдаёт PushSubscription.toJSON().
|
||||
func testPushKeys(t *testing.T) (string, string) {
|
||||
t.Helper()
|
||||
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
if err != nil {
|
||||
t.Fatalf("generate subscription key: %v", err)
|
||||
}
|
||||
point, err := key.PublicKey.Bytes()
|
||||
if err != nil {
|
||||
t.Fatalf("public key bytes: %v", err)
|
||||
}
|
||||
auth := make([]byte, 16)
|
||||
if _, err := rand.Read(auth); err != nil {
|
||||
t.Fatalf("auth: %v", err)
|
||||
}
|
||||
return base64.RawURLEncoding.EncodeToString(point), base64.RawURLEncoding.EncodeToString(auth)
|
||||
}
|
||||
|
||||
// testAuthKey — корректный auth-ключ подписки (16 байт).
|
||||
func testAuthKey(t *testing.T) string {
|
||||
t.Helper()
|
||||
_, auth := testPushKeys(t)
|
||||
return auth
|
||||
}
|
||||
|
||||
func pushSubscribeBody(t *testing.T, endpoint string) string {
|
||||
t.Helper()
|
||||
p256dh, auth := testPushKeys(t)
|
||||
return fmt.Sprintf(`{"endpoint":%q,"keys":{"p256dh":%q,"auth":%q}}`, endpoint, p256dh, auth)
|
||||
}
|
||||
|
||||
func TestPushConfigDisabledWithoutKeys(t *testing.T) {
|
||||
srv, _ := newPushTestServer(t, false)
|
||||
cookie := registerAndLogin(t, srv, "push_off", "push-off@example.com")
|
||||
|
||||
rec := doJSON(t, srv, http.MethodGet, "/api/v1/push/config", "", cookie)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("GET /push/config = %d, body = %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
payload := decodeResponse[struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
PublicKey string `json:"public_key"`
|
||||
}](t, rec)
|
||||
if payload.Enabled || payload.PublicKey != "" {
|
||||
t.Fatalf("без ключей push должен быть выключен: %+v", payload)
|
||||
}
|
||||
|
||||
// Подписка на инстансе без ключей — честная ошибка, а не молчаливый успех.
|
||||
rec = doJSON(t, srv, http.MethodPost, "/api/v1/push/subscriptions",
|
||||
pushSubscribeBody(t, "https://fcm.googleapis.com/fcm/send/disabled"), cookie)
|
||||
if rec.Code != http.StatusServiceUnavailable {
|
||||
t.Fatalf("подписка без ключей = %d, ожидалось 503", rec.Code)
|
||||
}
|
||||
if code := errorCodeOf(t, rec); code != "push.disabled" {
|
||||
t.Fatalf("код ошибки = %q, ожидался push.disabled", code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPushSubscribeFlow(t *testing.T) {
|
||||
srv, st := newPushTestServer(t, true)
|
||||
cookie := registerAndLogin(t, srv, "push_on", "push-on@example.com")
|
||||
user, err := srv.auth.UserByEmail(t.Context(), "push-on@example.com")
|
||||
if err != nil {
|
||||
t.Fatalf("UserByEmail: %v", err)
|
||||
}
|
||||
|
||||
config := decodeResponse[struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
PublicKey string `json:"public_key"`
|
||||
}](t, doJSON(t, srv, http.MethodGet, "/api/v1/push/config", "", cookie))
|
||||
if !config.Enabled || config.PublicKey == "" {
|
||||
t.Fatalf("push должен быть включён: %+v", config)
|
||||
}
|
||||
decoded, err := base64.RawURLEncoding.DecodeString(config.PublicKey)
|
||||
if err != nil || len(decoded) != 65 {
|
||||
t.Fatalf("публичный ключ не годится для applicationServerKey: %v", err)
|
||||
}
|
||||
|
||||
endpoint := "https://fcm.googleapis.com/fcm/send/device-one"
|
||||
rec := doJSON(t, srv, http.MethodPost, "/api/v1/push/subscriptions",
|
||||
pushSubscribeBody(t, endpoint), cookie)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("подписка = %d, body = %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
subscriptions, err := st.ListPushSubscriptions(t.Context(), user.ID)
|
||||
if err != nil || len(subscriptions) != 1 {
|
||||
t.Fatalf("подписок в базе %d (%v), ожидалась 1", len(subscriptions), err)
|
||||
}
|
||||
if subscriptions[0].Endpoint != endpoint {
|
||||
t.Fatalf("эндпоинт = %q", subscriptions[0].Endpoint)
|
||||
}
|
||||
|
||||
// Список для интерфейса отдаёт устройства и лимит.
|
||||
list := decodeResponse[struct {
|
||||
Subscriptions []struct {
|
||||
ID string `json:"id"`
|
||||
Endpoint string `json:"endpoint"`
|
||||
} `json:"subscriptions"`
|
||||
Limit int `json:"limit"`
|
||||
}](t, doJSON(t, srv, http.MethodGet, "/api/v1/push/subscriptions", "", cookie))
|
||||
if len(list.Subscriptions) != 1 || list.Limit != maxPushSubscriptionsPerUser {
|
||||
t.Fatalf("неожиданный список подписок: %+v", list)
|
||||
}
|
||||
|
||||
// Отписка устройства.
|
||||
rec = doJSON(t, srv, http.MethodDelete,
|
||||
"/api/v1/push/subscriptions?endpoint="+endpoint, "", cookie)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("отписка = %d, body = %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if count, err := st.CountPushSubscriptions(t.Context(), user.ID); err != nil || count != 0 {
|
||||
t.Fatalf("после отписки подписок %d (%v)", count, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPushSubscribeValidation(t *testing.T) {
|
||||
srv, _ := newPushTestServer(t, true)
|
||||
cookie := registerAndLogin(t, srv, "push_bad", "push-bad@example.com")
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
body string
|
||||
code string
|
||||
}{
|
||||
{
|
||||
name: "http вместо https",
|
||||
body: pushSubscribeBody(t, "http://fcm.googleapis.com/fcm/send/x"),
|
||||
code: "push.invalid_endpoint",
|
||||
},
|
||||
{
|
||||
name: "внутренний адрес",
|
||||
body: pushSubscribeBody(t, "https://10.0.0.5/push"),
|
||||
code: "push.invalid_endpoint",
|
||||
},
|
||||
{
|
||||
name: "метаданные облака",
|
||||
body: pushSubscribeBody(t, "https://169.254.169.254/latest/meta-data"),
|
||||
code: "push.invalid_endpoint",
|
||||
},
|
||||
{
|
||||
name: "loopback",
|
||||
body: pushSubscribeBody(t, "https://127.0.0.1:8443/push"),
|
||||
code: "push.invalid_endpoint",
|
||||
},
|
||||
{
|
||||
name: "p256dh не точка кривой",
|
||||
body: fmt.Sprintf(`{"endpoint":"https://fcm.googleapis.com/fcm/send/x","keys":{"p256dh":%q,"auth":%q}}`,
|
||||
base64.RawURLEncoding.EncodeToString(make([]byte, 65)), testAuthKey(t)),
|
||||
code: "push.invalid_keys",
|
||||
},
|
||||
{
|
||||
name: "битый ключ шифрования",
|
||||
body: `{"endpoint":"https://fcm.googleapis.com/fcm/send/x","keys":{"p256dh":"AAAA","auth":"AAAA"}}`,
|
||||
code: "push.invalid_keys",
|
||||
},
|
||||
{
|
||||
name: "пустой ключ",
|
||||
body: `{"endpoint":"https://fcm.googleapis.com/fcm/send/x","keys":{"p256dh":"","auth":""}}`,
|
||||
code: "push.invalid_keys",
|
||||
},
|
||||
}
|
||||
for _, testCase := range cases {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
rec := doJSON(t, srv, http.MethodPost, "/api/v1/push/subscriptions", testCase.body, cookie)
|
||||
if rec.Code != http.StatusUnprocessableEntity {
|
||||
t.Fatalf("код ответа = %d, ожидался 422 (body = %s)", rec.Code, rec.Body.String())
|
||||
}
|
||||
if code := errorCodeOf(t, rec); code != testCase.code {
|
||||
t.Fatalf("код ошибки = %q, ожидался %q", code, testCase.code)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPushSubscribeLimit(t *testing.T) {
|
||||
srv, _ := newPushTestServer(t, true)
|
||||
cookie := registerAndLogin(t, srv, "push_limit", "push-limit@example.com")
|
||||
// Лимит частоты проверяется отдельно: здесь важно дойти до предела
|
||||
// устройств на пользователя.
|
||||
srv.pushLimiter = httpx.NewRateLimiterWindow(100, time.Minute, 100)
|
||||
|
||||
for i := 0; i < maxPushSubscriptionsPerUser; i++ {
|
||||
endpoint := fmt.Sprintf("https://fcm.googleapis.com/fcm/send/device-%d", i)
|
||||
rec := doJSON(t, srv, http.MethodPost, "/api/v1/push/subscriptions",
|
||||
pushSubscribeBody(t, endpoint), cookie)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("подписка %d = %d, body = %s", i, rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
rec := doJSON(t, srv, http.MethodPost, "/api/v1/push/subscriptions",
|
||||
pushSubscribeBody(t, "https://fcm.googleapis.com/fcm/send/device-over-limit"), cookie)
|
||||
if rec.Code != http.StatusConflict {
|
||||
t.Fatalf("подписка сверх лимита = %d, ожидалось 409 (body = %s)", rec.Code, rec.Body.String())
|
||||
}
|
||||
if code := errorCodeOf(t, rec); code != "push.too_many_subscriptions" {
|
||||
t.Fatalf("код ошибки = %q", code)
|
||||
}
|
||||
}
|
||||
|
||||
// Подписки ограничены и по частоте: перебор устройств не должен превращаться
|
||||
// в поток запросов (AGENT.md 8.6).
|
||||
func TestPushSubscribeRateLimited(t *testing.T) {
|
||||
srv, _ := newPushTestServer(t, true)
|
||||
cookie := registerAndLogin(t, srv, "push_flood", "push-flood@example.com")
|
||||
|
||||
var limited bool
|
||||
for i := 0; i < 40; i++ {
|
||||
endpoint := fmt.Sprintf("https://fcm.googleapis.com/fcm/send/flood-%d", i)
|
||||
rec := doJSON(t, srv, http.MethodPost, "/api/v1/push/subscriptions",
|
||||
pushSubscribeBody(t, endpoint), cookie)
|
||||
if rec.Code == http.StatusTooManyRequests {
|
||||
limited = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !limited {
|
||||
t.Fatal("лимит частоты подписок не сработал")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPushRoutesRequireSession(t *testing.T) {
|
||||
srv, _ := newPushTestServer(t, true)
|
||||
body := pushSubscribeBody(t, "https://fcm.googleapis.com/fcm/send/no-session")
|
||||
for _, request := range []struct {
|
||||
method string
|
||||
path string
|
||||
body string
|
||||
}{
|
||||
{http.MethodGet, "/api/v1/push/config", ""},
|
||||
{http.MethodGet, "/api/v1/push/subscriptions", ""},
|
||||
{http.MethodPost, "/api/v1/push/subscriptions", body},
|
||||
{http.MethodDelete, "/api/v1/push/subscriptions", ""},
|
||||
} {
|
||||
rec := doJSON(t, srv, request.method, request.path, request.body)
|
||||
if rec.Code != http.StatusUnauthorized {
|
||||
t.Errorf("%s %s без сессии = %d, ожидалось 401", request.method, request.path, rec.Code)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Публичный ключ в /meta виден до входа: клиент решает, показывать ли раздел.
|
||||
func TestMetaExposesWebPushFlag(t *testing.T) {
|
||||
srv, _ := newPushTestServer(t, true)
|
||||
rec := doJSON(t, srv, http.MethodGet, "/api/v1/meta", "")
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("GET /meta = %d", rec.Code)
|
||||
}
|
||||
payload := decodeResponse[struct {
|
||||
Features struct {
|
||||
WebPushEnabled bool `json:"web_push_enabled"`
|
||||
} `json:"features"`
|
||||
}](t, rec)
|
||||
if !payload.Features.WebPushEnabled {
|
||||
t.Fatal("meta не сообщает о включённом Web Push")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user