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:
@@ -20,6 +20,7 @@ import (
|
||||
"glchat/internal/httpx"
|
||||
"glchat/internal/meta"
|
||||
"glchat/internal/permissions"
|
||||
"glchat/internal/push"
|
||||
"glchat/internal/source"
|
||||
"glchat/internal/store"
|
||||
"glchat/internal/sysinfo"
|
||||
@@ -71,6 +72,12 @@ type Server struct {
|
||||
// 10 запросов в минуту на IP — с запасом на пару begin/finish.
|
||||
passkeyLimiter *httpx.RateLimiter
|
||||
oauthLimiter *httpx.RateLimiter
|
||||
// pushLimiter ограничивает подписки на Web Push, pushNotifyLimiter — сами
|
||||
// уведомления на получателя (Фаза 7, AGENT.md 7.16, 8.6).
|
||||
pushLimiter *httpx.RateLimiter
|
||||
pushNotifyLimiter *httpx.RateLimiter
|
||||
// push отправляет Web Push; nil, если ключи не настроены.
|
||||
push pushSender
|
||||
// slowmode — время последней отправки в комнату для режима медленной
|
||||
// отправки; словарь ограничен по размеру (AGENT.md 7.5).
|
||||
slowmodeMu sync.Mutex
|
||||
@@ -130,9 +137,13 @@ func New(cfg config.Config, db *database.DB, logger *slog.Logger, deps Deps) *Se
|
||||
reactionLimiter: httpx.NewRateLimiter(20, 20),
|
||||
passkeyLimiter: httpx.NewRateLimiterWindow(10, time.Minute, 10),
|
||||
oauthLimiter: httpx.NewRateLimiterWindow(10, time.Minute, 10),
|
||||
slowmode: map[string]time.Time{},
|
||||
presence: map[uint64]time.Time{},
|
||||
webhookSeen: map[string]time.Time{},
|
||||
pushLimiter: httpx.NewRateLimiterWindow(10, time.Minute, 10),
|
||||
// Уведомления на получателя: 20 в минуту с запасом 20 — массовые
|
||||
// упоминания не должны заваливать устройство.
|
||||
pushNotifyLimiter: httpx.NewRateLimiter(20, 20),
|
||||
slowmode: map[string]time.Time{},
|
||||
presence: map[uint64]time.Time{},
|
||||
webhookSeen: map[string]time.Time{},
|
||||
// Дашборд опрашивает метрики раз в секунду: 120/мин с запасом.
|
||||
metricsLimiter: httpx.NewRateLimiter(120, 30),
|
||||
sysinfoPaths: sysinfo.DefaultPaths(),
|
||||
@@ -147,6 +158,16 @@ func New(cfg config.Config, db *database.DB, logger *slog.Logger, deps Deps) *Se
|
||||
case deps.Store != nil:
|
||||
s.perms = permissions.NewCalculator(source.New(deps.Store))
|
||||
}
|
||||
// Web Push (Фаза 7): неверный VAPID-ключ не должен мешать инстансу
|
||||
// подняться — пишем предупреждение и работаем без push.
|
||||
if deps.Store != nil {
|
||||
sender, err := push.New(cfg, deps.Store, logger)
|
||||
if err != nil {
|
||||
logger.Error("web push is disabled", slog.Any("error", err))
|
||||
} else {
|
||||
s.push = sender
|
||||
}
|
||||
}
|
||||
|
||||
router := chi.NewRouter()
|
||||
router.Route("/api/v1", func(apiRouter chi.Router) {
|
||||
@@ -182,6 +203,7 @@ func New(cfg config.Config, db *database.DB, logger *slog.Logger, deps Deps) *Se
|
||||
s.registerChannelBackgroundRoutes(apiRouter)
|
||||
s.registerInviteBackgroundRoutes(apiRouter)
|
||||
s.registerVoiceWebhook(apiRouter)
|
||||
s.registerPushRoutes(s.api)
|
||||
}
|
||||
apiRouter.Get("/openapi.json", s.handleOpenAPI)
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user