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,201 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Web Push: подписки браузеров на уведомления (AGENT.md 7.16, Фаза 7).
|
||||
//
|
||||
// Эндпоинт выдаёт push-сервис (FCM, Mozilla, Apple), ключи p256dh/auth нужны
|
||||
// для шифрования payload по RFC 8291 и приходят из PushSubscription.toJSON().
|
||||
// Всё, что приходит от клиента, ограничено по длине, а ключи дополнительно
|
||||
// проверяются в HTTP-слое: в базе не должно оказаться произвольного текста.
|
||||
|
||||
// PushSubscription — подписка одного устройства.
|
||||
type PushSubscription struct {
|
||||
ID uint64
|
||||
UserID uint64
|
||||
Endpoint string
|
||||
P256dh string
|
||||
Auth string
|
||||
UserAgent string
|
||||
CreatedAt time.Time
|
||||
LastSuccessAt *time.Time
|
||||
LastFailureAt *time.Time
|
||||
FailureCount int
|
||||
}
|
||||
|
||||
// SavePushSubscriptionParams — параметры подписки от клиента.
|
||||
type SavePushSubscriptionParams struct {
|
||||
UserID uint64
|
||||
Endpoint string
|
||||
P256dh string
|
||||
Auth string
|
||||
UserAgent string
|
||||
}
|
||||
|
||||
const pushSubscriptionColumns = `id, user_id, endpoint, p256dh, auth, user_agent, created_at,
|
||||
last_success_at, last_failure_at, failure_count`
|
||||
|
||||
// SavePushSubscription сохраняет подписку устройства: повторная подписка с тем
|
||||
// же эндпоинтом обновляет ключи и сбрасывает счётчик неудач. Эндпоинт
|
||||
// уникален глобально, поэтому подписка «переезжает» к последнему владельцу —
|
||||
// так бывает, когда на одном устройстве сменился аккаунт.
|
||||
func (s *Store) SavePushSubscription(ctx context.Context, params SavePushSubscriptionParams) (*PushSubscription, error) {
|
||||
_, err := s.writer.ExecContext(ctx, `
|
||||
INSERT INTO push_subscriptions (id, user_id, endpoint, p256dh, auth, user_agent, created_at, failure_count)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, 0)
|
||||
ON CONFLICT (endpoint) DO UPDATE SET
|
||||
user_id = excluded.user_id,
|
||||
p256dh = excluded.p256dh,
|
||||
auth = excluded.auth,
|
||||
user_agent = excluded.user_agent,
|
||||
failure_count = 0,
|
||||
last_failure_at = NULL`,
|
||||
int64(s.NextID()), int64(params.UserID), params.Endpoint, params.P256dh, params.Auth,
|
||||
params.UserAgent, s.Now(),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, mapError(err)
|
||||
}
|
||||
return s.GetPushSubscriptionByEndpoint(ctx, params.Endpoint)
|
||||
}
|
||||
|
||||
// GetPushSubscriptionByEndpoint возвращает подписку по эндпоинту.
|
||||
func (s *Store) GetPushSubscriptionByEndpoint(ctx context.Context, endpoint string) (*PushSubscription, error) {
|
||||
row := s.reader.QueryRowContext(ctx,
|
||||
`SELECT `+pushSubscriptionColumns+` FROM push_subscriptions WHERE endpoint = ?`, endpoint)
|
||||
return scanPushSubscription(row)
|
||||
}
|
||||
|
||||
// ListPushSubscriptions перечисляет подписки пользователя (свежие — первыми).
|
||||
func (s *Store) ListPushSubscriptions(ctx context.Context, userID uint64) ([]PushSubscription, error) {
|
||||
rows, err := s.reader.QueryContext(ctx,
|
||||
`SELECT `+pushSubscriptionColumns+` FROM push_subscriptions
|
||||
WHERE user_id = ? ORDER BY created_at DESC`, int64(userID))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
subscriptions := make([]PushSubscription, 0, 4)
|
||||
for rows.Next() {
|
||||
subscription, err := scanPushSubscription(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
subscriptions = append(subscriptions, *subscription)
|
||||
}
|
||||
return subscriptions, rows.Err()
|
||||
}
|
||||
|
||||
// CountPushSubscriptions считает подписки пользователя: лимит устройств.
|
||||
func (s *Store) CountPushSubscriptions(ctx context.Context, userID uint64) (int, error) {
|
||||
var count int
|
||||
if err := s.reader.QueryRowContext(ctx,
|
||||
`SELECT COUNT(*) FROM push_subscriptions WHERE user_id = ?`, int64(userID)).Scan(&count); err != nil {
|
||||
return 0, mapError(err)
|
||||
}
|
||||
return count, nil
|
||||
}
|
||||
|
||||
// DeletePushSubscription удаляет подписку по эндпоинту. Если эндпоинт пуст —
|
||||
// удаляются все подписки пользователя (отписка «со всех устройств»).
|
||||
func (s *Store) DeletePushSubscription(ctx context.Context, userID uint64, endpoint string) (int64, error) {
|
||||
query := `DELETE FROM push_subscriptions WHERE user_id = ?`
|
||||
args := []any{int64(userID)}
|
||||
if endpoint != "" {
|
||||
query += ` AND endpoint = ?`
|
||||
args = append(args, endpoint)
|
||||
}
|
||||
result, err := s.writer.ExecContext(ctx, query, args...)
|
||||
if err != nil {
|
||||
return 0, mapError(err)
|
||||
}
|
||||
return result.RowsAffected()
|
||||
}
|
||||
|
||||
// DeletePushSubscriptionByID удаляет подписку по идентификатору: так убираются
|
||||
// записи, признанные мёртвыми (push-сервис ответил 404/410).
|
||||
func (s *Store) DeletePushSubscriptionByID(ctx context.Context, id uint64) error {
|
||||
_, err := s.writer.ExecContext(ctx, `DELETE FROM push_subscriptions WHERE id = ?`, int64(id))
|
||||
return mapError(err)
|
||||
}
|
||||
|
||||
// TouchPushSubscription отмечает успешную доставку.
|
||||
func (s *Store) TouchPushSubscription(ctx context.Context, id uint64) error {
|
||||
_, err := s.writer.ExecContext(ctx,
|
||||
`UPDATE push_subscriptions SET last_success_at = ?, failure_count = 0 WHERE id = ?`,
|
||||
s.Now(), int64(id))
|
||||
return mapError(err)
|
||||
}
|
||||
|
||||
// FailPushSubscription отмечает неудачную доставку и возвращает новое число
|
||||
// подряд идущих неудач: по нему HTTP-слой решает, удалять ли подписку.
|
||||
func (s *Store) FailPushSubscription(ctx context.Context, id uint64) (int, error) {
|
||||
if _, err := s.writer.ExecContext(ctx,
|
||||
`UPDATE push_subscriptions
|
||||
SET failure_count = failure_count + 1, last_failure_at = ?
|
||||
WHERE id = ?`, s.Now(), int64(id)); err != nil {
|
||||
return 0, mapError(err)
|
||||
}
|
||||
var count int
|
||||
if err := s.reader.QueryRowContext(ctx,
|
||||
`SELECT failure_count FROM push_subscriptions WHERE id = ?`, int64(id)).Scan(&count); err != nil {
|
||||
return 0, mapError(err)
|
||||
}
|
||||
return count, nil
|
||||
}
|
||||
|
||||
// DeleteStalePushSubscriptions убирает подписки, которые давно не доставлялись:
|
||||
// молчащие эндпоинты не должны копиться вечно (AGENT.md 6.4).
|
||||
func (s *Store) DeleteStalePushSubscriptions(ctx context.Context, before time.Time, maxFailures int) (int64, error) {
|
||||
result, err := s.writer.ExecContext(ctx, `
|
||||
DELETE FROM push_subscriptions
|
||||
WHERE failure_count >= ? AND COALESCE(last_failure_at, created_at) <= ?`,
|
||||
maxFailures, s.Timestamp(before))
|
||||
if err != nil {
|
||||
return 0, mapError(err)
|
||||
}
|
||||
return result.RowsAffected()
|
||||
}
|
||||
|
||||
// DeletePushSubscriptionsForUser убирает все подписки пользователя: вызывается
|
||||
// при удалении аккаунта (каскад в БД страхует, но так понятнее по коду).
|
||||
func (s *Store) DeletePushSubscriptionsForUser(ctx context.Context, userID uint64) (int64, error) {
|
||||
result, err := s.writer.ExecContext(ctx,
|
||||
`DELETE FROM push_subscriptions WHERE user_id = ?`, int64(userID))
|
||||
if err != nil {
|
||||
return 0, mapError(err)
|
||||
}
|
||||
return result.RowsAffected()
|
||||
}
|
||||
|
||||
func scanPushSubscription(scanner interface{ Scan(...any) error }) (*PushSubscription, error) {
|
||||
var (
|
||||
subscription PushSubscription
|
||||
createdAt string
|
||||
lastSuccessAt sql.NullString
|
||||
lastFailureAt sql.NullString
|
||||
)
|
||||
err := scanner.Scan(
|
||||
&subscription.ID, &subscription.UserID, &subscription.Endpoint, &subscription.P256dh,
|
||||
&subscription.Auth, &subscription.UserAgent, &createdAt,
|
||||
&lastSuccessAt, &lastFailureAt, &subscription.FailureCount,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, mapError(err)
|
||||
}
|
||||
subscription.CreatedAt = parseTimestamp(createdAt)
|
||||
if lastSuccessAt.Valid {
|
||||
value := parseTimestamp(lastSuccessAt.String)
|
||||
subscription.LastSuccessAt = &value
|
||||
}
|
||||
if lastFailureAt.Valid {
|
||||
value := parseTimestamp(lastFailureAt.String)
|
||||
subscription.LastFailureAt = &value
|
||||
}
|
||||
return &subscription, nil
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"glchat/internal/database"
|
||||
)
|
||||
|
||||
// newPushTestStore открывает временную БД с миграциями: подписки Web Push
|
||||
// ссылаются на пользователя внешним ключом, поэтому нужен реальный аккаунт.
|
||||
func newPushTestStore(t *testing.T) (*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 := New(db)
|
||||
user, err := st.CreateUser(ctx, CreateUserParams{
|
||||
Username: "push_store_user",
|
||||
EmailEnc: "enc",
|
||||
EmailIndex: "idx",
|
||||
PasswordHash: "hash",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("создать пользователя: %v", err)
|
||||
}
|
||||
return st, user.ID
|
||||
}
|
||||
|
||||
func TestPushSubscriptionLifecycle(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
st, userID := newPushTestStore(t)
|
||||
|
||||
saved, err := st.SavePushSubscription(ctx, SavePushSubscriptionParams{
|
||||
UserID: userID,
|
||||
Endpoint: "https://fcm.googleapis.com/fcm/send/one",
|
||||
P256dh: "p256dh-1",
|
||||
Auth: "auth-1",
|
||||
UserAgent: "Chrome/140",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("SavePushSubscription: %v", err)
|
||||
}
|
||||
if saved.ID == 0 || saved.FailureCount != 0 || saved.LastSuccessAt != nil {
|
||||
t.Fatalf("неожиданная подписка: %+v", saved)
|
||||
}
|
||||
|
||||
byEndpoint, err := st.GetPushSubscriptionByEndpoint(ctx, saved.Endpoint)
|
||||
if err != nil {
|
||||
t.Fatalf("GetPushSubscriptionByEndpoint: %v", err)
|
||||
}
|
||||
if byEndpoint.UserID != userID || byEndpoint.P256dh != "p256dh-1" || byEndpoint.UserAgent != "Chrome/140" {
|
||||
t.Fatalf("подписка прочитана неверно: %+v", byEndpoint)
|
||||
}
|
||||
|
||||
// Повторная подписка с того же устройства обновляет ключи и сбрасывает
|
||||
// счётчик неудач: браузер мог перевыпустить ключи шифрования.
|
||||
if _, err := st.FailPushSubscription(ctx, saved.ID); err != nil {
|
||||
t.Fatalf("FailPushSubscription: %v", err)
|
||||
}
|
||||
updated, err := st.SavePushSubscription(ctx, SavePushSubscriptionParams{
|
||||
UserID: userID,
|
||||
Endpoint: saved.Endpoint,
|
||||
P256dh: "p256dh-2",
|
||||
Auth: "auth-2",
|
||||
UserAgent: "Chrome/141",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("повторный SavePushSubscription: %v", err)
|
||||
}
|
||||
if updated.ID != saved.ID {
|
||||
t.Fatalf("upsert создал новую запись: %d вместо %d", updated.ID, saved.ID)
|
||||
}
|
||||
if updated.P256dh != "p256dh-2" || updated.FailureCount != 0 {
|
||||
t.Fatalf("upsert не обновил запись: %+v", updated)
|
||||
}
|
||||
|
||||
count, err := st.CountPushSubscriptions(ctx, userID)
|
||||
if err != nil || count != 1 {
|
||||
t.Fatalf("CountPushSubscriptions = %d (%v), ожидалось 1", count, err)
|
||||
}
|
||||
|
||||
// Успешная доставка отмечается и обнуляет счётчик неудач.
|
||||
if _, err := st.FailPushSubscription(ctx, saved.ID); err != nil {
|
||||
t.Fatalf("FailPushSubscription: %v", err)
|
||||
}
|
||||
if err := st.TouchPushSubscription(ctx, saved.ID); err != nil {
|
||||
t.Fatalf("TouchPushSubscription: %v", err)
|
||||
}
|
||||
touched, err := st.GetPushSubscriptionByEndpoint(ctx, saved.Endpoint)
|
||||
if err != nil {
|
||||
t.Fatalf("GetPushSubscriptionByEndpoint: %v", err)
|
||||
}
|
||||
if touched.LastSuccessAt == nil || touched.FailureCount != 0 {
|
||||
t.Fatalf("успех не отмечен: %+v", touched)
|
||||
}
|
||||
|
||||
if affected, err := st.DeletePushSubscription(ctx, userID, saved.Endpoint); err != nil || affected != 1 {
|
||||
t.Fatalf("DeletePushSubscription = %d (%v), ожидалось 1", affected, err)
|
||||
}
|
||||
if _, err := st.GetPushSubscriptionByEndpoint(ctx, saved.Endpoint); !errors.Is(err, ErrNotFound) {
|
||||
t.Fatalf("после удаления ошибка = %v, ожидалась ErrNotFound", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPushSubscriptionLimitAndBulkDelete(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
st, userID := newPushTestStore(t)
|
||||
|
||||
for i := 0; i < 3; i++ {
|
||||
if _, err := st.SavePushSubscription(ctx, SavePushSubscriptionParams{
|
||||
UserID: userID,
|
||||
Endpoint: "https://push.example.com/" + string(rune('a'+i)),
|
||||
P256dh: "key",
|
||||
Auth: "auth",
|
||||
}); err != nil {
|
||||
t.Fatalf("SavePushSubscription %d: %v", i, err)
|
||||
}
|
||||
}
|
||||
subscriptions, err := st.ListPushSubscriptions(ctx, userID)
|
||||
if err != nil {
|
||||
t.Fatalf("ListPushSubscriptions: %v", err)
|
||||
}
|
||||
if len(subscriptions) != 3 {
|
||||
t.Fatalf("подписок %d, ожидалось 3", len(subscriptions))
|
||||
}
|
||||
|
||||
// Пустой эндпоинт — отписка со всех устройств.
|
||||
if affected, err := st.DeletePushSubscription(ctx, userID, ""); err != nil || affected != 3 {
|
||||
t.Fatalf("массовая отписка = %d (%v), ожидалось 3", affected, err)
|
||||
}
|
||||
if count, err := st.CountPushSubscriptions(ctx, userID); err != nil || count != 0 {
|
||||
t.Fatalf("после отписки подписок %d (%v)", count, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPushSubscriptionFailuresAndStaleCleanup(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
st, userID := newPushTestStore(t)
|
||||
|
||||
subscription, err := st.SavePushSubscription(ctx, SavePushSubscriptionParams{
|
||||
UserID: userID,
|
||||
Endpoint: "https://push.example.com/stale",
|
||||
P256dh: "key",
|
||||
Auth: "auth",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("SavePushSubscription: %v", err)
|
||||
}
|
||||
for i := 1; i <= 3; i++ {
|
||||
count, err := st.FailPushSubscription(ctx, subscription.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("FailPushSubscription: %v", err)
|
||||
}
|
||||
if count != i {
|
||||
t.Fatalf("счётчик неудач = %d, ожидался %d", count, i)
|
||||
}
|
||||
}
|
||||
|
||||
// Свежая неудача: подписка ещё не считается мёртвой.
|
||||
if removed, err := st.DeleteStalePushSubscriptions(ctx, time.Now().UTC().Add(-time.Hour), 3); err != nil || removed != 0 {
|
||||
t.Fatalf("удалено свежих подписок %d (%v), ожидалось 0", removed, err)
|
||||
}
|
||||
// Неудачная доставка была давно — подписка убирается.
|
||||
if removed, err := st.DeleteStalePushSubscriptions(ctx, time.Now().UTC().Add(time.Hour), 3); err != nil || removed != 1 {
|
||||
t.Fatalf("удалено мёртвых подписок %d (%v), ожидалось 1", removed, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPushSubscriptionsRemovedWithUser(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
st, userID := newPushTestStore(t)
|
||||
|
||||
if _, err := st.SavePushSubscription(ctx, SavePushSubscriptionParams{
|
||||
UserID: userID,
|
||||
Endpoint: "https://push.example.com/cascade",
|
||||
P256dh: "key",
|
||||
Auth: "auth",
|
||||
}); err != nil {
|
||||
t.Fatalf("SavePushSubscription: %v", err)
|
||||
}
|
||||
// Мягкое удаление пользователя сессии отзывает, но подписки остаются:
|
||||
// их убирает явный вызов, иначе push уходил бы удалённому аккаунту.
|
||||
if _, err := st.DeletePushSubscriptionsForUser(ctx, userID); err != nil {
|
||||
t.Fatalf("DeletePushSubscriptionsForUser: %v", err)
|
||||
}
|
||||
if count, err := st.CountPushSubscriptions(ctx, userID); err != nil || count != 0 {
|
||||
t.Fatalf("после удаления пользователя подписок %d (%v)", count, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeletePushSubscriptionByID(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
st, userID := newPushTestStore(t)
|
||||
|
||||
subscription, err := st.SavePushSubscription(ctx, SavePushSubscriptionParams{
|
||||
UserID: userID,
|
||||
Endpoint: "https://push.example.com/by-id",
|
||||
P256dh: "key",
|
||||
Auth: "auth",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("SavePushSubscription: %v", err)
|
||||
}
|
||||
if err := st.DeletePushSubscriptionByID(ctx, subscription.ID); err != nil {
|
||||
t.Fatalf("DeletePushSubscriptionByID: %v", err)
|
||||
}
|
||||
if _, err := st.GetPushSubscriptionByEndpoint(ctx, subscription.Endpoint); !errors.Is(err, ErrNotFound) {
|
||||
t.Fatalf("после удаления ошибка = %v, ожидалась ErrNotFound", err)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user