Files

202 lines
8.1 KiB
Go
Raw Permalink Normal View History

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
}