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:
2026-09-26 16:14:20 +03:00
parent f9464130cd
commit 7afd23d6d4
27 changed files with 3157 additions and 7 deletions
+25 -3
View File
@@ -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)
})