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, очистка мёртвых подписок), ручки и лимиты, отправка при личном сообщении и упоминании, пропуск онлайн-получателей, миграция на чистой БД.
256 lines
8.9 KiB
Go
256 lines
8.9 KiB
Go
package server
|
|
|
|
import (
|
|
"context"
|
|
"crypto/ecdsa"
|
|
"crypto/elliptic"
|
|
"encoding/base64"
|
|
"net/http"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"github.com/danielgtaylor/huma/v2"
|
|
|
|
"glchat/internal/httpx"
|
|
"glchat/internal/store"
|
|
)
|
|
|
|
// Web Push (AGENT.md 7.16, Фаза 7): подписка устройства, отписка и параметры
|
|
// для клиента. VAPID-ключ приватный живёт в конфиге инстанса, наружу уходит
|
|
// только публичный — он и нужен браузеру как `applicationServerKey`.
|
|
|
|
// Лимиты подписок: устройств на пользователя и длины полей от клиента.
|
|
const (
|
|
maxPushSubscriptionsPerUser = 10
|
|
maxPushEndpointLength = 1024
|
|
maxPushKeyLength = 128
|
|
maxPushUserAgentLength = 200
|
|
)
|
|
|
|
type pushConfigOutput struct {
|
|
Body struct {
|
|
Enabled bool `json:"enabled"`
|
|
PublicKey string `json:"public_key,omitempty"`
|
|
}
|
|
}
|
|
|
|
type pushSubscriptionPayload struct {
|
|
ID string `json:"id"`
|
|
Endpoint string `json:"endpoint"`
|
|
UserAgent string `json:"user_agent,omitempty"`
|
|
CreatedAt string `json:"created_at"`
|
|
}
|
|
|
|
type pushSubscriptionListOutput struct {
|
|
Body struct {
|
|
Subscriptions []pushSubscriptionPayload `json:"subscriptions"`
|
|
// Limit — сколько устройств можно подписать (для интерфейса).
|
|
Limit int `json:"limit"`
|
|
}
|
|
}
|
|
|
|
type pushOKOutput struct {
|
|
Body struct {
|
|
OK bool `json:"ok"`
|
|
}
|
|
}
|
|
|
|
// registerPushRoutes описывает ручки Web Push.
|
|
func (s *Server) registerPushRoutes(api huma.API) {
|
|
security := []map[string][]string{{"sessionCookie": {}}, {"bearerAuth": {}}}
|
|
|
|
huma.Register(api, huma.Operation{
|
|
OperationID: "getPushConfig",
|
|
Method: http.MethodGet,
|
|
Path: "/push/config",
|
|
Summary: "Параметры Web Push для клиента",
|
|
Tags: []string{"Push"},
|
|
Security: security,
|
|
}, func(ctx context.Context, _ *struct{}) (*pushConfigOutput, error) {
|
|
if _, _, err := requireUser(ctx); err != nil {
|
|
return nil, err
|
|
}
|
|
output := &pushConfigOutput{}
|
|
output.Body.Enabled = s.push.Enabled()
|
|
output.Body.PublicKey = s.push.PublicKey()
|
|
return output, nil
|
|
})
|
|
|
|
huma.Register(api, huma.Operation{
|
|
OperationID: "listPushSubscriptions",
|
|
Method: http.MethodGet,
|
|
Path: "/push/subscriptions",
|
|
Summary: "Подписки устройств текущего пользователя",
|
|
Tags: []string{"Push"},
|
|
Security: security,
|
|
}, func(ctx context.Context, _ *struct{}) (*pushSubscriptionListOutput, error) {
|
|
user, _, err := requireUser(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
subscriptions, err := s.store.ListPushSubscriptions(ctx, user.ID)
|
|
if err != nil {
|
|
return nil, humaError(err)
|
|
}
|
|
output := &pushSubscriptionListOutput{}
|
|
output.Body.Limit = maxPushSubscriptionsPerUser
|
|
output.Body.Subscriptions = make([]pushSubscriptionPayload, 0, len(subscriptions))
|
|
for i := range subscriptions {
|
|
output.Body.Subscriptions = append(output.Body.Subscriptions, pushSubscriptionPayload{
|
|
ID: formatSnowflake(subscriptions[i].ID),
|
|
Endpoint: subscriptions[i].Endpoint,
|
|
UserAgent: subscriptions[i].UserAgent,
|
|
CreatedAt: s.store.Timestamp(subscriptions[i].CreatedAt),
|
|
})
|
|
}
|
|
return output, nil
|
|
})
|
|
|
|
huma.Register(api, huma.Operation{
|
|
OperationID: "subscribePush",
|
|
Method: http.MethodPost,
|
|
Path: "/push/subscriptions",
|
|
Summary: "Подписать устройство на Web Push",
|
|
Tags: []string{"Push"},
|
|
Security: security,
|
|
}, func(ctx context.Context, input *struct {
|
|
UserAgent string `header:"User-Agent"`
|
|
Body struct {
|
|
Endpoint string `json:"endpoint" maxLength:"1024" minLength:"8"`
|
|
Keys struct {
|
|
P256dh string `json:"p256dh" maxLength:"128"`
|
|
Auth string `json:"auth" maxLength:"128"`
|
|
} `json:"keys"`
|
|
}
|
|
},
|
|
) (*pushOKOutput, error) {
|
|
user, _, err := requireUser(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if !s.push.Enabled() {
|
|
return nil, humaErrorStatus(http.StatusServiceUnavailable, "push.disabled",
|
|
"web push is not configured on this instance")
|
|
}
|
|
if allowed, retryAfter := s.pushLimiter.Allow(pushLimitKey(user.ID)); !allowed {
|
|
return nil, rateLimitedError(retryAfter)
|
|
}
|
|
endpoint, err := validatePushEndpoint(input.Body.Endpoint)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if err := validatePushKey("p256dh", input.Body.Keys.P256dh, 65); err != nil {
|
|
return nil, err
|
|
}
|
|
if err := validatePushKey("auth", input.Body.Keys.Auth, 16); err != nil {
|
|
return nil, err
|
|
}
|
|
count, err := s.store.CountPushSubscriptions(ctx, user.ID)
|
|
if err != nil {
|
|
return nil, humaError(err)
|
|
}
|
|
// Повторная подписка с того же устройства обновляет запись, поэтому
|
|
// лимит проверяем только для новой.
|
|
if count >= maxPushSubscriptionsPerUser {
|
|
existing, err := s.store.GetPushSubscriptionByEndpoint(ctx, endpoint)
|
|
if err != nil || existing.UserID != user.ID {
|
|
return nil, humaErrorStatus(http.StatusConflict, "push.too_many_subscriptions",
|
|
"too many subscribed devices")
|
|
}
|
|
}
|
|
if _, err := s.store.SavePushSubscription(ctx, store.SavePushSubscriptionParams{
|
|
UserID: user.ID,
|
|
Endpoint: endpoint,
|
|
P256dh: input.Body.Keys.P256dh,
|
|
Auth: input.Body.Keys.Auth,
|
|
UserAgent: truncate(input.UserAgent, maxPushUserAgentLength),
|
|
}); err != nil {
|
|
return nil, humaError(err)
|
|
}
|
|
output := &pushOKOutput{}
|
|
output.Body.OK = true
|
|
return output, nil
|
|
})
|
|
|
|
huma.Register(api, huma.Operation{
|
|
OperationID: "unsubscribePush",
|
|
Method: http.MethodDelete,
|
|
Path: "/push/subscriptions",
|
|
Summary: "Отписать устройство (или все) от Web Push",
|
|
Tags: []string{"Push"},
|
|
Security: security,
|
|
}, func(ctx context.Context, input *struct {
|
|
Endpoint string `query:"endpoint,omitempty" maxLength:"1024"`
|
|
},
|
|
) (*pushOKOutput, error) {
|
|
user, _, err := requireUser(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
endpoint := strings.TrimSpace(input.Endpoint)
|
|
if endpoint != "" {
|
|
if _, err := validatePushEndpoint(endpoint); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
if _, err := s.store.DeletePushSubscription(ctx, user.ID, endpoint); err != nil {
|
|
return nil, humaError(err)
|
|
}
|
|
output := &pushOKOutput{}
|
|
output.Body.OK = true
|
|
return output, nil
|
|
})
|
|
}
|
|
|
|
// validatePushEndpoint проверяет эндпоинт подписки: http не допускаем, а
|
|
// литеральный внутренний адрес отсекаем сразу — иначе сервер сам себе
|
|
// организует SSRF, отправляя подписанный VAPID-запрос во внутреннюю сеть.
|
|
func validatePushEndpoint(raw string) (string, error) {
|
|
trimmed := strings.TrimSpace(raw)
|
|
if trimmed == "" || len(trimmed) > maxPushEndpointLength {
|
|
return "", humaErrorStatus(http.StatusUnprocessableEntity, "push.invalid_endpoint",
|
|
"push endpoint is empty or too long")
|
|
}
|
|
if _, err := httpx.ValidatePublicURL(trimmed, false); err != nil {
|
|
return "", humaErrorStatus(http.StatusUnprocessableEntity, "push.invalid_endpoint",
|
|
"push endpoint must be a public https url")
|
|
}
|
|
return trimmed, nil
|
|
}
|
|
|
|
// validatePushKey проверяет ключ подписки: base64url, длину и — для p256dh —
|
|
// что это действительно точка P-256. Без второй проверки мусор от клиента
|
|
// доходил бы до шифрования и падал уже в очереди доставки.
|
|
func validatePushKey(name, value string, wantBytes int) error {
|
|
trimmed := strings.TrimSpace(value)
|
|
if trimmed == "" || len(trimmed) > maxPushKeyLength {
|
|
return humaErrorStatus(http.StatusUnprocessableEntity, "push.invalid_keys",
|
|
name+" key is missing or too long")
|
|
}
|
|
decoded, err := base64.RawURLEncoding.DecodeString(strings.TrimRight(trimmed, "="))
|
|
if err != nil || len(decoded) != wantBytes {
|
|
return humaErrorStatus(http.StatusUnprocessableEntity, "push.invalid_keys",
|
|
name+" key must be base64url of "+strconv.Itoa(wantBytes)+" bytes")
|
|
}
|
|
if name == "p256dh" {
|
|
if _, err := ecdsa.ParseUncompressedPublicKey(elliptic.P256(), decoded); err != nil {
|
|
return humaErrorStatus(http.StatusUnprocessableEntity, "push.invalid_keys",
|
|
"p256dh key is not a P-256 point")
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// pushLimitKey — ключ лимита подписок: 10 запросов в минуту на пользователя.
|
|
func pushLimitKey(userID uint64) string { return "user:" + formatSnowflake(userID) }
|
|
|
|
// truncate обрезает пользовательский текст до лимита (User-Agent устройства).
|
|
func truncate(value string, limit int) string {
|
|
trimmed := strings.TrimSpace(value)
|
|
runes := []rune(trimmed)
|
|
if len(runes) <= limit {
|
|
return trimmed
|
|
}
|
|
return string(runes[:limit])
|
|
}
|