From 7afd23d6d4dd799a379e002095fce98ebeab5db3 Mon Sep 17 00:00:00 2001 From: grendervill Date: Sat, 26 Sep 2026 16:14:20 +0300 Subject: [PATCH] =?UTF-8?q?feat(push):=20Web=20Push=20=E2=80=94=20VAPID,?= =?UTF-8?q?=20=D0=BF=D0=BE=D0=B4=D0=BF=D0=B8=D1=81=D0=BA=D0=B8=20=D1=83?= =?UTF-8?q?=D1=81=D1=82=D1=80=D0=BE=D0=B9=D1=81=D1=82=D0=B2=20=D0=B8=20?= =?UTF-8?q?=D0=BE=D1=82=D0=BF=D1=80=D0=B0=D0=B2=D0=BA=D0=B0=20(AGENT.md=20?= =?UTF-8?q?7.16)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Уведомления в браузере и на телефоне: сервер сам решает, кому их слать, и подписывает запрос 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, очистка мёртвых подписок), ручки и лимиты, отправка при личном сообщении и упоминании, пропуск онлайн-получателей, миграция на чистой БД. --- .env.example | 7 + cmd/glchat/cli.go | 40 +- cmd/glchat/main.go | 2 +- deploy/glchat-cli.sh | 10 + deploy/install.sh | 47 +++ go.mod | 2 + go.sum | 69 ++++ internal/config/config.go | 12 + .../migrations/00021_push_subscriptions.sql | 29 ++ internal/database/push_migration_test.go | 104 +++++ internal/gateway/presence.go | 38 ++ internal/httpx/safeurl.go | 146 +++++++ internal/httpx/safeurl_test.go | 141 +++++++ internal/meta/meta.go | 2 +- internal/push/push.go | 343 ++++++++++++++++ internal/push/push_test.go | 369 ++++++++++++++++++ internal/push/rules.go | 235 +++++++++++ internal/push/rules_test.go | 134 +++++++ internal/retention/retention.go | 19 + internal/server/api_messages.go | 3 + internal/server/api_push.go | 255 ++++++++++++ internal/server/api_push_test.go | 354 +++++++++++++++++ internal/server/push_notify.go | 128 ++++++ internal/server/push_notify_test.go | 221 +++++++++++ internal/server/server.go | 28 +- internal/store/push.go | 201 ++++++++++ internal/store/push_test.go | 225 +++++++++++ 27 files changed, 3157 insertions(+), 7 deletions(-) create mode 100644 internal/database/migrations/00021_push_subscriptions.sql create mode 100644 internal/database/push_migration_test.go create mode 100644 internal/gateway/presence.go create mode 100644 internal/httpx/safeurl.go create mode 100644 internal/httpx/safeurl_test.go create mode 100644 internal/push/push.go create mode 100644 internal/push/push_test.go create mode 100644 internal/push/rules.go create mode 100644 internal/push/rules_test.go create mode 100644 internal/server/api_push.go create mode 100644 internal/server/api_push_test.go create mode 100644 internal/server/push_notify.go create mode 100644 internal/server/push_notify_test.go create mode 100644 internal/store/push.go create mode 100644 internal/store/push_test.go diff --git a/.env.example b/.env.example index c9ad3ed..441e75c 100644 --- a/.env.example +++ b/.env.example @@ -51,6 +51,13 @@ OAUTH_DISCORD_CLIENT_SECRET= # Необязательно: внешний адрес инстанса для redirect_uri (пусто — берётся из DOMAIN). OAUTH_REDIRECT_BASE= +# --- Web Push (Фаза 7): уведомления в браузере и на телефоне --- +# Приватный VAPID-ключ (P-256) в PKCS#8 DER (base64, одной строкой). Пусто — +# Web Push выключен. Ключ генерирует установщик; вручную — `glchat vapid-keys`. +VAPID_PRIVATE_KEY= +# Контакт администратора для push-сервисов (mailto:admin@<домен> или https://…). +VAPID_SUBJECT= + AGE_RECIPIENT= # публичный age-ключ для шифрования бэкапов BACKUP_KEEP_DAYS= # сколько дней хранить бэкапы (профиль задаёт значение) BACKUP_OFFSITE_TARGET= # необязательно: rsync-цель (user@host:/path) или rclone:remote:path diff --git a/cmd/glchat/cli.go b/cmd/glchat/cli.go index a34bb8b..3d7f9e2 100644 --- a/cmd/glchat/cli.go +++ b/cmd/glchat/cli.go @@ -11,6 +11,7 @@ import ( "strconv" "time" + webpush "github.com/SherClockHolmes/webpush-go" "github.com/pquerna/otp/totp" "glchat/internal/auth" @@ -41,6 +42,8 @@ func runCLI(args []string) int { return cliCleanup(args[1:]) case "reindex": return cliReindex(args[1:]) + case "vapid-keys": + return cliVAPIDKeys(args[1:]) default: fmt.Fprintf(os.Stderr, "glchat: неизвестная команда %q\n", args[0]) return 2 @@ -399,8 +402,10 @@ func cliCleanup(args []string) int { options.OrphanHours = *orphanHours } stats, err := retention.New(st, options).RunOnce(ctx) - fmt.Printf("удалено: сессий %d, записей аудита %d, файлов %d, файлов без записи %d (%d байт)\n", - stats.Sessions, stats.Audit, stats.Files, stats.Unknown, stats.Bytes) + fmt.Printf("удалено: сессий %d, записей аудита %d, файлов %d, файлов без записи %d, "+ + "подписок Web Push %d (%d байт)\n", + stats.Sessions, stats.Audit, stats.Files, stats.Unknown, + stats.PushSubscriptions, stats.Bytes) return err }) } @@ -415,3 +420,34 @@ func cliReindex(_ []string) int { return nil }) } + +// cliVAPIDKeys печатает пару VAPID-ключей для Web Push (AGENT.md 7.16). +// Установщик генерирует ключ сам; команда нужна тем, кто ставил инстанс иначе +// или меняет ключ вручную. Публичный ключ приложение выводит из приватного, +// поэтому в .env достаточно одной строки. +func cliVAPIDKeys(args []string) int { + flags := flag.NewFlagSet("vapid-keys", flag.ContinueOnError) + subject := flags.String("subject", "", "контакт администратора для push-сервисов (mailto:… или https://…)") + if err := flags.Parse(args); err != nil { + return 2 + } + domain := "localhost" + if cfg, err := config.Load(); err == nil && cfg.Domain != "" { + domain = cfg.Domain + } + contact := *subject + if contact == "" { + contact = "mailto:admin@" + domain + } + privateKey, publicKey, err := webpush.GenerateVAPIDKeys() + if err != nil { + fmt.Fprintf(os.Stderr, "glchat: не удалось сгенерировать ключи: %v\n", err) + return 1 + } + fmt.Println("# Web Push (Фаза 7): строки ниже добавьте в /opt/glchat/.env и перезапустите glchat-app.") + fmt.Printf("VAPID_PRIVATE_KEY=%s\n", privateKey) + fmt.Printf("VAPID_SUBJECT=%s\n", contact) + // Публичный ключ выводится приложением из приватного: печатаем справочно. + fmt.Printf("# публичный ключ: %s\n", publicKey) + return 0 +} diff --git a/cmd/glchat/main.go b/cmd/glchat/main.go index 2614a39..a0dd0f4 100644 --- a/cmd/glchat/main.go +++ b/cmd/glchat/main.go @@ -43,7 +43,7 @@ func main() { if len(os.Args) > 1 { switch os.Args[1] { case "bootstrap-admin", "reset-password", "make-admin", "remove-admin", "totp-setup", "totp-reset", - "create-user", "cleanup", "reindex": + "create-user", "cleanup", "reindex", "vapid-keys": os.Exit(runCLI(os.Args[1:])) } } diff --git a/deploy/glchat-cli.sh b/deploy/glchat-cli.sh index 4d6aa07..30e8b59 100755 --- a/deploy/glchat-cli.sh +++ b/deploy/glchat-cli.sh @@ -45,6 +45,7 @@ glchat — управление инстансом glchat profile [tier] показать/применить профиль производительности glchat security-check аудит: SSH, fail2ban, порты, лимиты glchat reindex пересобрать индекс поиска (FTS5) + glchat vapid-keys сгенерировать пару VAPID-ключей для Web Push glchat cleanup чистка: сессии, аудит, файлы без ссылок glchat create-user создать аккаунты (--count/--prefix/--sessions) glchat change-domain сменить домен @@ -227,6 +228,14 @@ cmd_reindex() { ${COMPOSE} exec -T app glchat reindex } +# cmd_vapid_keys печатает новую пару VAPID-ключей (Фаза 7): строки из вывода +# добавляются в .env инстанса вручную — смена ключа отзывает все подписки, +# поэтому автоматически её никто не делает. +cmd_vapid_keys() { + require_stack + ${COMPOSE} exec -T app glchat vapid-keys "$@" +} + # cmd_cleanup запускает чистку данных вручную: те же правила, что у # периодического обслуживания (AGENT.md 6.4). cmd_cleanup() { @@ -261,6 +270,7 @@ main() { profile) cmd_profile "$@" ;; security-check) cmd_security_check "$@" ;; reindex) cmd_reindex "$@" ;; + vapid-keys) cmd_vapid_keys "$@" ;; verify-backup) cmd_verify_backup "$@" ;; cleanup) cmd_cleanup "$@" ;; create-user) cmd_create_user "$@" ;; diff --git a/deploy/install.sh b/deploy/install.sh index 9e73719..a986d89 100755 --- a/deploy/install.sh +++ b/deploy/install.sh @@ -638,6 +638,24 @@ print("".join(charset[d] for d in out)) PYEOF } +# generate_vapid_key создаёт приватный ключ VAPID (P-256, PKCS#8 DER в base64). +# Публичный ключ приложение выводит из приватного, поэтому в .env хранится +# только приватный. Без openssl ключ не сделать: функция честно возвращает +# ошибку, и Web Push остаётся выключенным. +generate_vapid_key() { + have openssl || return 1 + local tmp key + tmp="$(mktemp -d)" + if ! openssl ecparam -name prime256v1 -genkey -noout -out "${tmp}/key.pem" 2>/dev/null; then + rm -rf "$tmp" + return 1 + fi + key="$(openssl pkcs8 -topk8 -nocrypt -in "${tmp}/key.pem" -outform DER 2>/dev/null | base64 -w0)" + rm -rf "$tmp" + [ -n "$key" ] || return 1 + printf '%s' "$key" +} + generate_secrets() { info "секреты" local env_existing=0 @@ -693,6 +711,25 @@ generate_secrets() { LIVEKIT_API_KEY="$(secret_or_new LIVEKIT_API_KEY rand_hex)" LIVEKIT_API_SECRET="$(secret_or_new LIVEKIT_API_SECRET rand_hex)" + # VAPID-ключ Web Push (Фаза 7, AGENT.md 7.16): приватный ключ P-256 в + # PKCS#8 DER (base64 одной строкой), публичный приложение выводит само. + # Ключ переиспользуется при повторной установке: его смена отозвала бы все + # подписки браузеров, поэтому она возможна только с --rotate-secrets. + VAPID_PRIVATE_KEY="$(secret_or_new VAPID_PRIVATE_KEY generate_vapid_key || true)" + if [ -z "$VAPID_PRIVATE_KEY" ]; then + warn "VAPID-ключ не сгенерирован: Web Push будет выключен (нужен openssl)" + fi + # Контакт администратора для push-сервисов: значение можно задать заранее, + # иначе берём email администратора или адрес домена. + VAPID_SUBJECT="$(oauth_value VAPID_SUBJECT)" + if [ -z "$VAPID_SUBJECT" ]; then + if [ -n "${ADMIN_EMAIL:-}" ]; then + VAPID_SUBJECT="mailto:${ADMIN_EMAIL}" + else + VAPID_SUBJECT="mailto:admin@${DOMAIN}" + fi + fi + # OAuth (Фаза 7) не генерируется: клиентские id/секреты выдаёт провайдер. # Значение берём из окружения, иначе сохраняем прежнее из .env — иначе # `--reconfigure` молча выключил бы вход через провайдеров. @@ -886,6 +923,12 @@ OAUTH_DISCORD_CLIENT_SECRET=@OAUTH_DISCORD_CLIENT_SECRET@ # Необязательно: внешний адрес инстанса для redirect_uri (пусто — из домена). OAUTH_REDIRECT_BASE=@OAUTH_REDIRECT_BASE@ +# --- Web Push (Фаза 7) --- +# Приватный VAPID-ключ (PKCS#8 DER в base64). Пусто — Web Push выключен. +VAPID_PRIVATE_KEY=@VAPID_PRIVATE_KEY@ +# Контакт администратора для push-сервисов: mailto:… или https://… +VAPID_SUBJECT=@VAPID_SUBJECT@ + # --- функции --- REGISTRATION_ENABLED=@REGISTRATION_ENABLED@ ANTI_BOT_ENABLED=@ANTI_BOT_ENABLED@ @@ -966,6 +1009,10 @@ OAUTH_DISCORD_CLIENT_ID=$(env_quote "$OAUTH_DISCORD_CLIENT_ID") OAUTH_DISCORD_CLIENT_SECRET=$(env_quote "$OAUTH_DISCORD_CLIENT_SECRET") OAUTH_REDIRECT_BASE=$(env_quote "$OAUTH_REDIRECT_BASE") +# Web Push (Фаза 7): приватный VAPID-ключ переиспользуется при переустановке. +VAPID_PRIVATE_KEY=${VAPID_PRIVATE_KEY} +VAPID_SUBJECT=$(env_quote "$VAPID_SUBJECT") + # Offsite-бэкапы: цель и срок хранения задаются вручную после установки. BACKUP_OFFSITE_TARGET=${BACKUP_OFFSITE_TARGET:-} BACKUP_OFFSITE_KEEP_DAYS=${BACKUP_OFFSITE_KEEP_DAYS:-} diff --git a/go.mod b/go.mod index 91f8e5c..d1cf261 100644 --- a/go.mod +++ b/go.mod @@ -16,6 +16,7 @@ require ( ) require ( + github.com/SherClockHolmes/webpush-go v1.4.0 // indirect github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc // indirect github.com/go-viper/mapstructure/v2 v2.5.0 // indirect github.com/go-webauthn/x v0.3.1 // indirect @@ -28,6 +29,7 @@ require ( github.com/tinylib/msgp v1.6.4 // indirect github.com/x448/float16 v0.8.4 // indirect go.uber.org/multierr v1.11.0 // indirect + golang.org/x/net v0.59.0 // indirect golang.org/x/sync v0.22.0 // indirect golang.org/x/sys v0.48.0 // indirect ) diff --git a/go.sum b/go.sum index d72725e..a3990db 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,5 @@ +github.com/SherClockHolmes/webpush-go v1.4.0 h1:ocnzNKWN23T9nvHi6IfyrQjkIc0oJWv1B1pULsf9i3s= +github.com/SherClockHolmes/webpush-go v1.4.0/go.mod h1:XSq8pKX11vNV8MJEMwjrlTkxhAj1zKfxmyhdV7Pd6UA= github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc h1:biVzkmvwrH8WK8raXaxBx6fRVTlJILwEwQGL1I/ByEI= github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8= github.com/coder/websocket v1.8.15 h1:6B2JPeOGlpff2Uz6vOEH1Vzpi0iUz20A+lPVhPHtNUA= @@ -17,8 +19,10 @@ github.com/go-webauthn/webauthn v0.18.2 h1:0BeftmEHU7i3Dv0VFwBtidy/ba37Vcdjvqst9 github.com/go-webauthn/webauthn v0.18.2/go.mod h1:hEXaOuLxvZ3zG9miZe3ehlyeVso9AtklXG+kTn36k+A= github.com/go-webauthn/x v0.3.1 h1:1ff37z3XfmTTomkhlURgGizLIDyOvPgTt2t9nlzKLRo= github.com/go-webauthn/x v0.3.1/go.mod h1:ZInxAynYXfBPvvm5gzKZ7geBlL23K71xASMgohHl/Rg= +github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo= github.com/google/go-tpm v0.9.8/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY= github.com/google/go-tpm-tools v0.3.13-0.20230620182252-4639ecce2aba h1:qJEJcuLzH5KDR0gKc0zcktin6KSAwL7+jWKBYceddTc= @@ -52,20 +56,85 @@ github.com/tinylib/msgp v1.6.4 h1:mOwYbyYDLPj35mkA2BjjYejgJk9BuHxDdvRnb6v2ZcQ= github.com/tinylib/msgp v1.6.4/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA= github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y= go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU= go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw= go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc= +golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= +golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= +golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M= golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= +golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk= +golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= +golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= +golang.org/x/net v0.59.0 h1:5zfYln+w5XCxwrnMMJPufRgNoXEaGxl0wo5GqPXyues= +golang.org/x/net v0.59.0/go.mod h1:2DA/G1UfVbCpQPeWTmMPGY7Cs2PkBkwu743bVX5PIVg= golang.org/x/oauth2 v0.37.0 h1:JUlcxA8oAtauLfiH8FX2/FkAWHAdi0QtGCGc+hofE98= golang.org/x/oauth2 v0.37.0/go.mod h1:IxwZNxUULJmpBFf9K/9NTMSIfZZuvuTy1gGxhigP/58= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= +golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo= golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og= +golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= +golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= +golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU= +golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= +golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY= +golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= +golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= +golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= +golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= modernc.org/libc v1.75.6 h1:yKk8qo+Di4gkmvRboK8ocCqH22FiUCR6jRy2OwtCRus= modernc.org/libc v1.75.6/go.mod h1:bO5o2ztHxBb2rjz0PgdHN0sSMw57CgxGFLZ3Qd/QpVQ= modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= diff --git a/internal/config/config.go b/internal/config/config.go index 1c33639..cc07b5a 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -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`, а не ломает вход. diff --git a/internal/database/migrations/00021_push_subscriptions.sql b/internal/database/migrations/00021_push_subscriptions.sql new file mode 100644 index 0000000..22b68a9 --- /dev/null +++ b/internal/database/migrations/00021_push_subscriptions.sql @@ -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; diff --git a/internal/database/push_migration_test.go b/internal/database/push_migration_test.go new file mode 100644 index 0000000..71d9d8b --- /dev/null +++ b/internal/database/push_migration_test.go @@ -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) + } +} diff --git a/internal/gateway/presence.go b/internal/gateway/presence.go new file mode 100644 index 0000000..9f9ba1b --- /dev/null +++ b/internal/gateway/presence.go @@ -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) +} diff --git a/internal/httpx/safeurl.go b/internal/httpx/safeurl.go new file mode 100644 index 0000000..8cd50b0 --- /dev/null +++ b/internal/httpx/safeurl.go @@ -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 +} diff --git a/internal/httpx/safeurl_test.go b/internal/httpx/safeurl_test.go new file mode 100644 index 0000000..f191fdd --- /dev/null +++ b/internal/httpx/safeurl_test.go @@ -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 с проверкой адреса") + } +} diff --git a/internal/meta/meta.go b/internal/meta/meta.go index 8f0bbd7..372b443 100644 --- a/internal/meta/meta.go +++ b/internal/meta/meta.go @@ -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(), diff --git a/internal/push/push.go b/internal/push/push.go new file mode 100644 index 0000000..c3530c5 --- /dev/null +++ b/internal/push/push.go @@ -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) } diff --git a/internal/push/push_test.go b/internal/push/push_test.go new file mode 100644 index 0000000..234a799 --- /dev/null +++ b/internal/push/push_test.go @@ -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) + } + } +} diff --git a/internal/push/rules.go b/internal/push/rules.go new file mode 100644 index 0000000..f80d69a --- /dev/null +++ b/internal/push/rules.go @@ -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) +} diff --git a/internal/push/rules_test.go b/internal/push/rules_test.go new file mode 100644 index 0000000..5039f2d --- /dev/null +++ b/internal/push/rules_test.go @@ -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) + } +} diff --git a/internal/retention/retention.go b/internal/retention/retention.go index c0b4899..5ea536e 100644 --- a/internal/retention/retention.go +++ b/internal/retention/retention.go @@ -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)) } } diff --git a/internal/server/api_messages.go b/internal/server/api_messages.go index 03e5d77..f6c79b5 100644 --- a/internal/server/api_messages.go +++ b/internal/server/api_messages.go @@ -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 diff --git a/internal/server/api_push.go b/internal/server/api_push.go new file mode 100644 index 0000000..cbd34e5 --- /dev/null +++ b/internal/server/api_push.go @@ -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]) +} diff --git a/internal/server/api_push_test.go b/internal/server/api_push_test.go new file mode 100644 index 0000000..df32eda --- /dev/null +++ b/internal/server/api_push_test.go @@ -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") + } +} diff --git a/internal/server/push_notify.go b/internal/server/push_notify.go new file mode 100644 index 0000000..2129990 --- /dev/null +++ b/internal/server/push_notify.go @@ -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 +} diff --git a/internal/server/push_notify_test.go b/internal/server/push_notify_test.go new file mode 100644 index 0000000..29912d4 --- /dev/null +++ b/internal/server/push_notify_test.go @@ -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) + } +} diff --git a/internal/server/server.go b/internal/server/server.go index d499629..2a47627 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -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) }) diff --git a/internal/store/push.go b/internal/store/push.go new file mode 100644 index 0000000..652d042 --- /dev/null +++ b/internal/store/push.go @@ -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 +} diff --git a/internal/store/push_test.go b/internal/store/push_test.go new file mode 100644 index 0000000..06df7b4 --- /dev/null +++ b/internal/store/push_test.go @@ -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) + } +}