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:
@@ -73,6 +73,11 @@ type Config struct {
|
||||
// OAuthRedirectBase — внешний адрес инстанса для redirect_uri (пусто —
|
||||
// берётся BaseURL из домена). Нужен, если клиент ходит по другому адресу.
|
||||
OAuthRedirectBase string
|
||||
// Web Push (AGENT.md 7.16, Фаза 7): приватный VAPID-ключ (PKCS#8 DER в
|
||||
// base64, генерирует установщик) и контакт администратора для push-сервисов
|
||||
// (mailto:… или https://…). Пустой ключ — Web Push выключен.
|
||||
VAPIDPrivateKey string
|
||||
VAPIDSubject string
|
||||
}
|
||||
|
||||
func Load() (Config, error) {
|
||||
@@ -119,6 +124,9 @@ func Load() (Config, error) {
|
||||
OAuthDiscordClientID: env("OAUTH_DISCORD_CLIENT_ID", ""),
|
||||
OAuthDiscordClientSecret: env("OAUTH_DISCORD_CLIENT_SECRET", ""),
|
||||
OAuthRedirectBase: env("OAUTH_REDIRECT_BASE", ""),
|
||||
|
||||
VAPIDPrivateKey: env("VAPID_PRIVATE_KEY", ""),
|
||||
VAPIDSubject: env("VAPID_SUBJECT", ""),
|
||||
}
|
||||
if cfg.FilesDomain == "" {
|
||||
cfg.FilesDomain = "files." + cfg.Domain
|
||||
@@ -168,6 +176,10 @@ func (c Config) VoiceEnabled() bool {
|
||||
// OAuthEnabled сообщает, настроен ли хотя бы один OAuth-провайдер (AGENT.md 7.1).
|
||||
func (c Config) OAuthEnabled() bool { return len(c.OAuthProviders()) > 0 }
|
||||
|
||||
// WebPushEnabled сообщает, настроен ли Web Push: без VAPID-ключа подписки
|
||||
// браузеров принимать некуда (AGENT.md 7.16).
|
||||
func (c Config) WebPushEnabled() bool { return strings.TrimSpace(c.VAPIDPrivateKey) != "" }
|
||||
|
||||
// WebAuthnEnabled сообщает, годится ли домен инстанса как RP ID для passkeys.
|
||||
// WebAuthn не работает на IP-адресах (исключение — localhost), поэтому стенд
|
||||
// без домена честно отдаёт `passkeys_enabled: false`, а не ломает вход.
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
-- +goose Up
|
||||
-- Фаза 7: Web Push (AGENT.md 7.16).
|
||||
--
|
||||
-- Подписка браузера (Push API) живёт на устройстве: эндпоинт выдаёт push-сервис
|
||||
-- (FCM/Mozilla/Apple), ключи p256dh/auth шифруют payload по RFC 8291. Один
|
||||
-- пользователь может подписать несколько устройств, поэтому уникален эндпоинт,
|
||||
-- а не пользователь: повторная подписка с того же устройства обновляет запись.
|
||||
--
|
||||
-- Счётчик неудач и время последнего успеха нужны, чтобы чистить мёртвые
|
||||
-- подписки: push-сервис отвечает 404/410 на отозванные, а временные сбои
|
||||
-- (5xx, 429) не должны удалять запись сразу.
|
||||
|
||||
CREATE TABLE push_subscriptions (
|
||||
id INTEGER PRIMARY KEY,
|
||||
user_id INTEGER NOT NULL REFERENCES users (id) ON DELETE CASCADE,
|
||||
endpoint TEXT NOT NULL UNIQUE,
|
||||
p256dh TEXT NOT NULL,
|
||||
auth TEXT NOT NULL,
|
||||
user_agent TEXT NOT NULL DEFAULT '',
|
||||
created_at TEXT NOT NULL,
|
||||
last_success_at TEXT,
|
||||
last_failure_at TEXT,
|
||||
failure_count INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE INDEX push_subscriptions_user_idx ON push_subscriptions (user_id);
|
||||
|
||||
-- +goose Down
|
||||
DROP TABLE push_subscriptions;
|
||||
@@ -0,0 +1,104 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"context"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestPushSubscriptionsMigration проверяет миграцию 00021 на чистой базе:
|
||||
// таблица подписок Web Push, уникальность эндпоинта, внешний ключ на users и
|
||||
// индекс по пользователю (AGENT.md 7.16, Фаза 7).
|
||||
func TestPushSubscriptionsMigration(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
db, err := Open(ctx, Options{
|
||||
Path: filepath.Join(t.TempDir(), "glchat.db"),
|
||||
ReadPool: 2,
|
||||
Migrate: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Open() вернул ошибку: %v", err)
|
||||
}
|
||||
defer func() {
|
||||
if err := db.Close(); err != nil {
|
||||
t.Errorf("Close(): %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
version, err := db.SchemaVersion(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("SchemaVersion(): %v", err)
|
||||
}
|
||||
if version < 21 {
|
||||
t.Fatalf("schema_version = %d, ожидалось >= 21", version)
|
||||
}
|
||||
|
||||
columns := map[string]bool{}
|
||||
rows, err := db.Reader.QueryContext(ctx, `SELECT name FROM pragma_table_info('push_subscriptions')`)
|
||||
if err != nil {
|
||||
t.Fatalf("pragma_table_info: %v", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var name string
|
||||
if err := rows.Scan(&name); err != nil {
|
||||
t.Fatalf("scan: %v", err)
|
||||
}
|
||||
columns[name] = true
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
t.Fatalf("rows: %v", err)
|
||||
}
|
||||
for _, expected := range []string{
|
||||
"id", "user_id", "endpoint", "p256dh", "auth", "user_agent",
|
||||
"created_at", "last_success_at", "last_failure_at", "failure_count",
|
||||
} {
|
||||
if !columns[expected] {
|
||||
t.Errorf("в push_subscriptions нет колонки %q", expected)
|
||||
}
|
||||
}
|
||||
|
||||
var indexes int
|
||||
if err := db.Reader.QueryRowContext(ctx,
|
||||
`SELECT COUNT(*) FROM sqlite_master WHERE type = 'index' AND name = 'push_subscriptions_user_idx'`,
|
||||
).Scan(&indexes); err != nil {
|
||||
t.Fatalf("чтение индекса: %v", err)
|
||||
}
|
||||
if indexes != 1 {
|
||||
t.Errorf("индекс push_subscriptions_user_idx не создан")
|
||||
}
|
||||
|
||||
userID := int64(1000)
|
||||
if _, err := db.Writer.ExecContext(ctx,
|
||||
`INSERT INTO users (id, username, username_lower, display_name, email_enc, email_index,
|
||||
password_hash, locale, created_at, updated_at)
|
||||
VALUES (?, 'push', 'push', 'Push', 'enc', 'idx', 'hash', 'ru', '2026-01-01T00:00:00.000Z', '2026-01-01T00:00:00.000Z')`,
|
||||
userID); err != nil {
|
||||
t.Fatalf("вставка пользователя: %v", err)
|
||||
}
|
||||
insert := `INSERT INTO push_subscriptions (id, user_id, endpoint, p256dh, auth, created_at)
|
||||
VALUES (?, ?, ?, 'key', 'auth', '2026-01-01T00:00:00.000Z')`
|
||||
if _, err := db.Writer.ExecContext(ctx, insert, 1, userID, "https://push.example.com/one"); err != nil {
|
||||
t.Fatalf("вставка подписки: %v", err)
|
||||
}
|
||||
// Эндпоинт уникален: повторная подписка с того же устройства обновляет
|
||||
// запись, а не создаёт вторую.
|
||||
if _, err := db.Writer.ExecContext(ctx, insert, 2, userID, "https://push.example.com/one"); err == nil {
|
||||
t.Fatal("повторный эндпоинт принят, ожидалась ошибка уникальности")
|
||||
}
|
||||
// Внешний ключ: подписка без пользователя невозможна.
|
||||
if _, err := db.Writer.ExecContext(ctx, insert, 3, 999999, "https://push.example.com/two"); err == nil {
|
||||
t.Fatal("подписка без пользователя принята, ожидалась ошибка внешнего ключа")
|
||||
}
|
||||
// Удаление пользователя убирает его подписки.
|
||||
if _, err := db.Writer.ExecContext(ctx, `DELETE FROM users WHERE id = ?`, userID); err != nil {
|
||||
t.Fatalf("удаление пользователя: %v", err)
|
||||
}
|
||||
var remaining int
|
||||
if err := db.Reader.QueryRowContext(ctx, `SELECT COUNT(*) FROM push_subscriptions`).Scan(&remaining); err != nil {
|
||||
t.Fatalf("подсчёт подписок: %v", err)
|
||||
}
|
||||
if remaining != 0 {
|
||||
t.Errorf("после удаления пользователя осталось подписок: %d", remaining)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package gateway
|
||||
|
||||
// Присутствие в Gateway: кто прямо сейчас держит соединение. Нужно Web Push
|
||||
// (AGENT.md 7.16, Фаза 7): если у получателя есть активная сессия, уведомление
|
||||
// покажет сам клиент по своим правилам, и дублировать его на телефон не нужно.
|
||||
//
|
||||
// Файл отдельный, чтобы не трогать горячий gateway.go: логика читает те же
|
||||
// поля и под тем же мьютексом, что и рассылка.
|
||||
|
||||
// IsUserOnline сообщает, есть ли у пользователя хотя бы одно соединение.
|
||||
func (s *Service) IsUserOnline(userID uint64) bool {
|
||||
if s == nil || userID == 0 {
|
||||
return false
|
||||
}
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
for _, session := range s.sessions {
|
||||
if session.userID == userID {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// OnlineUsers возвращает количество уникальных пользователей с соединением —
|
||||
// используется в тестах и диагностике.
|
||||
func (s *Service) OnlineUsers() int {
|
||||
if s == nil {
|
||||
return 0
|
||||
}
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
seen := make(map[uint64]struct{}, len(s.sessions))
|
||||
for _, session := range s.sessions {
|
||||
seen[session.userID] = struct{}{}
|
||||
}
|
||||
return len(seen)
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
package httpx
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Проверки адресов для исходящих запросов (AGENT.md 9.2, 9.7): сервер сам
|
||||
// ходит в интернет только там, где это осознанно (Web Push, превью ссылок),
|
||||
// поэтому внутренние подсети должны быть закрыты и для IP-литералов, и для
|
||||
// имён, которые в них разрешаются (DNS rebinding).
|
||||
|
||||
var (
|
||||
// ErrAddressBlocked — адрес ведёт во внутреннюю сеть или не является IP.
|
||||
ErrAddressBlocked = errors.New("address is not allowed")
|
||||
// ErrURLUnsupported — схема, хост или формат URL недопустимы.
|
||||
ErrURLUnsupported = errors.New("url is not supported")
|
||||
)
|
||||
|
||||
// IsBlockedIP сообщает, что адрес нельзя запрашивать: loopback, приватные
|
||||
// диапазоны, link-local (включая метаданные облака 169.254.169.254),
|
||||
// multicast, unspecified и IPv4-mapped варианты приватных адресов.
|
||||
func IsBlockedIP(ip net.IP) bool {
|
||||
if ip == nil {
|
||||
return true
|
||||
}
|
||||
// IPv4-mapped (::ffff:10.0.0.1) разворачиваем в IPv4: иначе проверки
|
||||
// 4-байтовых диапазонов его не увидят.
|
||||
if v4 := ip.To4(); v4 != nil {
|
||||
ip = v4
|
||||
}
|
||||
if ip.IsLoopback() || ip.IsPrivate() || ip.IsLinkLocalUnicast() ||
|
||||
ip.IsLinkLocalMulticast() || ip.IsMulticast() || ip.IsUnspecified() {
|
||||
return true
|
||||
}
|
||||
// 0.0.0.0/8 и 100.64.0.0/10 (CGNAT), 192.0.0.0/24 — заведомо не публичные
|
||||
// адреса назначения.
|
||||
if v4 := ip.To4(); v4 != nil {
|
||||
return v4[0] == 0 ||
|
||||
(v4[0] == 100 && v4[1] >= 64 && v4[1] <= 127) ||
|
||||
(v4[0] == 192 && v4[1] == 0 && v4[2] == 0)
|
||||
}
|
||||
// IPv6: unique local (fc00::/7) и link-local уже покрыты стандартными
|
||||
// проверками, отдельно закрываем 2001:db8::/32 (диапазон документации).
|
||||
return len(ip) == net.IPv6len && ip[0] == 0x20 && ip[1] == 0x01 && ip[2] == 0x0d && ip[3] == 0xb8
|
||||
}
|
||||
|
||||
// SafeDialControl — функция для net.Dialer.Control: проверяет уже разрешённый
|
||||
// адрес в момент подключения, поэтому подмена DNS между проверкой и запросом
|
||||
// ничего не даёт.
|
||||
func SafeDialControl(_ string, address string, _ syscall.RawConn) error {
|
||||
host, _, err := net.SplitHostPort(address)
|
||||
if err != nil {
|
||||
host = address
|
||||
}
|
||||
ip := net.ParseIP(strings.Trim(host, "[]"))
|
||||
if ip == nil {
|
||||
return fmt.Errorf("%w: %q is not an ip address", ErrAddressBlocked, address)
|
||||
}
|
||||
if IsBlockedIP(ip) {
|
||||
return fmt.Errorf("%w: %s", ErrAddressBlocked, ip)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// SafeTransport собирает транспорт для исходящих запросов: без прокси из
|
||||
// окружения, с проверкой адреса на подключении, с таймаутами и без keep-alive
|
||||
// на чужих хостах.
|
||||
func SafeTransport(timeout time.Duration) *http.Transport {
|
||||
if timeout <= 0 {
|
||||
timeout = 5 * time.Second
|
||||
}
|
||||
return &http.Transport{
|
||||
Proxy: nil,
|
||||
DialContext: (&net.Dialer{
|
||||
Timeout: timeout,
|
||||
KeepAlive: 30 * time.Second,
|
||||
Control: SafeDialControl,
|
||||
}).DialContext,
|
||||
TLSHandshakeTimeout: timeout,
|
||||
ResponseHeaderTimeout: timeout,
|
||||
ExpectContinueTimeout: time.Second,
|
||||
MaxIdleConns: 4,
|
||||
MaxIdleConnsPerHost: 2,
|
||||
IdleConnTimeout: 30 * time.Second,
|
||||
ForceAttemptHTTP2: true,
|
||||
}
|
||||
}
|
||||
|
||||
// SafeClient возвращает HTTP-клиент с проверкой адресов и общим таймаутом.
|
||||
func SafeClient(timeout time.Duration) *http.Client {
|
||||
if timeout <= 0 {
|
||||
timeout = 5 * time.Second
|
||||
}
|
||||
return &http.Client{
|
||||
Transport: SafeTransport(timeout),
|
||||
Timeout: timeout,
|
||||
CheckRedirect: func(req *http.Request, via []*http.Request) error {
|
||||
if len(via) >= 3 {
|
||||
return fmt.Errorf("%w: too many redirects", ErrAddressBlocked)
|
||||
}
|
||||
// Каждый хоп проверяем теми же правилами, что и первый запрос:
|
||||
// редирект во внутреннюю сеть — обычный приём SSRF.
|
||||
if _, err := ValidatePublicURL(req.URL.String(), false); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// ValidatePublicURL проверяет URL для исходящего запроса: разрешены только
|
||||
// http/https (http — если allowHTTP), без userinfo и без литерального
|
||||
// внутреннего адреса. Имя хоста окончательно проверяется при подключении
|
||||
// (SafeDialControl): до резолва доверять ему нельзя.
|
||||
func ValidatePublicURL(raw string, allowHTTP bool) (*url.URL, error) {
|
||||
trimmed := strings.TrimSpace(raw)
|
||||
if trimmed == "" || len(trimmed) > 2048 {
|
||||
return nil, fmt.Errorf("%w: empty or too long", ErrURLUnsupported)
|
||||
}
|
||||
parsed, err := url.Parse(trimmed)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w: %w", ErrURLUnsupported, err)
|
||||
}
|
||||
scheme := strings.ToLower(parsed.Scheme)
|
||||
if scheme != "https" && (!allowHTTP || scheme != "http") {
|
||||
return nil, fmt.Errorf("%w: scheme %q", ErrURLUnsupported, parsed.Scheme)
|
||||
}
|
||||
if parsed.User != nil {
|
||||
return nil, fmt.Errorf("%w: userinfo is not allowed", ErrURLUnsupported)
|
||||
}
|
||||
host := parsed.Hostname()
|
||||
if host == "" {
|
||||
return nil, fmt.Errorf("%w: host is empty", ErrURLUnsupported)
|
||||
}
|
||||
if ip := net.ParseIP(host); ip != nil && IsBlockedIP(ip) {
|
||||
return nil, fmt.Errorf("%w: %s", ErrAddressBlocked, ip)
|
||||
}
|
||||
return parsed, nil
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
package httpx
|
||||
|
||||
import (
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TestIsBlockedIP покрывает адреса, по которым сервер не должен ходить сам
|
||||
// (AGENT.md 9.2, 9.7): внутренние сети, метаданные облака, multicast.
|
||||
func TestIsBlockedIP(t *testing.T) {
|
||||
blocked := []string{
|
||||
"127.0.0.1", "127.9.9.9", "::1",
|
||||
"10.0.0.1", "172.16.0.1", "172.31.255.255", "192.168.1.1",
|
||||
"169.254.169.254", "fe80::1",
|
||||
"0.0.0.0", "::", "0.1.2.3",
|
||||
"224.0.0.1", "ff02::1",
|
||||
"100.64.0.1", "100.127.255.255",
|
||||
"192.0.0.1",
|
||||
"fc00::1", "fd12:3456::1",
|
||||
"::ffff:10.0.0.1", "::ffff:127.0.0.1",
|
||||
"2001:db8::1",
|
||||
}
|
||||
for _, raw := range blocked {
|
||||
ip := net.ParseIP(raw)
|
||||
if ip == nil {
|
||||
t.Fatalf("не разобран адрес %q", raw)
|
||||
}
|
||||
if !IsBlockedIP(ip) {
|
||||
t.Errorf("IsBlockedIP(%s) = false, ожидалось true", raw)
|
||||
}
|
||||
}
|
||||
|
||||
allowed := []string{"8.8.8.8", "1.1.1.1", "138.16.226.159", "2606:4700::1111", "100.63.255.255"}
|
||||
for _, raw := range allowed {
|
||||
ip := net.ParseIP(raw)
|
||||
if ip == nil {
|
||||
t.Fatalf("не разобран адрес %q", raw)
|
||||
}
|
||||
if IsBlockedIP(ip) {
|
||||
t.Errorf("IsBlockedIP(%s) = true, ожидалось false", raw)
|
||||
}
|
||||
}
|
||||
|
||||
if !IsBlockedIP(nil) {
|
||||
t.Error("IsBlockedIP(nil) = false, ожидалось true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidatePublicURL(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
raw string
|
||||
allowHTTP bool
|
||||
wantErr bool
|
||||
}{
|
||||
{name: "https", raw: "https://example.com/page?a=1"},
|
||||
{name: "http запрещён", raw: "http://example.com/", wantErr: true},
|
||||
{name: "http разрешён флагом", raw: "http://example.com/", allowHTTP: true},
|
||||
{name: "file", raw: "file:///etc/passwd", wantErr: true},
|
||||
{name: "gopher", raw: "gopher://example.com/", wantErr: true},
|
||||
{name: "javascript", raw: "javascript:alert(1)", wantErr: true},
|
||||
{name: "userinfo", raw: "https://user:pass@example.com/", wantErr: true},
|
||||
{name: "пусто", raw: " ", wantErr: true},
|
||||
{name: "loopback", raw: "https://127.0.0.1/x", wantErr: true},
|
||||
{name: "метаданные", raw: "https://169.254.169.254/latest/meta-data/", wantErr: true},
|
||||
{name: "приватная сеть", raw: "https://10.1.2.3/", wantErr: true},
|
||||
{name: "ipv6 loopback", raw: "https://[::1]/", wantErr: true},
|
||||
{name: "слишком длинный", raw: "https://example.com/" + strings.Repeat("a", 2100), wantErr: true},
|
||||
}
|
||||
for _, testCase := range cases {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
parsed, err := ValidatePublicURL(testCase.raw, testCase.allowHTTP)
|
||||
if testCase.wantErr {
|
||||
if err == nil {
|
||||
t.Fatalf("ValidatePublicURL(%q) = %v, ожидалась ошибка", testCase.raw, parsed)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("ValidatePublicURL(%q) вернул ошибку: %v", testCase.raw, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestSafeDialControl проверяет, что проверка идёт по уже разрешённому адресу:
|
||||
// именно так закрывается DNS rebinding.
|
||||
func TestSafeDialControl(t *testing.T) {
|
||||
blocked := []string{"127.0.0.1:443", "[::1]:443", "169.254.169.254:80", "10.0.0.5:8080"}
|
||||
for _, address := range blocked {
|
||||
if err := SafeDialControl("tcp", address, nil); err == nil {
|
||||
t.Errorf("SafeDialControl пропустил %s", address)
|
||||
}
|
||||
}
|
||||
if err := SafeDialControl("tcp", "93.184.216.34:443", nil); err != nil {
|
||||
t.Fatalf("SafeDialControl отклонил публичный адрес: %v", err)
|
||||
}
|
||||
// Имя вместо адреса означает, что резолв не произошёл: доверять нельзя.
|
||||
if err := SafeDialControl("tcp", "example.com:443", nil); err == nil {
|
||||
t.Fatal("SafeDialControl принял неразрешённое имя хоста")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSafeClientBlocksUnsafeRedirects(t *testing.T) {
|
||||
client := SafeClient(time.Second)
|
||||
origin := &http.Request{URL: &url.URL{Scheme: "https", Host: "example.com"}}
|
||||
via := []*http.Request{origin}
|
||||
|
||||
redirect := &http.Request{URL: &url.URL{Scheme: "http", Host: "127.0.0.1:8080", Path: "/steal"}}
|
||||
if err := client.CheckRedirect(redirect, via); err == nil {
|
||||
t.Fatal("CheckRedirect пропустил переход на внутренний адрес")
|
||||
}
|
||||
// Схема http запрещена всегда: редирект не должен понижать защиту.
|
||||
redirect = &http.Request{URL: &url.URL{Scheme: "http", Host: "example.com"}}
|
||||
if err := client.CheckRedirect(redirect, via); err == nil {
|
||||
t.Fatal("CheckRedirect пропустил переход на http")
|
||||
}
|
||||
redirect = &http.Request{URL: &url.URL{Scheme: "https", Host: "example.com", Path: "/next"}}
|
||||
if err := client.CheckRedirect(redirect, via); err != nil {
|
||||
t.Fatalf("CheckRedirect отклонил обычный https-редирект: %v", err)
|
||||
}
|
||||
// Больше трёх переходов — отказ (защита от цепочек).
|
||||
many := []*http.Request{origin, origin, origin, origin}
|
||||
if err := client.CheckRedirect(redirect, many); err == nil {
|
||||
t.Fatal("CheckRedirect пропустил слишком длинную цепочку")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSafeTransportHasNoProxy(t *testing.T) {
|
||||
transport := SafeTransport(time.Second)
|
||||
if transport.Proxy != nil {
|
||||
t.Fatal("транспорт не должен использовать прокси из окружения")
|
||||
}
|
||||
if transport.DialContext == nil {
|
||||
t.Fatal("транспорт должен задавать DialContext с проверкой адреса")
|
||||
}
|
||||
}
|
||||
@@ -49,7 +49,7 @@ func New(cfg config.Config) Response {
|
||||
AntiBotEnabled: false,
|
||||
VoiceEnabled: cfg.VoiceEnabled(),
|
||||
VoiceURL: cfg.LiveKitURL,
|
||||
WebPushEnabled: false,
|
||||
WebPushEnabled: cfg.WebPushEnabled(),
|
||||
OAuthEnabled: cfg.OAuthEnabled(),
|
||||
PasskeysEnabled: cfg.WebAuthnEnabled(),
|
||||
OAuthProviders: cfg.OAuthProviders(),
|
||||
|
||||
@@ -0,0 +1,343 @@
|
||||
// Package push отправляет Web Push уведомления браузерам и телефонам
|
||||
// (AGENT.md 7.16, Фаза 7): VAPID-подпись, шифрование полезной нагрузки по
|
||||
// RFC 8291 и очередь доставки, чтобы HTTP-ручки не ждали push-сервисы.
|
||||
//
|
||||
// Решение «уведомлять или нет» принимает сервер (internal/push/rules.go) по
|
||||
// тем же правилам, что нативные уведомления desktop-обёртки
|
||||
// (web/src/lib/desktopNotifications.ts), а показывает уведомление service
|
||||
// worker клиента (web/public/sw.js).
|
||||
package push
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
"crypto/x509"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
webpush "github.com/SherClockHolmes/webpush-go"
|
||||
|
||||
"glchat/internal/config"
|
||||
"glchat/internal/httpx"
|
||||
"glchat/internal/store"
|
||||
)
|
||||
|
||||
const (
|
||||
// DefaultTTL — сколько push-сервис хранит недоставленное уведомление.
|
||||
DefaultTTL = 12 * time.Hour
|
||||
// MaxPayload — предел полезной нагрузки: запись RFC 8291 — 4096 байт,
|
||||
// оставляем запас на шифрование и служебные поля.
|
||||
MaxPayload = 3000
|
||||
// DeliverTimeout — сколько ждём push-сервис на одну доставку.
|
||||
DeliverTimeout = 10 * time.Second
|
||||
// MaxFailures — после стольких неудач подряд подписка удаляется.
|
||||
MaxFailures = 5
|
||||
// Воркеры и очередь: push не должен ни блокировать запросы, ни плодить
|
||||
// горутины на каждого получателя.
|
||||
workerCount = 4
|
||||
queueSize = 256
|
||||
)
|
||||
|
||||
// VAPIDSubject по умолчанию: push-сервисы требуют контакт администратора.
|
||||
const defaultSubjectPrefix = "mailto:admin@"
|
||||
|
||||
// Payload — то, что получает service worker (web/public/sw.js).
|
||||
type Payload struct {
|
||||
Title string `json:"title"`
|
||||
Body string `json:"body"`
|
||||
// Kind — "mention" или "direct": клиент по нему выбирает иконку и текст.
|
||||
Kind string `json:"kind"`
|
||||
GuildID string `json:"guild_id,omitempty"`
|
||||
GuildName string `json:"guild_name,omitempty"`
|
||||
ChannelID string `json:"channel_id"`
|
||||
ChannelName string `json:"channel_name,omitempty"`
|
||||
AuthorID string `json:"author_id,omitempty"`
|
||||
AuthorName string `json:"author_name,omitempty"`
|
||||
MessageID string `json:"message_id,omitempty"`
|
||||
// URL — путь, который открывает клик по уведомлению.
|
||||
URL string `json:"url"`
|
||||
// Tag — ключ схлопывания: уведомления одной комнаты заменяют друг друга.
|
||||
Tag string `json:"tag"`
|
||||
}
|
||||
|
||||
// Service — отправитель Web Push. Нулевое значение безопасно: методы
|
||||
// проверяют nil, поэтому «push выключен» не требует ветвлений у вызывающего.
|
||||
type Service struct {
|
||||
logger *slog.Logger
|
||||
store *store.Store
|
||||
publicKey string
|
||||
privateKey string
|
||||
subject string
|
||||
ttl int
|
||||
client webpush.HTTPClient
|
||||
|
||||
startOnce sync.Once
|
||||
queue chan delivery
|
||||
closed chan struct{}
|
||||
closeOnce sync.Once
|
||||
wg sync.WaitGroup
|
||||
}
|
||||
|
||||
type delivery struct {
|
||||
subscription store.PushSubscription
|
||||
payload Payload
|
||||
}
|
||||
|
||||
// New собирает сервис из конфига. Если VAPID-ключ не задан — возвращает nil
|
||||
// без ошибки: инстанс без push работает как обычно. Некорректный ключ —
|
||||
// ошибка: молча выключенный push выглядел бы как «уведомления не приходят».
|
||||
func New(cfg config.Config, st *store.Store, logger *slog.Logger) (*Service, error) {
|
||||
if logger == nil {
|
||||
logger = slog.New(slog.DiscardHandler)
|
||||
}
|
||||
if strings.TrimSpace(cfg.VAPIDPrivateKey) == "" {
|
||||
return nil, nil
|
||||
}
|
||||
privateKey, publicKey, err := ParseVAPIDKeys(cfg.VAPIDPrivateKey)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse VAPID key: %w", err)
|
||||
}
|
||||
subject := strings.TrimSpace(cfg.VAPIDSubject)
|
||||
if subject == "" {
|
||||
subject = defaultSubjectPrefix + cfg.Domain
|
||||
}
|
||||
return &Service{
|
||||
logger: logger,
|
||||
store: st,
|
||||
publicKey: publicKey,
|
||||
privateKey: privateKey,
|
||||
subject: subject,
|
||||
ttl: int(DefaultTTL.Seconds()),
|
||||
client: httpx.SafeClient(DeliverTimeout),
|
||||
queue: make(chan delivery, queueSize),
|
||||
closed: make(chan struct{}),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Enabled сообщает, настроен ли Web Push на инстансе.
|
||||
func (s *Service) Enabled() bool { return s != nil && s.privateKey != "" }
|
||||
|
||||
// PublicKey — публичный VAPID-ключ (base64url) для `applicationServerKey`.
|
||||
func (s *Service) PublicKey() string {
|
||||
if s == nil {
|
||||
return ""
|
||||
}
|
||||
return s.publicKey
|
||||
}
|
||||
|
||||
// Enqueue ставит доставку в очередь. Возвращает false, если push выключен или
|
||||
// очередь переполнена: уведомление теряется осознанно, но запрос не ждёт.
|
||||
func (s *Service) Enqueue(subscription store.PushSubscription, payload Payload) bool {
|
||||
if !s.Enabled() {
|
||||
return false
|
||||
}
|
||||
select {
|
||||
case <-s.closed:
|
||||
return false
|
||||
default:
|
||||
}
|
||||
s.start()
|
||||
select {
|
||||
case s.queue <- delivery{subscription: subscription, payload: payload}:
|
||||
return true
|
||||
default:
|
||||
s.logger.Warn("push queue is full, notification dropped",
|
||||
slog.String("user_id", formatID(subscription.UserID)))
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Close останавливает воркеры: вызывается при завершении процесса и в тестах.
|
||||
func (s *Service) Close() {
|
||||
if s == nil {
|
||||
return
|
||||
}
|
||||
s.closeOnce.Do(func() { close(s.closed) })
|
||||
s.wg.Wait()
|
||||
}
|
||||
|
||||
func (s *Service) start() {
|
||||
s.startOnce.Do(func() {
|
||||
for i := 0; i < workerCount; i++ {
|
||||
s.wg.Add(1)
|
||||
go s.work()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Service) work() {
|
||||
defer s.wg.Done()
|
||||
for {
|
||||
select {
|
||||
case <-s.closed:
|
||||
return
|
||||
case job := <-s.queue:
|
||||
s.deliver(context.Background(), job)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// deliver отправляет одно уведомление и обслуживает ответ push-сервиса:
|
||||
// успех отмечается, 404/410 удаляют подписку, остальные ошибки копят счётчик.
|
||||
func (s *Service) deliver(ctx context.Context, job delivery) {
|
||||
payload, err := json.Marshal(job.payload)
|
||||
if err != nil {
|
||||
s.logger.WarnContext(ctx, "push payload marshal failed", slog.Any("error", err))
|
||||
return
|
||||
}
|
||||
if len(payload) > MaxPayload {
|
||||
s.logger.WarnContext(ctx, "push payload is too large",
|
||||
slog.Int("bytes", len(payload)), slog.String("user_id", formatID(job.subscription.UserID)))
|
||||
return
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(ctx, DeliverTimeout)
|
||||
defer cancel()
|
||||
|
||||
response, err := webpush.SendNotificationWithContext(ctx, payload, &webpush.Subscription{
|
||||
Endpoint: job.subscription.Endpoint,
|
||||
Keys: webpush.Keys{
|
||||
P256dh: job.subscription.P256dh,
|
||||
Auth: job.subscription.Auth,
|
||||
},
|
||||
}, &webpush.Options{
|
||||
HTTPClient: s.client,
|
||||
Subscriber: s.subject,
|
||||
TTL: s.ttl,
|
||||
Urgency: webpush.UrgencyNormal,
|
||||
Topic: topicFor(job.payload),
|
||||
VAPIDPublicKey: s.publicKey,
|
||||
VAPIDPrivateKey: s.privateKey,
|
||||
})
|
||||
if err != nil {
|
||||
s.noteFailure(ctx, job.subscription, 0, err)
|
||||
return
|
||||
}
|
||||
defer func() { _ = response.Body.Close() }()
|
||||
|
||||
switch {
|
||||
case response.StatusCode >= 200 && response.StatusCode < 300:
|
||||
if err := s.store.TouchPushSubscription(ctx, job.subscription.ID); err != nil {
|
||||
s.logger.WarnContext(ctx, "push subscription touch failed", slog.Any("error", err))
|
||||
}
|
||||
case response.StatusCode == http.StatusNotFound || response.StatusCode == http.StatusGone:
|
||||
// Подписка отозвана браузером или push-сервисом — хранить нечего.
|
||||
if err := s.store.DeletePushSubscriptionByID(ctx, job.subscription.ID); err != nil {
|
||||
s.logger.WarnContext(ctx, "push subscription delete failed", slog.Any("error", err))
|
||||
}
|
||||
default:
|
||||
s.noteFailure(ctx, job.subscription, response.StatusCode, nil)
|
||||
}
|
||||
}
|
||||
|
||||
// noteFailure копит неудачи и убирает подписку, которая не работает подряд.
|
||||
func (s *Service) noteFailure(ctx context.Context, subscription store.PushSubscription, status int, cause error) {
|
||||
count, err := s.store.FailPushSubscription(ctx, subscription.ID)
|
||||
if err != nil {
|
||||
s.logger.WarnContext(ctx, "push subscription failure note failed", slog.Any("error", err))
|
||||
return
|
||||
}
|
||||
attributes := []any{
|
||||
slog.String("user_id", formatID(subscription.UserID)),
|
||||
slog.Int("status", status),
|
||||
slog.Int("failures", count),
|
||||
}
|
||||
if cause != nil {
|
||||
attributes = append(attributes, slog.Any("error", cause))
|
||||
}
|
||||
s.logger.WarnContext(ctx, "web push delivery failed", attributes...)
|
||||
if count >= MaxFailures {
|
||||
if err := s.store.DeletePushSubscriptionByID(ctx, subscription.ID); err != nil {
|
||||
s.logger.WarnContext(ctx, "push subscription delete failed", slog.Any("error", err))
|
||||
return
|
||||
}
|
||||
s.logger.InfoContext(ctx, "stale push subscription removed",
|
||||
slog.String("user_id", formatID(subscription.UserID)))
|
||||
}
|
||||
}
|
||||
|
||||
// topicFor схлопывает уведомления одной комнаты: свежее заменяет предыдущее,
|
||||
// иначе упоминания в активной переписке заваливают телефон.
|
||||
func topicFor(payload Payload) string {
|
||||
if payload.ChannelID == "" {
|
||||
return ""
|
||||
}
|
||||
return "channel-" + payload.ChannelID
|
||||
}
|
||||
|
||||
// ParseVAPIDKeys принимает приватный ключ в двух видах и возвращает пару в
|
||||
// формате, которого ждёт web-push (base64url без выравнивания):
|
||||
// - PKCS#8 DER в base64 — так ключ генерирует установщик (`openssl pkcs8`);
|
||||
// - «сырой» 32-байтовый скаляр в base64url — вывод GenerateVAPIDKeys().
|
||||
//
|
||||
// Публичный ключ всегда выводится из приватного: хранить его отдельно
|
||||
// незачем, а рассинхронизация пары ломала бы подписку молча.
|
||||
func ParseVAPIDKeys(encoded string) (privateKey, publicKey string, err error) {
|
||||
trimmed := strings.TrimSpace(encoded)
|
||||
raw, decodeErr := decodeBase64(trimmed)
|
||||
if decodeErr != nil {
|
||||
return "", "", fmt.Errorf("decode: %w", decodeErr)
|
||||
}
|
||||
var key *ecdsa.PrivateKey
|
||||
switch {
|
||||
case len(raw) == 32:
|
||||
// «Сырой» скаляр: так ключ печатает GenerateVAPIDKeys().
|
||||
parsed, parseErr := ecdsa.ParseRawPrivateKey(elliptic.P256(), raw)
|
||||
if parseErr != nil {
|
||||
return "", "", fmt.Errorf("parse raw private key: %w", parseErr)
|
||||
}
|
||||
key = parsed
|
||||
default:
|
||||
parsed, parseErr := x509.ParsePKCS8PrivateKey(raw)
|
||||
if parseErr != nil {
|
||||
return "", "", fmt.Errorf("parse PKCS#8: %w", parseErr)
|
||||
}
|
||||
ecdsaKey, ok := parsed.(*ecdsa.PrivateKey)
|
||||
if !ok {
|
||||
return "", "", errors.New("key is not an ECDSA private key")
|
||||
}
|
||||
if ecdsaKey.Curve != elliptic.P256() {
|
||||
return "", "", fmt.Errorf("curve %s is not P-256", ecdsaKey.Curve.Params().Name)
|
||||
}
|
||||
key = ecdsaKey
|
||||
}
|
||||
// Bytes() у ecdsa отдаёт фиксированную длину и несжатую точку (SEC 1):
|
||||
// именно в таком виде ключи ждёт web-push, и никаких big.Int снаружи.
|
||||
scalar, err := key.Bytes()
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("encode private key: %w", err)
|
||||
}
|
||||
point, err := key.PublicKey.Bytes()
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("encode public key: %w", err)
|
||||
}
|
||||
return base64.RawURLEncoding.EncodeToString(scalar),
|
||||
base64.RawURLEncoding.EncodeToString(point), nil
|
||||
}
|
||||
|
||||
// decodeBase64 принимает base64 (standard/raw/url) — установщик пишет
|
||||
// стандартный base64, а ключи из библиотек приходят в base64url.
|
||||
func decodeBase64(value string) ([]byte, error) {
|
||||
encodings := []*base64.Encoding{
|
||||
base64.StdEncoding, base64.RawStdEncoding,
|
||||
base64.URLEncoding, base64.RawURLEncoding,
|
||||
}
|
||||
var lastErr error
|
||||
for _, encoding := range encodings {
|
||||
decoded, err := encoding.DecodeString(value)
|
||||
if err == nil {
|
||||
return decoded, nil
|
||||
}
|
||||
lastErr = err
|
||||
}
|
||||
return nil, lastErr
|
||||
}
|
||||
|
||||
func formatID(id uint64) string { return fmt.Sprintf("%d", id) }
|
||||
@@ -0,0 +1,369 @@
|
||||
package push
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
"crypto/rand"
|
||||
"crypto/x509"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
webpush "github.com/SherClockHolmes/webpush-go"
|
||||
|
||||
"glchat/internal/config"
|
||||
"glchat/internal/database"
|
||||
"glchat/internal/store"
|
||||
)
|
||||
|
||||
// newPushTestStore открывает временную БД с миграциями и создаёт пользователя:
|
||||
// подписки ссылаются на users по внешнему ключу.
|
||||
func newPushTestStore(t *testing.T) (*store.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 := store.New(db)
|
||||
user, err := st.CreateUser(ctx, store.CreateUserParams{
|
||||
Username: "push_user",
|
||||
DisplayName: "Push",
|
||||
EmailEnc: "enc",
|
||||
EmailIndex: "idx",
|
||||
PasswordHash: "hash",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("создать пользователя: %v", err)
|
||||
}
|
||||
return st, user.ID
|
||||
}
|
||||
|
||||
// testVAPIDKey возвращает приватный ключ в том же виде, в каком его пишет
|
||||
// установщик: PKCS#8 DER в base64 (одной строкой).
|
||||
func testVAPIDKey(t *testing.T) string {
|
||||
t.Helper()
|
||||
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
if err != nil {
|
||||
t.Fatalf("сгенерировать ключ: %v", err)
|
||||
}
|
||||
der, err := x509.MarshalPKCS8PrivateKey(key)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal PKCS#8: %v", err)
|
||||
}
|
||||
return base64.StdEncoding.EncodeToString(der)
|
||||
}
|
||||
|
||||
func testConfig(t *testing.T) config.Config {
|
||||
return config.Config{
|
||||
Domain: "gl.example.com",
|
||||
VAPIDPrivateKey: testVAPIDKey(t),
|
||||
VAPIDSubject: "mailto:admin@example.com",
|
||||
}
|
||||
}
|
||||
|
||||
// testSubscription сохраняет в БД подписку с указанным эндпоинтом. Ключи
|
||||
// настоящие (P-256 и 16 байт auth): иначе шифрование RFC 8291 не соберётся.
|
||||
func testSubscription(t *testing.T, st *store.Store, userID uint64, endpoint string) store.PushSubscription {
|
||||
t.Helper()
|
||||
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
if err != nil {
|
||||
t.Fatalf("сгенерировать ключ подписки: %v", err)
|
||||
}
|
||||
point, err := key.PublicKey.Bytes()
|
||||
if err != nil {
|
||||
t.Fatalf("публичный ключ подписки: %v", err)
|
||||
}
|
||||
auth := make([]byte, 16)
|
||||
if _, err := rand.Read(auth); err != nil {
|
||||
t.Fatalf("auth: %v", err)
|
||||
}
|
||||
subscription, err := st.SavePushSubscription(context.Background(), store.SavePushSubscriptionParams{
|
||||
UserID: userID,
|
||||
Endpoint: endpoint,
|
||||
P256dh: base64.RawURLEncoding.EncodeToString(point),
|
||||
Auth: base64.RawURLEncoding.EncodeToString(auth),
|
||||
UserAgent: "test",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("сохранить подписку: %v", err)
|
||||
}
|
||||
return *subscription
|
||||
}
|
||||
|
||||
func TestNewDisabledWithoutKey(t *testing.T) {
|
||||
st, _ := newPushTestStore(t)
|
||||
service, err := New(config.Config{Domain: "example.com"}, st, slog.New(slog.DiscardHandler))
|
||||
if err != nil {
|
||||
t.Fatalf("New без ключа вернул ошибку: %v", err)
|
||||
}
|
||||
if service != nil {
|
||||
t.Fatal("без VAPID-ключа сервис должен быть nil")
|
||||
}
|
||||
if service.Enabled() {
|
||||
t.Fatal("nil-сервис не может быть включён")
|
||||
}
|
||||
if service.PublicKey() != "" {
|
||||
t.Fatal("у выключенного сервиса нет публичного ключа")
|
||||
}
|
||||
if service.Enqueue(store.PushSubscription{}, Payload{}) {
|
||||
t.Fatal("выключенный сервис не должен принимать доставку")
|
||||
}
|
||||
service.Close()
|
||||
}
|
||||
|
||||
func TestNewRejectsBrokenKey(t *testing.T) {
|
||||
st, _ := newPushTestStore(t)
|
||||
if _, err := New(config.Config{VAPIDPrivateKey: "not-a-key"}, st, nil); err == nil {
|
||||
t.Fatal("некорректный ключ должен давать ошибку, а не тихо выключать push")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseVAPIDKeysAcceptsBothFormats(t *testing.T) {
|
||||
// Формат библиотеки: «сырой» скаляр в base64url.
|
||||
rawPrivate, rawPublic, err := webpush.GenerateVAPIDKeys()
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateVAPIDKeys: %v", err)
|
||||
}
|
||||
privateKey, publicKey, err := ParseVAPIDKeys(rawPrivate)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseVAPIDKeys(raw): %v", err)
|
||||
}
|
||||
if privateKey != rawPrivate || publicKey != rawPublic {
|
||||
t.Fatalf("пары не совпали: %s/%s против %s/%s", privateKey, publicKey, rawPrivate, rawPublic)
|
||||
}
|
||||
|
||||
// Формат установщика: PKCS#8 DER в base64.
|
||||
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateKey: %v", err)
|
||||
}
|
||||
der, err := x509.MarshalPKCS8PrivateKey(key)
|
||||
if err != nil {
|
||||
t.Fatalf("MarshalPKCS8PrivateKey: %v", err)
|
||||
}
|
||||
privateKey, publicKey, err = ParseVAPIDKeys(base64.StdEncoding.EncodeToString(der))
|
||||
if err != nil {
|
||||
t.Fatalf("ParseVAPIDKeys(pkcs8): %v", err)
|
||||
}
|
||||
decodedPublic, err := base64.RawURLEncoding.DecodeString(publicKey)
|
||||
if err != nil {
|
||||
t.Fatalf("публичный ключ не base64url: %v", err)
|
||||
}
|
||||
expectedPublic, err := key.PublicKey.Bytes()
|
||||
if err != nil {
|
||||
t.Fatalf("публичный ключ ключа: %v", err)
|
||||
}
|
||||
if !bytes.Equal(decodedPublic, expectedPublic) {
|
||||
t.Fatalf("публичный ключ не совпал с приватным: %d байт", len(decodedPublic))
|
||||
}
|
||||
expectedPrivate, err := key.Bytes()
|
||||
if err != nil {
|
||||
t.Fatalf("приватный ключ: %v", err)
|
||||
}
|
||||
decodedPrivate, err := base64.RawURLEncoding.DecodeString(privateKey)
|
||||
if err != nil {
|
||||
t.Fatalf("приватный ключ не base64url: %v", err)
|
||||
}
|
||||
if !bytes.Equal(decodedPrivate, expectedPrivate) {
|
||||
t.Fatal("приватный ключ не совпал с исходным")
|
||||
}
|
||||
|
||||
if _, _, err := ParseVAPIDKeys("!!!not base64!!!"); err == nil {
|
||||
t.Fatal("мусор должен отвергаться")
|
||||
}
|
||||
if _, _, err := ParseVAPIDKeys(base64.StdEncoding.EncodeToString([]byte("слишком короткий ключ"))); err == nil {
|
||||
t.Fatal("неверная длина ключа должна отвергаться")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeliverSuccessMarksSubscription(t *testing.T) {
|
||||
st, userID := newPushTestStore(t)
|
||||
var (
|
||||
mu sync.Mutex
|
||||
seen http.Header
|
||||
method string
|
||||
)
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
mu.Lock()
|
||||
seen = r.Header.Clone()
|
||||
method = r.Method
|
||||
mu.Unlock()
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
service, err := New(testConfig(t), st, slog.New(slog.DiscardHandler))
|
||||
if err != nil {
|
||||
t.Fatalf("New: %v", err)
|
||||
}
|
||||
// Эндпоинт тестовый (http + 127.0.0.1), поэтому клиент подменяем: в бою
|
||||
// адрес проверяет httpx.SafeClient.
|
||||
service.client = server.Client()
|
||||
subscription := testSubscription(t, st, userID, server.URL)
|
||||
|
||||
service.deliver(context.Background(), delivery{
|
||||
subscription: subscription,
|
||||
payload: Payload{Title: "Упоминание", Body: "привет", ChannelID: "10", Tag: "channel-10"},
|
||||
})
|
||||
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
if method != http.MethodPost {
|
||||
t.Fatalf("метод = %q, ожидался POST", method)
|
||||
}
|
||||
if seen.Get("Content-Encoding") != "aes128gcm" {
|
||||
t.Fatalf("Content-Encoding = %q", seen.Get("Content-Encoding"))
|
||||
}
|
||||
if seen.Get("TTL") != "43200" {
|
||||
t.Fatalf("TTL = %q, ожидалось 43200", seen.Get("TTL"))
|
||||
}
|
||||
if seen.Get("Topic") != "channel-10" {
|
||||
t.Fatalf("Topic = %q", seen.Get("Topic"))
|
||||
}
|
||||
if !strings.HasPrefix(seen.Get("Authorization"), "vapid t=") {
|
||||
t.Fatalf("нет VAPID-подписи: %q", seen.Get("Authorization"))
|
||||
}
|
||||
|
||||
updated, err := st.GetPushSubscriptionByEndpoint(context.Background(), server.URL)
|
||||
if err != nil {
|
||||
t.Fatalf("прочитать подписку: %v", err)
|
||||
}
|
||||
if updated.LastSuccessAt == nil {
|
||||
t.Fatal("успешная доставка не отмечена")
|
||||
}
|
||||
if updated.FailureCount != 0 {
|
||||
t.Fatalf("счётчик неудач = %d, ожидался 0", updated.FailureCount)
|
||||
}
|
||||
service.Close()
|
||||
}
|
||||
|
||||
func TestDeliverRemovesRevokedSubscription(t *testing.T) {
|
||||
for _, status := range []int{http.StatusNotFound, http.StatusGone} {
|
||||
st, userID := newPushTestStore(t)
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(status)
|
||||
}))
|
||||
service, err := New(testConfig(t), st, slog.New(slog.DiscardHandler))
|
||||
if err != nil {
|
||||
t.Fatalf("New: %v", err)
|
||||
}
|
||||
service.client = server.Client()
|
||||
subscription := testSubscription(t, st, userID, server.URL)
|
||||
|
||||
service.deliver(context.Background(), delivery{subscription: subscription, payload: Payload{Title: "x"}})
|
||||
|
||||
if _, err := st.GetPushSubscriptionByEndpoint(context.Background(), server.URL); err == nil {
|
||||
t.Fatalf("подписка с ответом %d должна быть удалена", status)
|
||||
}
|
||||
service.Close()
|
||||
server.Close()
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeliverDropsSubscriptionAfterRepeatedFailures(t *testing.T) {
|
||||
st, userID := newPushTestStore(t)
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
service, err := New(testConfig(t), st, slog.New(slog.DiscardHandler))
|
||||
if err != nil {
|
||||
t.Fatalf("New: %v", err)
|
||||
}
|
||||
service.client = server.Client()
|
||||
subscription := testSubscription(t, st, userID, server.URL)
|
||||
|
||||
for i := 0; i < MaxFailures; i++ {
|
||||
service.deliver(context.Background(), delivery{subscription: subscription, payload: Payload{Title: "x"}})
|
||||
}
|
||||
if _, err := st.GetPushSubscriptionByEndpoint(context.Background(), server.URL); err == nil {
|
||||
t.Fatalf("после %d неудач подписка должна быть удалена", MaxFailures)
|
||||
}
|
||||
service.Close()
|
||||
}
|
||||
|
||||
// Очередь не блокирует запрос: Enqueue возвращает управление сразу, а воркеры
|
||||
// доставляют уведомление в фоне.
|
||||
func TestEnqueueDeliversInBackground(t *testing.T) {
|
||||
st, userID := newPushTestStore(t)
|
||||
delivered := make(chan struct{}, 1)
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
select {
|
||||
case delivered <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
service, err := New(testConfig(t), st, slog.New(slog.DiscardHandler))
|
||||
if err != nil {
|
||||
t.Fatalf("New: %v", err)
|
||||
}
|
||||
service.client = server.Client()
|
||||
subscription := testSubscription(t, st, userID, server.URL)
|
||||
if !service.Enqueue(subscription, Payload{Title: "Упоминание", Body: "привет", ChannelID: "10"}) {
|
||||
t.Fatal("Enqueue отказал при пустой очереди")
|
||||
}
|
||||
select {
|
||||
case <-delivered:
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("уведомление не доставлено за 5 секунд")
|
||||
}
|
||||
service.Close()
|
||||
}
|
||||
|
||||
// Публичный ключ из конфига годится для applicationServerKey браузера.
|
||||
func TestPublicKeyIsBase64URLPoint(t *testing.T) {
|
||||
st, _ := newPushTestStore(t)
|
||||
service, err := New(testConfig(t), st, slog.New(slog.DiscardHandler))
|
||||
if err != nil {
|
||||
t.Fatalf("New: %v", err)
|
||||
}
|
||||
decoded, err := base64.RawURLEncoding.DecodeString(service.PublicKey())
|
||||
if err != nil {
|
||||
t.Fatalf("публичный ключ не base64url: %v", err)
|
||||
}
|
||||
if len(decoded) != 65 {
|
||||
t.Fatalf("длина публичного ключа = %d, ожидалось 65", len(decoded))
|
||||
}
|
||||
service.Close()
|
||||
}
|
||||
|
||||
// Полезная нагрузка сериализуется в JSON с полями, которые читает service
|
||||
// worker клиента (web/public/sw.js).
|
||||
func TestPayloadJSONShape(t *testing.T) {
|
||||
payload := Payload{
|
||||
Title: "Упоминание в #общий", Body: "привет", Kind: "mention",
|
||||
GuildID: "1", ChannelID: "10", MessageID: "99", Tag: "channel-10", URL: "/app/1/10",
|
||||
}
|
||||
encoded, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal: %v", err)
|
||||
}
|
||||
for _, field := range []string{`"title"`, `"body"`, `"kind"`, `"channel_id"`, `"tag"`, `"url"`} {
|
||||
if !strings.Contains(string(encoded), field) {
|
||||
t.Errorf("в payload нет поля %s: %s", field, encoded)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
package push
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"unicode"
|
||||
|
||||
"glchat/internal/store"
|
||||
)
|
||||
|
||||
// Правила уведомлений: сервер повторяет логику desktop-уведомлений клиента
|
||||
// (web/src/lib/desktopNotifications.ts, docs/client-tauri.md §5), потому что
|
||||
// push уходит на устройство, где приложение не запущено и решить не может.
|
||||
//
|
||||
// Отличия от клиента ровно два и оба вынужденные:
|
||||
// - «окно в фокусе и комната открыта» сервер не знает, поэтому вместо этого
|
||||
// проверяется наличие активного соединения шлюза: если оно есть,
|
||||
// уведомление покажет сам клиент по своим правилам;
|
||||
// - «уведомления включены» на устройстве — это наличие подписки: отписался,
|
||||
// значит выключил.
|
||||
|
||||
// previewLimit — сколько символов сообщения показываем в уведомлении.
|
||||
const previewLimit = 140
|
||||
|
||||
// NotificationInput — данные, от которых зависит решение об уведомлении.
|
||||
type NotificationInput struct {
|
||||
// MessageType — тип сообщения (AGENT.md 7.6).
|
||||
MessageType store.MessageType
|
||||
Content string
|
||||
Mentions []uint64
|
||||
// AuthorID — автор сообщения (0 у системных сообщений и вебхуков).
|
||||
AuthorID uint64
|
||||
// MeID — получатель: свои сообщения не уведомляют.
|
||||
MeID uint64
|
||||
// IsDirect — личная беседа (в т.ч. групповая).
|
||||
IsDirect bool
|
||||
// ChannelName — имя комнаты для заголовка (у беседы — имя или пусто).
|
||||
ChannelName string
|
||||
// ChannelID — комната сообщения: по ней строится ссылка и ключ схлопывания.
|
||||
ChannelID uint64
|
||||
// MessageID — сообщение: клик открывает комнату и подсвечивает его.
|
||||
MessageID uint64
|
||||
// GuildID — сервер комнаты (0 — личная беседа).
|
||||
GuildID uint64
|
||||
// GuildName — имя сервера для текста уведомления.
|
||||
GuildName string
|
||||
// AuthorName — отображаемое имя автора.
|
||||
AuthorName string
|
||||
// Online — у получателя есть активное соединение шлюза.
|
||||
Online bool
|
||||
}
|
||||
|
||||
// NotificationFor возвращает уведомление или nil, если показывать не нужно.
|
||||
// Чистая функция: её поведение проверяется тестами без сети.
|
||||
func NotificationFor(input NotificationInput) *Payload {
|
||||
// Системные сообщения (входы, баны) уведомлений не требуют.
|
||||
if input.MessageType == store.MessageSystem {
|
||||
return nil
|
||||
}
|
||||
if input.AuthorID != 0 && input.AuthorID == input.MeID {
|
||||
return nil
|
||||
}
|
||||
mentionsMe := false
|
||||
for _, mentioned := range input.Mentions {
|
||||
if mentioned == input.MeID {
|
||||
mentionsMe = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !mentionsMe && !input.IsDirect {
|
||||
return nil
|
||||
}
|
||||
// Получатель в приложении: уведомление покажет клиент (и только если окно
|
||||
// неактивно) — второй раз с телефона дёргать не нужно.
|
||||
if input.Online {
|
||||
return nil
|
||||
}
|
||||
body := previewText(input.Content)
|
||||
if body == "" && len(input.Mentions) == 0 {
|
||||
return nil
|
||||
}
|
||||
if body == "" {
|
||||
// Вложение без текста: так же, как в клиенте.
|
||||
body = "Вложение"
|
||||
}
|
||||
kind := "mention"
|
||||
title := "Упоминание"
|
||||
if input.IsDirect {
|
||||
kind = "direct"
|
||||
title = "Личное сообщение"
|
||||
if input.ChannelName != "" {
|
||||
title = "Личное сообщение — " + input.ChannelName
|
||||
}
|
||||
} else if input.ChannelName != "" {
|
||||
title = "Упоминание в #" + input.ChannelName
|
||||
}
|
||||
channel := formatOptionalID(input.ChannelID)
|
||||
tag := ""
|
||||
url := "/app"
|
||||
switch {
|
||||
case input.GuildID != 0 && input.ChannelID != 0:
|
||||
url = "/app/" + formatID(input.GuildID) + "/" + channel
|
||||
tag = "channel-" + channel
|
||||
case input.ChannelID != 0:
|
||||
// Личная беседа: раздел друзей выбирает беседу по идентификатору.
|
||||
url = "/app/friends/" + channel
|
||||
tag = "channel-" + channel
|
||||
}
|
||||
return &Payload{
|
||||
Title: title,
|
||||
Body: body,
|
||||
Kind: kind,
|
||||
GuildID: formatOptionalID(input.GuildID),
|
||||
GuildName: input.GuildName,
|
||||
ChannelID: channel,
|
||||
ChannelName: input.ChannelName,
|
||||
AuthorID: formatOptionalID(input.AuthorID),
|
||||
AuthorName: input.AuthorName,
|
||||
MessageID: formatOptionalID(input.MessageID),
|
||||
URL: url,
|
||||
Tag: tag,
|
||||
}
|
||||
}
|
||||
|
||||
// previewText убирает Markdown-разметку и лишние переводы строк — та же
|
||||
// подготовка, что в web/src/lib/desktopNotifications.ts.
|
||||
func previewText(content string) string {
|
||||
plain := stripCodeBlocks(content)
|
||||
plain = stripInlineCode(plain)
|
||||
plain = stripLinks(plain)
|
||||
plain = strings.Map(func(r rune) rune {
|
||||
if strings.ContainsRune("*_~>#", r) {
|
||||
return -1
|
||||
}
|
||||
return r
|
||||
}, plain)
|
||||
plain = collapseSpaces(plain)
|
||||
runes := []rune(plain)
|
||||
if len(runes) <= previewLimit {
|
||||
return plain
|
||||
}
|
||||
return strings.TrimRight(string(runes[:previewLimit-1]), " ") + "…"
|
||||
}
|
||||
|
||||
// stripCodeBlocks убирает блоки ```…``` целиком, оставляя пометку.
|
||||
func stripCodeBlocks(content string) string {
|
||||
var builder strings.Builder
|
||||
rest := content
|
||||
for {
|
||||
start := strings.Index(rest, "```")
|
||||
if start < 0 {
|
||||
builder.WriteString(rest)
|
||||
return builder.String()
|
||||
}
|
||||
builder.WriteString(rest[:start])
|
||||
builder.WriteString(" [код] ")
|
||||
end := strings.Index(rest[start+3:], "```")
|
||||
if end < 0 {
|
||||
return builder.String()
|
||||
}
|
||||
rest = rest[start+3+end+3:]
|
||||
}
|
||||
}
|
||||
|
||||
// stripInlineCode убирает обратные кавычки, оставляя их содержимое.
|
||||
func stripInlineCode(content string) string {
|
||||
var builder strings.Builder
|
||||
for {
|
||||
start := strings.Index(content, "`")
|
||||
if start < 0 {
|
||||
builder.WriteString(content)
|
||||
return builder.String()
|
||||
}
|
||||
builder.WriteString(content[:start])
|
||||
rest := content[start+1:]
|
||||
end := strings.Index(rest, "`")
|
||||
if end < 0 {
|
||||
builder.WriteString(rest)
|
||||
return builder.String()
|
||||
}
|
||||
builder.WriteString(rest[:end])
|
||||
content = rest[end+1:]
|
||||
}
|
||||
}
|
||||
|
||||
// stripLinks оставляет от ссылки её текст: [текст](url) → текст.
|
||||
func stripLinks(content string) string {
|
||||
var builder strings.Builder
|
||||
rest := content
|
||||
for {
|
||||
open := strings.Index(rest, "](")
|
||||
if open < 0 {
|
||||
builder.WriteString(rest)
|
||||
return builder.String()
|
||||
}
|
||||
start := strings.LastIndex(rest[:open], "[")
|
||||
if start < 0 {
|
||||
builder.WriteString(rest[:open+2])
|
||||
rest = rest[open+2:]
|
||||
continue
|
||||
}
|
||||
builder.WriteString(rest[:start])
|
||||
builder.WriteString(rest[start+1 : open])
|
||||
end := strings.Index(rest[open+2:], ")")
|
||||
if end < 0 {
|
||||
return builder.String()
|
||||
}
|
||||
rest = rest[open+2+end+1:]
|
||||
}
|
||||
}
|
||||
|
||||
// collapseSpaces схлопывает любые пробельные последовательности в один пробел.
|
||||
func collapseSpaces(value string) string {
|
||||
var builder strings.Builder
|
||||
space := false
|
||||
for _, r := range value {
|
||||
if unicode.IsSpace(r) {
|
||||
if !space && builder.Len() > 0 {
|
||||
builder.WriteRune(' ')
|
||||
}
|
||||
space = true
|
||||
continue
|
||||
}
|
||||
space = false
|
||||
builder.WriteRune(r)
|
||||
}
|
||||
return strings.TrimSpace(builder.String())
|
||||
}
|
||||
|
||||
// formatOptionalID печатает идентификатор в десятичном виде, пусто для нуля.
|
||||
func formatOptionalID(id uint64) string {
|
||||
if id == 0 {
|
||||
return ""
|
||||
}
|
||||
return formatID(id)
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
package push
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"glchat/internal/store"
|
||||
)
|
||||
|
||||
// input — сообщение по умолчанию: обычное сообщение в комнате сервера.
|
||||
// Набор проверок повторяет web/tests/desktopNotifications.test.ts: правила
|
||||
// push и нативных уведомлений обязаны совпадать.
|
||||
func input(overrides func(*NotificationInput)) NotificationInput {
|
||||
value := NotificationInput{
|
||||
MessageType: store.MessageDefault,
|
||||
Content: "привет",
|
||||
Mentions: nil,
|
||||
AuthorID: 2,
|
||||
MeID: 1,
|
||||
IsDirect: false,
|
||||
ChannelName: "общий",
|
||||
ChannelID: 10,
|
||||
MessageID: 99,
|
||||
GuildID: 1,
|
||||
GuildName: "Сервер",
|
||||
AuthorName: "Аня",
|
||||
}
|
||||
if overrides != nil {
|
||||
overrides(&value)
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func TestNotificationForMention(t *testing.T) {
|
||||
payload := NotificationFor(input(func(in *NotificationInput) { in.Mentions = []uint64{1} }))
|
||||
if payload == nil {
|
||||
t.Fatal("упоминание не дало уведомления")
|
||||
}
|
||||
if payload.Title != "Упоминание в #общий" || payload.Body != "привет" {
|
||||
t.Fatalf("неожиданный текст: %+v", payload)
|
||||
}
|
||||
if payload.Kind != "mention" || payload.ChannelID != "10" || payload.Tag != "channel-10" {
|
||||
t.Fatalf("неожиданные поля: %+v", payload)
|
||||
}
|
||||
if payload.URL != "/app/1/10" {
|
||||
t.Fatalf("ссылка = %q, ожидалось /app/1/10", payload.URL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNotificationForDirect(t *testing.T) {
|
||||
payload := NotificationFor(input(func(in *NotificationInput) {
|
||||
in.IsDirect = true
|
||||
in.GuildID = 0
|
||||
in.GuildName = ""
|
||||
in.ChannelName = "Аня"
|
||||
}))
|
||||
if payload == nil {
|
||||
t.Fatal("личное сообщение не дало уведомления")
|
||||
}
|
||||
if payload.Title != "Личное сообщение — Аня" || payload.Kind != "direct" {
|
||||
t.Fatalf("неожиданный текст: %+v", payload)
|
||||
}
|
||||
if payload.URL != "/app/friends/10" {
|
||||
t.Fatalf("ссылка = %q, ожидалось /app/friends/10", payload.URL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNotificationForSilentCases(t *testing.T) {
|
||||
cases := map[string]NotificationInput{
|
||||
"обычное сообщение в комнате": input(nil),
|
||||
"своё сообщение": input(func(in *NotificationInput) {
|
||||
in.IsDirect = true
|
||||
in.AuthorID = in.MeID
|
||||
}),
|
||||
"системное сообщение": input(func(in *NotificationInput) {
|
||||
in.IsDirect = true
|
||||
in.MessageType = store.MessageSystem
|
||||
}),
|
||||
"получатель в клиенте": input(func(in *NotificationInput) {
|
||||
in.IsDirect = true
|
||||
in.Mentions = []uint64{1}
|
||||
in.Online = true
|
||||
}),
|
||||
"упоминание другого пользователя": input(func(in *NotificationInput) {
|
||||
in.Mentions = []uint64{777}
|
||||
}),
|
||||
"пустое сообщение без упоминаний": input(func(in *NotificationInput) {
|
||||
in.IsDirect = true
|
||||
in.Content = ""
|
||||
}),
|
||||
}
|
||||
for name, value := range cases {
|
||||
if payload := NotificationFor(value); payload != nil {
|
||||
t.Errorf("%s: уведомление не должно отправляться (%+v)", name, payload)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Упоминание с пустым текстом (одно вложение) — как в клиенте: «Вложение».
|
||||
func TestNotificationForAttachmentOnly(t *testing.T) {
|
||||
payload := NotificationFor(input(func(in *NotificationInput) {
|
||||
in.IsDirect = true
|
||||
in.Content = ""
|
||||
in.Mentions = []uint64{1}
|
||||
}))
|
||||
if payload == nil || payload.Body != "Вложение" {
|
||||
t.Fatalf("ожидалось «Вложение», получили %+v", payload)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPreviewText(t *testing.T) {
|
||||
if got := previewText("**жирный**\n\n[ссылка](https://example.com) `код`"); got != "жирный ссылка код" {
|
||||
t.Fatalf("previewText = %q", got)
|
||||
}
|
||||
if got := previewText("```go\nfunc main() {}\n``` после"); got != "[код] после" {
|
||||
t.Fatalf("блок кода = %q", got)
|
||||
}
|
||||
long := previewText(strings.Repeat("а", 400))
|
||||
if len([]rune(long)) > previewLimit {
|
||||
t.Fatalf("длина превью %d превышает лимит %d", len([]rune(long)), previewLimit)
|
||||
}
|
||||
if !strings.HasSuffix(long, "…") {
|
||||
t.Fatalf("длинный текст не обрезан: %q", long)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatOptionalID(t *testing.T) {
|
||||
if got := formatOptionalID(0); got != "" {
|
||||
t.Fatalf("нулевой идентификатор = %q, ожидалась пустая строка", got)
|
||||
}
|
||||
if got := formatOptionalID(42); got != "42" {
|
||||
t.Fatalf("идентификатор = %q", got)
|
||||
}
|
||||
}
|
||||
@@ -40,8 +40,17 @@ type Stats struct {
|
||||
Unknown int64
|
||||
// Bytes — сколько освободили на диске.
|
||||
Bytes int64
|
||||
// PushSubscriptions — мёртвые подписки Web Push (Фаза 7).
|
||||
PushSubscriptions int64
|
||||
}
|
||||
|
||||
// Мёртвые подписки Web Push (Фаза 7): подписка, которая не доставлялась
|
||||
// неделю и накопила неудачи, больше не нужна.
|
||||
const (
|
||||
stalePushAfterDays = 7
|
||||
stalePushMaxFailures = 5
|
||||
)
|
||||
|
||||
// Runner выполняет чистку по расписанию.
|
||||
type Runner struct {
|
||||
store *store.Store
|
||||
@@ -118,6 +127,15 @@ func (r *Runner) RunOnce(ctx context.Context) (Stats, error) {
|
||||
stats.Bytes += bytes
|
||||
}
|
||||
|
||||
// 5. Мёртвые подписки Web Push: браузер мог отозвать подписку, а
|
||||
// push-сервис — перестать отвечать. Живые подписки не трогаем.
|
||||
cutoff := r.now().AddDate(0, 0, -stalePushAfterDays)
|
||||
removed, err := r.store.DeleteStalePushSubscriptions(ctx, cutoff, stalePushMaxFailures)
|
||||
if err != nil {
|
||||
errs = append(errs, fmt.Errorf("push subscriptions: %w", err))
|
||||
}
|
||||
stats.PushSubscriptions = removed
|
||||
|
||||
stats2 := stats
|
||||
if len(errs) > 0 {
|
||||
return stats2, errors.Join(errs...)
|
||||
@@ -235,6 +253,7 @@ func (r *Runner) Run(ctx context.Context, interval time.Duration) func() {
|
||||
slog.Int64("audit", stats.Audit),
|
||||
slog.Int64("files", stats.Files),
|
||||
slog.Int64("unknown_files", stats.Unknown),
|
||||
slog.Int64("push_subscriptions", stats.PushSubscriptions),
|
||||
slog.Int64("bytes", stats.Bytes))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -222,6 +222,9 @@ func (s *Server) registerMessageRoutes(api huma.API) {
|
||||
} else {
|
||||
s.dispatchChannelEvent(ctx, channelID, "MESSAGE_CREATE", payload)
|
||||
}
|
||||
// Web Push (Фаза 7): упоминания и личные беседы уходят на устройства
|
||||
// тех, кто не в клиенте — тем же правилам, что у desktop-уведомлений.
|
||||
s.notifyPushAboutMessage(ctx, message, channel, displayNameOf(user))
|
||||
output := &messageOutput{}
|
||||
output.Body.Message = payload
|
||||
return output, nil
|
||||
|
||||
@@ -0,0 +1,255 @@
|
||||
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])
|
||||
}
|
||||
@@ -0,0 +1,354 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
"crypto/rand"
|
||||
"crypto/x509"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"glchat/internal/auth"
|
||||
"glchat/internal/config"
|
||||
"glchat/internal/database"
|
||||
"glchat/internal/gateway"
|
||||
"glchat/internal/httpx"
|
||||
"glchat/internal/permissions"
|
||||
"glchat/internal/source"
|
||||
"glchat/internal/store"
|
||||
)
|
||||
|
||||
// newPushTestServer поднимает тестовый сервер с настроенным VAPID-ключом:
|
||||
// newTestServer из server_test.go о push ничего не знает, а ручки обязаны
|
||||
// работать и с ключом, и без него.
|
||||
func newPushTestServer(t *testing.T, withKeys bool) (*Server, *store.Store) {
|
||||
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("open test database: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
if err := db.Close(); err != nil {
|
||||
t.Errorf("close test database: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
cfg := config.Config{
|
||||
DataDir: t.TempDir(),
|
||||
Domain: "gl.mhspx.su",
|
||||
WebRoot: filepath.Join("testdata", "web"),
|
||||
FilesDomain: "files.gl.mhspx.su",
|
||||
InstanceName: "glchat",
|
||||
ListenAddr: "127.0.0.1:0",
|
||||
Version: "v0.1.0-test",
|
||||
Commit: "deadbee",
|
||||
BuildDate: "2026-09-19T00:00:00Z",
|
||||
MaxUploadSize: 26214400,
|
||||
TLSEnabled: true,
|
||||
SessionPepper: "test-pepper",
|
||||
MasterKey: "00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff",
|
||||
Argon2MemoryKiB: 1024,
|
||||
Argon2Iterations: 1,
|
||||
Argon2Parallelism: 1,
|
||||
LogLevel: "error",
|
||||
LogFormat: "json",
|
||||
}
|
||||
if withKeys {
|
||||
cfg.VAPIDPrivateKey = testVAPIDPrivateKey(t)
|
||||
cfg.VAPIDSubject = "mailto:admin@gl.mhspx.su"
|
||||
}
|
||||
logger := slog.New(slog.DiscardHandler)
|
||||
st := store.New(db)
|
||||
authService, err := auth.New(context.Background(), cfg, st, logger)
|
||||
if err != nil {
|
||||
t.Fatalf("initialize authentication: %v", err)
|
||||
}
|
||||
calculator := permissions.NewCalculator(source.New(st))
|
||||
gatewayService := gateway.New(st, authService, gateway.NewSnapshot(st, calculator), logger, cfg.AllowedOrigins())
|
||||
return New(cfg, db, logger, Deps{
|
||||
Store: st, Auth: authService, Gateway: gatewayService, Permissions: calculator,
|
||||
}), st
|
||||
}
|
||||
|
||||
func testVAPIDPrivateKey(t *testing.T) string {
|
||||
t.Helper()
|
||||
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
if err != nil {
|
||||
t.Fatalf("generate key: %v", err)
|
||||
}
|
||||
der, err := x509.MarshalPKCS8PrivateKey(key)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal PKCS#8: %v", err)
|
||||
}
|
||||
return base64.StdEncoding.EncodeToString(der)
|
||||
}
|
||||
|
||||
// testPushKeys возвращает корректные ключи подписки (65-байтовая точка P-256
|
||||
// и 16 байт auth) в base64url — как их отдаёт PushSubscription.toJSON().
|
||||
func testPushKeys(t *testing.T) (string, string) {
|
||||
t.Helper()
|
||||
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
if err != nil {
|
||||
t.Fatalf("generate subscription key: %v", err)
|
||||
}
|
||||
point, err := key.PublicKey.Bytes()
|
||||
if err != nil {
|
||||
t.Fatalf("public key bytes: %v", err)
|
||||
}
|
||||
auth := make([]byte, 16)
|
||||
if _, err := rand.Read(auth); err != nil {
|
||||
t.Fatalf("auth: %v", err)
|
||||
}
|
||||
return base64.RawURLEncoding.EncodeToString(point), base64.RawURLEncoding.EncodeToString(auth)
|
||||
}
|
||||
|
||||
// testAuthKey — корректный auth-ключ подписки (16 байт).
|
||||
func testAuthKey(t *testing.T) string {
|
||||
t.Helper()
|
||||
_, auth := testPushKeys(t)
|
||||
return auth
|
||||
}
|
||||
|
||||
func pushSubscribeBody(t *testing.T, endpoint string) string {
|
||||
t.Helper()
|
||||
p256dh, auth := testPushKeys(t)
|
||||
return fmt.Sprintf(`{"endpoint":%q,"keys":{"p256dh":%q,"auth":%q}}`, endpoint, p256dh, auth)
|
||||
}
|
||||
|
||||
func TestPushConfigDisabledWithoutKeys(t *testing.T) {
|
||||
srv, _ := newPushTestServer(t, false)
|
||||
cookie := registerAndLogin(t, srv, "push_off", "push-off@example.com")
|
||||
|
||||
rec := doJSON(t, srv, http.MethodGet, "/api/v1/push/config", "", cookie)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("GET /push/config = %d, body = %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
payload := decodeResponse[struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
PublicKey string `json:"public_key"`
|
||||
}](t, rec)
|
||||
if payload.Enabled || payload.PublicKey != "" {
|
||||
t.Fatalf("без ключей push должен быть выключен: %+v", payload)
|
||||
}
|
||||
|
||||
// Подписка на инстансе без ключей — честная ошибка, а не молчаливый успех.
|
||||
rec = doJSON(t, srv, http.MethodPost, "/api/v1/push/subscriptions",
|
||||
pushSubscribeBody(t, "https://fcm.googleapis.com/fcm/send/disabled"), cookie)
|
||||
if rec.Code != http.StatusServiceUnavailable {
|
||||
t.Fatalf("подписка без ключей = %d, ожидалось 503", rec.Code)
|
||||
}
|
||||
if code := errorCodeOf(t, rec); code != "push.disabled" {
|
||||
t.Fatalf("код ошибки = %q, ожидался push.disabled", code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPushSubscribeFlow(t *testing.T) {
|
||||
srv, st := newPushTestServer(t, true)
|
||||
cookie := registerAndLogin(t, srv, "push_on", "push-on@example.com")
|
||||
user, err := srv.auth.UserByEmail(t.Context(), "push-on@example.com")
|
||||
if err != nil {
|
||||
t.Fatalf("UserByEmail: %v", err)
|
||||
}
|
||||
|
||||
config := decodeResponse[struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
PublicKey string `json:"public_key"`
|
||||
}](t, doJSON(t, srv, http.MethodGet, "/api/v1/push/config", "", cookie))
|
||||
if !config.Enabled || config.PublicKey == "" {
|
||||
t.Fatalf("push должен быть включён: %+v", config)
|
||||
}
|
||||
decoded, err := base64.RawURLEncoding.DecodeString(config.PublicKey)
|
||||
if err != nil || len(decoded) != 65 {
|
||||
t.Fatalf("публичный ключ не годится для applicationServerKey: %v", err)
|
||||
}
|
||||
|
||||
endpoint := "https://fcm.googleapis.com/fcm/send/device-one"
|
||||
rec := doJSON(t, srv, http.MethodPost, "/api/v1/push/subscriptions",
|
||||
pushSubscribeBody(t, endpoint), cookie)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("подписка = %d, body = %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
subscriptions, err := st.ListPushSubscriptions(t.Context(), user.ID)
|
||||
if err != nil || len(subscriptions) != 1 {
|
||||
t.Fatalf("подписок в базе %d (%v), ожидалась 1", len(subscriptions), err)
|
||||
}
|
||||
if subscriptions[0].Endpoint != endpoint {
|
||||
t.Fatalf("эндпоинт = %q", subscriptions[0].Endpoint)
|
||||
}
|
||||
|
||||
// Список для интерфейса отдаёт устройства и лимит.
|
||||
list := decodeResponse[struct {
|
||||
Subscriptions []struct {
|
||||
ID string `json:"id"`
|
||||
Endpoint string `json:"endpoint"`
|
||||
} `json:"subscriptions"`
|
||||
Limit int `json:"limit"`
|
||||
}](t, doJSON(t, srv, http.MethodGet, "/api/v1/push/subscriptions", "", cookie))
|
||||
if len(list.Subscriptions) != 1 || list.Limit != maxPushSubscriptionsPerUser {
|
||||
t.Fatalf("неожиданный список подписок: %+v", list)
|
||||
}
|
||||
|
||||
// Отписка устройства.
|
||||
rec = doJSON(t, srv, http.MethodDelete,
|
||||
"/api/v1/push/subscriptions?endpoint="+endpoint, "", cookie)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("отписка = %d, body = %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if count, err := st.CountPushSubscriptions(t.Context(), user.ID); err != nil || count != 0 {
|
||||
t.Fatalf("после отписки подписок %d (%v)", count, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPushSubscribeValidation(t *testing.T) {
|
||||
srv, _ := newPushTestServer(t, true)
|
||||
cookie := registerAndLogin(t, srv, "push_bad", "push-bad@example.com")
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
body string
|
||||
code string
|
||||
}{
|
||||
{
|
||||
name: "http вместо https",
|
||||
body: pushSubscribeBody(t, "http://fcm.googleapis.com/fcm/send/x"),
|
||||
code: "push.invalid_endpoint",
|
||||
},
|
||||
{
|
||||
name: "внутренний адрес",
|
||||
body: pushSubscribeBody(t, "https://10.0.0.5/push"),
|
||||
code: "push.invalid_endpoint",
|
||||
},
|
||||
{
|
||||
name: "метаданные облака",
|
||||
body: pushSubscribeBody(t, "https://169.254.169.254/latest/meta-data"),
|
||||
code: "push.invalid_endpoint",
|
||||
},
|
||||
{
|
||||
name: "loopback",
|
||||
body: pushSubscribeBody(t, "https://127.0.0.1:8443/push"),
|
||||
code: "push.invalid_endpoint",
|
||||
},
|
||||
{
|
||||
name: "p256dh не точка кривой",
|
||||
body: fmt.Sprintf(`{"endpoint":"https://fcm.googleapis.com/fcm/send/x","keys":{"p256dh":%q,"auth":%q}}`,
|
||||
base64.RawURLEncoding.EncodeToString(make([]byte, 65)), testAuthKey(t)),
|
||||
code: "push.invalid_keys",
|
||||
},
|
||||
{
|
||||
name: "битый ключ шифрования",
|
||||
body: `{"endpoint":"https://fcm.googleapis.com/fcm/send/x","keys":{"p256dh":"AAAA","auth":"AAAA"}}`,
|
||||
code: "push.invalid_keys",
|
||||
},
|
||||
{
|
||||
name: "пустой ключ",
|
||||
body: `{"endpoint":"https://fcm.googleapis.com/fcm/send/x","keys":{"p256dh":"","auth":""}}`,
|
||||
code: "push.invalid_keys",
|
||||
},
|
||||
}
|
||||
for _, testCase := range cases {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
rec := doJSON(t, srv, http.MethodPost, "/api/v1/push/subscriptions", testCase.body, cookie)
|
||||
if rec.Code != http.StatusUnprocessableEntity {
|
||||
t.Fatalf("код ответа = %d, ожидался 422 (body = %s)", rec.Code, rec.Body.String())
|
||||
}
|
||||
if code := errorCodeOf(t, rec); code != testCase.code {
|
||||
t.Fatalf("код ошибки = %q, ожидался %q", code, testCase.code)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPushSubscribeLimit(t *testing.T) {
|
||||
srv, _ := newPushTestServer(t, true)
|
||||
cookie := registerAndLogin(t, srv, "push_limit", "push-limit@example.com")
|
||||
// Лимит частоты проверяется отдельно: здесь важно дойти до предела
|
||||
// устройств на пользователя.
|
||||
srv.pushLimiter = httpx.NewRateLimiterWindow(100, time.Minute, 100)
|
||||
|
||||
for i := 0; i < maxPushSubscriptionsPerUser; i++ {
|
||||
endpoint := fmt.Sprintf("https://fcm.googleapis.com/fcm/send/device-%d", i)
|
||||
rec := doJSON(t, srv, http.MethodPost, "/api/v1/push/subscriptions",
|
||||
pushSubscribeBody(t, endpoint), cookie)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("подписка %d = %d, body = %s", i, rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
rec := doJSON(t, srv, http.MethodPost, "/api/v1/push/subscriptions",
|
||||
pushSubscribeBody(t, "https://fcm.googleapis.com/fcm/send/device-over-limit"), cookie)
|
||||
if rec.Code != http.StatusConflict {
|
||||
t.Fatalf("подписка сверх лимита = %d, ожидалось 409 (body = %s)", rec.Code, rec.Body.String())
|
||||
}
|
||||
if code := errorCodeOf(t, rec); code != "push.too_many_subscriptions" {
|
||||
t.Fatalf("код ошибки = %q", code)
|
||||
}
|
||||
}
|
||||
|
||||
// Подписки ограничены и по частоте: перебор устройств не должен превращаться
|
||||
// в поток запросов (AGENT.md 8.6).
|
||||
func TestPushSubscribeRateLimited(t *testing.T) {
|
||||
srv, _ := newPushTestServer(t, true)
|
||||
cookie := registerAndLogin(t, srv, "push_flood", "push-flood@example.com")
|
||||
|
||||
var limited bool
|
||||
for i := 0; i < 40; i++ {
|
||||
endpoint := fmt.Sprintf("https://fcm.googleapis.com/fcm/send/flood-%d", i)
|
||||
rec := doJSON(t, srv, http.MethodPost, "/api/v1/push/subscriptions",
|
||||
pushSubscribeBody(t, endpoint), cookie)
|
||||
if rec.Code == http.StatusTooManyRequests {
|
||||
limited = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !limited {
|
||||
t.Fatal("лимит частоты подписок не сработал")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPushRoutesRequireSession(t *testing.T) {
|
||||
srv, _ := newPushTestServer(t, true)
|
||||
body := pushSubscribeBody(t, "https://fcm.googleapis.com/fcm/send/no-session")
|
||||
for _, request := range []struct {
|
||||
method string
|
||||
path string
|
||||
body string
|
||||
}{
|
||||
{http.MethodGet, "/api/v1/push/config", ""},
|
||||
{http.MethodGet, "/api/v1/push/subscriptions", ""},
|
||||
{http.MethodPost, "/api/v1/push/subscriptions", body},
|
||||
{http.MethodDelete, "/api/v1/push/subscriptions", ""},
|
||||
} {
|
||||
rec := doJSON(t, srv, request.method, request.path, request.body)
|
||||
if rec.Code != http.StatusUnauthorized {
|
||||
t.Errorf("%s %s без сессии = %d, ожидалось 401", request.method, request.path, rec.Code)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Публичный ключ в /meta виден до входа: клиент решает, показывать ли раздел.
|
||||
func TestMetaExposesWebPushFlag(t *testing.T) {
|
||||
srv, _ := newPushTestServer(t, true)
|
||||
rec := doJSON(t, srv, http.MethodGet, "/api/v1/meta", "")
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("GET /meta = %d", rec.Code)
|
||||
}
|
||||
payload := decodeResponse[struct {
|
||||
Features struct {
|
||||
WebPushEnabled bool `json:"web_push_enabled"`
|
||||
} `json:"features"`
|
||||
}](t, rec)
|
||||
if !payload.Features.WebPushEnabled {
|
||||
t.Fatal("meta не сообщает о включённом Web Push")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
|
||||
"glchat/internal/push"
|
||||
"glchat/internal/store"
|
||||
)
|
||||
|
||||
// Web Push о новых сообщениях (AGENT.md 7.16, Фаза 7).
|
||||
//
|
||||
// Правила те же, что у нативных уведомлений desktop-обёртки
|
||||
// (web/src/lib/desktopNotifications.ts): упоминания и личные беседы, без своих
|
||||
// и системных сообщений. Отличие одно: сервер не знает про фокус окна, поэтому
|
||||
// вместо «комната открыта и окно активно» проверяется наличие соединения
|
||||
// шлюза — если пользователь в приложении, уведомление покажет клиент.
|
||||
|
||||
// pushSender — то, что серверу нужно от Web Push. Интерфейс позволяет
|
||||
// подменить отправителя в тестах: доставка идёт в фоне и наружу не видна.
|
||||
type pushSender interface {
|
||||
Enabled() bool
|
||||
PublicKey() string
|
||||
Enqueue(subscription store.PushSubscription, payload push.Payload) bool
|
||||
}
|
||||
|
||||
// displayNameOf — имя автора для уведомления: display_name, а при пустом —
|
||||
// логин (у новых аккаунтов display_name заполняется логином, но вебхуки и
|
||||
// ручные данные могут его не иметь).
|
||||
func displayNameOf(user *store.User) string {
|
||||
if user == nil {
|
||||
return ""
|
||||
}
|
||||
if user.DisplayName != "" {
|
||||
return user.DisplayName
|
||||
}
|
||||
return user.Username
|
||||
}
|
||||
|
||||
// notifyPushAboutMessage ставит уведомления в очередь push-сервиса.
|
||||
//
|
||||
// authorName — отображаемое имя автора: в личной беседе 1:1 оно и есть имя
|
||||
// «комнаты» (отдельного названия у неё нет).
|
||||
func (s *Server) notifyPushAboutMessage(ctx context.Context, message *store.Message, channel *store.Channel, authorName string) {
|
||||
if !s.push.Enabled() || channel == nil {
|
||||
return
|
||||
}
|
||||
recipients := s.pushRecipients(ctx, message, channel)
|
||||
if len(recipients) == 0 {
|
||||
return
|
||||
}
|
||||
authorID := uint64(0)
|
||||
if message.AuthorID != nil {
|
||||
authorID = *message.AuthorID
|
||||
}
|
||||
guildID := uint64(0)
|
||||
guildName := ""
|
||||
if channel.GuildID != nil {
|
||||
guildID = *channel.GuildID
|
||||
if guild, err := s.store.GetGuild(ctx, guildID); err == nil {
|
||||
guildName = guild.Name
|
||||
}
|
||||
}
|
||||
channelName := s.pushChannelName(channel, authorName)
|
||||
for _, recipient := range recipients {
|
||||
if recipient == 0 || recipient == authorID {
|
||||
continue
|
||||
}
|
||||
// Получатель в приложении: уведомление покажет клиент, а не телефон.
|
||||
if s.gateway.IsUserOnline(recipient) {
|
||||
continue
|
||||
}
|
||||
// Лимит на получателя: массовые упоминания не должны превращаться в
|
||||
// поток push-сообщений (AGENT.md 8.6).
|
||||
if allowed, _ := s.pushNotifyLimiter.Allow(pushLimitKey(recipient)); !allowed {
|
||||
continue
|
||||
}
|
||||
payload := push.NotificationFor(push.NotificationInput{
|
||||
MessageType: message.Type,
|
||||
Content: message.Content,
|
||||
Mentions: message.Mentions,
|
||||
AuthorID: authorID,
|
||||
MeID: recipient,
|
||||
IsDirect: channel.GuildID == nil,
|
||||
ChannelName: channelName,
|
||||
ChannelID: message.ChannelID,
|
||||
MessageID: message.ID,
|
||||
GuildID: guildID,
|
||||
GuildName: guildName,
|
||||
AuthorName: authorName,
|
||||
})
|
||||
if payload == nil {
|
||||
continue
|
||||
}
|
||||
subscriptions, err := s.store.ListPushSubscriptions(ctx, recipient)
|
||||
if err != nil {
|
||||
s.logger.WarnContext(ctx, "push subscriptions read failed", slog.Any("error", err))
|
||||
continue
|
||||
}
|
||||
for i := range subscriptions {
|
||||
s.push.Enqueue(subscriptions[i], *payload)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// pushRecipients перечисляет, кому адресовано уведомление: в комнате сервера —
|
||||
// упомянутым, в личной беседе — всем остальным участникам.
|
||||
func (s *Server) pushRecipients(ctx context.Context, message *store.Message, channel *store.Channel) []uint64 {
|
||||
if channel.GuildID != nil {
|
||||
return message.Mentions
|
||||
}
|
||||
participants, err := s.store.DMParticipants(ctx, channel.ID)
|
||||
if err != nil {
|
||||
s.logger.WarnContext(ctx, "dm participants read failed", slog.Any("error", err))
|
||||
return nil
|
||||
}
|
||||
return participants
|
||||
}
|
||||
|
||||
// pushChannelName подбирает название для заголовка уведомления: у групповой
|
||||
// беседы — её имя, у 1:1 своего имени нет, поэтому берём имя собеседника
|
||||
// (автора сообщения).
|
||||
func (s *Server) pushChannelName(channel *store.Channel, authorName string) string {
|
||||
if channel.GuildID == nil && channel.DMOwnerID == nil {
|
||||
return authorName
|
||||
}
|
||||
return channel.Name
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"glchat/internal/push"
|
||||
"glchat/internal/store"
|
||||
)
|
||||
|
||||
// recordingPush — подмена отправителя: запоминает, что сервер поставил в
|
||||
// очередь. Реальная доставка идёт в фоне и в тесте не наблюдаема.
|
||||
type recordingPush struct {
|
||||
enabled bool
|
||||
publicKey string
|
||||
jobs []recordedPush
|
||||
}
|
||||
|
||||
type recordedPush struct {
|
||||
subscription store.PushSubscription
|
||||
payload push.Payload
|
||||
}
|
||||
|
||||
func (r *recordingPush) Enabled() bool { return r.enabled }
|
||||
func (r *recordingPush) PublicKey() string { return r.publicKey }
|
||||
func (r *recordingPush) Enqueue(subscription store.PushSubscription, payload push.Payload) bool {
|
||||
r.jobs = append(r.jobs, recordedPush{subscription: subscription, payload: payload})
|
||||
return true
|
||||
}
|
||||
|
||||
// createTestDM открывает личную беседу между автором и указанным участником
|
||||
// и возвращает идентификатор комнаты.
|
||||
func createTestDM(t *testing.T, srv *Server, cookie *http.Cookie, userID string) string {
|
||||
t.Helper()
|
||||
rec := doJSON(t, srv, http.MethodPost, "/api/v1/users/@me/channels",
|
||||
`{"recipient_id":"`+userID+`"}`, cookie)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("открыть личную беседу = %d, body = %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
payload := decodeResponse[struct {
|
||||
Channel struct {
|
||||
ID string `json:"id"`
|
||||
} `json:"channel"`
|
||||
}](t, rec)
|
||||
if payload.Channel.ID == "" {
|
||||
t.Fatalf("беседа без идентификатора: %s", rec.Body.String())
|
||||
}
|
||||
return payload.Channel.ID
|
||||
}
|
||||
|
||||
// savePushSubscription заводит подписку устройства напрямую в БД.
|
||||
func savePushSubscription(t *testing.T, st *store.Store, userID uint64, endpoint string) store.PushSubscription {
|
||||
t.Helper()
|
||||
subscription, err := st.SavePushSubscription(context.Background(), store.SavePushSubscriptionParams{
|
||||
UserID: userID,
|
||||
Endpoint: endpoint,
|
||||
P256dh: "p256dh",
|
||||
Auth: "auth",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("save subscription: %v", err)
|
||||
}
|
||||
return *subscription
|
||||
}
|
||||
|
||||
// TestPushNotifiesDirectMessage: личное сообщение уходит на устройство
|
||||
// получателя, если он не в клиенте (AGENT.md 7.16, Фаза 7).
|
||||
func TestPushNotifiesDirectMessage(t *testing.T) {
|
||||
srv, st := newPushTestServer(t, true)
|
||||
recorder := &recordingPush{enabled: true, publicKey: "key"}
|
||||
srv.push = recorder
|
||||
|
||||
authorCookie := registerAndLogin(t, srv, "push_author", "push-author@example.com")
|
||||
registerAndLogin(t, srv, "push_target", "push-target@example.com")
|
||||
targetID := mustUserID(t, srv, "push-target@example.com")
|
||||
savePushSubscription(t, st, targetID, "https://fcm.googleapis.com/fcm/send/target")
|
||||
|
||||
channelID := createTestDM(t, srv, authorCookie, formatSnowflake(targetID))
|
||||
rec := doJSON(t, srv, http.MethodPost, "/api/v1/channels/"+channelID+"/messages",
|
||||
`{"content":"привет, это личное"}`, authorCookie)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("отправка сообщения = %d, body = %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
if len(recorder.jobs) != 1 {
|
||||
t.Fatalf("поставлено уведомлений %d, ожидалось 1", len(recorder.jobs))
|
||||
}
|
||||
job := recorder.jobs[0]
|
||||
if job.payload.Kind != "direct" || job.payload.Body != "привет, это личное" {
|
||||
t.Fatalf("неожиданное уведомление: %+v", job.payload)
|
||||
}
|
||||
if job.payload.ChannelID != channelID {
|
||||
t.Fatalf("комната уведомления %q, ожидалась %q", job.payload.ChannelID, channelID)
|
||||
}
|
||||
if job.payload.URL != "/app/friends/"+channelID {
|
||||
t.Fatalf("ссылка = %q", job.payload.URL)
|
||||
}
|
||||
if job.subscription.Endpoint != "https://fcm.googleapis.com/fcm/send/target" {
|
||||
t.Fatalf("подписка = %q", job.subscription.Endpoint)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPushNotifiesMention: упоминание в комнате сервера уходит получателю;
|
||||
// обычное сообщение — нет.
|
||||
func TestPushNotifiesMention(t *testing.T) {
|
||||
fixture := newMessagingFixture(t)
|
||||
recorder := &recordingPush{enabled: true, publicKey: "key"}
|
||||
fixture.srv.push = recorder
|
||||
savePushSubscription(t, fixture.srv.store, guildIDOf(t, fixture.memberID), "https://fcm.googleapis.com/fcm/send/member")
|
||||
|
||||
// Обычное сообщение: упоминаний нет — push не нужен.
|
||||
rec := doJSON(t, fixture.srv, http.MethodPost, "/api/v1/channels/"+fixture.openChannel+"/messages",
|
||||
`{"content":"просто сообщение"}`, fixture.ownerCookie)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("отправка сообщения = %d, body = %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if len(recorder.jobs) != 0 {
|
||||
t.Fatalf("без упоминания поставлено уведомлений %d", len(recorder.jobs))
|
||||
}
|
||||
|
||||
// Упоминание: уведомление уходит со ссылкой на комнату сервера.
|
||||
rec = doJSON(t, fixture.srv, http.MethodPost, "/api/v1/channels/"+fixture.openChannel+"/messages",
|
||||
`{"content":"<@`+fixture.memberID+`> посмотри"}`, fixture.ownerCookie)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("отправка упоминания = %d, body = %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if len(recorder.jobs) != 1 {
|
||||
t.Fatalf("поставлено уведомлений %d, ожидалось 1", len(recorder.jobs))
|
||||
}
|
||||
payload := recorder.jobs[0].payload
|
||||
if payload.Kind != "mention" {
|
||||
t.Fatalf("вид уведомления = %q", payload.Kind)
|
||||
}
|
||||
if !strings.Contains(payload.Body, "посмотри") {
|
||||
t.Fatalf("тело уведомления = %q", payload.Body)
|
||||
}
|
||||
if payload.ChannelID != fixture.openChannel {
|
||||
t.Fatalf("комната уведомления %q, ожидалась %q", payload.ChannelID, fixture.openChannel)
|
||||
}
|
||||
if payload.URL != "/app/"+fixture.guildID+"/"+fixture.openChannel {
|
||||
t.Fatalf("ссылка = %q", payload.URL)
|
||||
}
|
||||
if payload.Title == "" || !strings.Contains(payload.Title, "#") {
|
||||
t.Fatalf("заголовок уведомления = %q", payload.Title)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPushSkipsOwnMessages: свои сообщения уведомлений не порождают — как в
|
||||
// desktopNotifications.ts.
|
||||
func TestPushSkipsOwnMessages(t *testing.T) {
|
||||
srv, st := newPushTestServer(t, true)
|
||||
recorder := &recordingPush{enabled: true, publicKey: "key"}
|
||||
srv.push = recorder
|
||||
|
||||
cookie := registerAndLogin(t, srv, "push_self", "push-self@example.com")
|
||||
selfID := mustUserID(t, srv, "push-self@example.com")
|
||||
savePushSubscription(t, st, selfID, "https://fcm.googleapis.com/fcm/send/self")
|
||||
otherCookie := registerAndLogin(t, srv, "push_other", "push-other@example.com")
|
||||
otherID := mustUserID(t, srv, "push-other@example.com")
|
||||
|
||||
channelID := createTestDM(t, srv, cookie, formatSnowflake(otherID))
|
||||
// Отправляем другому, но получатель — тоже автор? Нет: подписка только у
|
||||
// автора, а сообщение уходит второму участнику — уведомлений быть не должно.
|
||||
rec := doJSON(t, srv, http.MethodPost, "/api/v1/channels/"+channelID+"/messages",
|
||||
`{"content":"<@`+formatSnowflake(selfID)+`> себе"}`, cookie)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("отправка = %d, body = %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if len(recorder.jobs) != 0 {
|
||||
t.Fatalf("своё сообщение дало уведомления: %+v", recorder.jobs)
|
||||
}
|
||||
_ = otherCookie
|
||||
}
|
||||
|
||||
// TestPushSkipsOnlineUsers: если получатель в клиенте (есть соединение шлюза),
|
||||
// уведомление покажет сам клиент — push не отправляем.
|
||||
func TestPushSkipsOnlineUsers(t *testing.T) {
|
||||
srv, st := newPushTestServer(t, true)
|
||||
recorder := &recordingPush{enabled: true, publicKey: "key"}
|
||||
srv.push = recorder
|
||||
|
||||
authorCookie := registerAndLogin(t, srv, "push_online_author", "push-online-author@example.com")
|
||||
targetCookie := registerAndLogin(t, srv, "push_online_target", "push-online-target@example.com")
|
||||
targetID := mustUserID(t, srv, "push-online-target@example.com")
|
||||
savePushSubscription(t, st, targetID, "https://fcm.googleapis.com/fcm/send/online")
|
||||
channelID := createTestDM(t, srv, authorCookie, formatSnowflake(targetID))
|
||||
|
||||
// Настоящее соединение шлюза за получателя: он «в клиенте».
|
||||
httpServer := httptest.NewServer(srv.Handler())
|
||||
t.Cleanup(httpServer.Close)
|
||||
dialGateway(t, httpServer, targetCookie)
|
||||
|
||||
rec := doJSON(t, srv, http.MethodPost, "/api/v1/channels/"+channelID+"/messages",
|
||||
`{"content":"он в клиенте"}`, authorCookie)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("отправка = %d, body = %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if len(recorder.jobs) != 0 {
|
||||
t.Fatalf("пользователю в клиенте отправлен push: %+v", recorder.jobs)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPushSkippedWhenDisabled: без VAPID-ключа сервер не трогает подписки.
|
||||
func TestPushSkippedWhenDisabled(t *testing.T) {
|
||||
fixture := newMessagingFixture(t)
|
||||
recorder := &recordingPush{enabled: false}
|
||||
fixture.srv.push = recorder
|
||||
savePushSubscription(t, fixture.srv.store, guildIDOf(t, fixture.memberID), "https://fcm.googleapis.com/fcm/send/off")
|
||||
|
||||
rec := doJSON(t, fixture.srv, http.MethodPost, "/api/v1/channels/"+fixture.openChannel+"/messages",
|
||||
`{"content":"<@`+fixture.memberID+`> привет"}`, fixture.ownerCookie)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("отправка = %d, body = %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if len(recorder.jobs) != 0 {
|
||||
t.Fatalf("выключенный push всё равно отправил: %+v", recorder.jobs)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
@@ -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