Этап 4: PWA и пуши — service worker, Web Push с VAPID, баннер установки

Сервер: пакет push на webpush-go (третья и последняя прямая зависимость),
подписка устройства, правило ADR-023 «пуш только молчащему устройству и только
один» через атомарный захват push_pending, удаление подписки на 404 и 410,
TTL сутки, urgency normal. В нагрузке только {title, body, chat} — тело
собирается из константы, плейнтекст туда не попадает даже по ошибке.

Клиент: service worker с версионированным кэшем оболочки и никогда — /api/*,
push и notificationclick, подписка на VAPID-ключ сервера, запрос разрешения
после первого отправленного сообщения, разделы настроек «уведомления»
и «установить приложение», баннер установки на iOS.

ADR-045: пуш адресован получателю — по букве ADR-023 он уходил бы и молчащему
устройству отправителя с бессмысленным заголовком из собственного ника.
ADR-047: сервер ходит на endpoint подписки, который выбирает браузер. Проверка
«только https» обходилась редиректом, а имя могло смотреть внутрь сети — теперь
запрет редиректов и проверка разрешённого адреса на уровне сокета.
ADR-048: пределы отправки — недоступный push-сервис одного аккаунта больше
не съедает пуши всего сервера.
ADR-049: явно выключенные уведомления сами не включаются обратно.

Попутно: webpush-go дописывает набивку в переданный срез, а одна нагрузка
уходила всем устройствам сообщения — гонка, пойманная go test -race.
Теперь у каждого задания своя копия.

Приёмка на боевом: подписки, hasPush, чужое устройство, Origin, оболочка
из девяти файлов, Service-Worker-Allowed. Отдельно шесть непубличных адресов
и endpoint на 3 КиБ — все отбиты.

Чеклист ручной проверки на iPhone и Android — в docs/plan.md.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015DbCjVfTFq4ZFG8juD45YJ
This commit is contained in:
2026-08-23 00:50:58 +03:00
co-authored by Claude Opus 5
parent 05586218e1
commit 8f67f4aa4d
42 changed files with 3047 additions and 92 deletions
+22 -6
View File
@@ -16,6 +16,7 @@ import (
"github.com/xmatic-squad/bare/internal/auth"
"github.com/xmatic-squad/bare/internal/config"
"github.com/xmatic-squad/bare/internal/hub"
"github.com/xmatic-squad/bare/internal/push"
"github.com/xmatic-squad/bare/internal/store"
)
@@ -34,27 +35,39 @@ type server struct {
cfg *config.Config
st *store.Store
hub *hub.Hub
push *push.Sender
msgs *buckets
logw io.Writer
}
// Handler — обработчик всех маршрутов и живые SSE-потоки за ним.
// Handler — обработчик всех маршрутов, живые SSE-потоки и очередь пушей
// за ним.
type Handler struct {
http.Handler
hub *hub.Hub
hub *hub.Hub
push *push.Sender
}
// Close закрывает открытые потоки событий. Без него остановка сервера
// ждала бы, пока клиенты уйдут сами: у потока нет конца (ADR-004).
func (h *Handler) Close() { h.hub.CloseAll() }
// Close закрывает открытые потоки событий и останавливает отправку
// пушей. Без него остановка сервера ждала бы, пока клиенты уйдут сами:
// у потока нет конца (ADR-004).
func (h *Handler) Close() {
h.hub.CloseAll()
h.push.Close()
}
// New собирает обработчик: /api/, /healthz, всё остальное — статика.
// logw — куда писать строки запросов и причины отказов; nil отключает лог.
func New(cfg *config.Config, st *store.Store, static http.Handler, logw io.Writer) *Handler {
// Отправитель пушей спрашивает у hub, подключено ли устройство:
// решение «пуш только молчащему» принимается в момент захвата права
// на него, а не при постановке в очередь (ADR-023).
live := hub.New()
s := &server{
cfg: cfg,
st: st,
hub: hub.New(),
hub: live,
push: push.New(cfg, st, live.Connected, logw),
msgs: newBuckets(messagesPerMinute, messagesBurst),
logw: logw,
}
@@ -79,6 +92,8 @@ func New(cfg *config.Config, st *store.Store, static http.Handler, logw io.Write
mux.Handle("POST /api/devices", private(http.HandlerFunc(s.createDevice)))
mux.Handle("GET /api/devices", private(http.HandlerFunc(s.devices)))
mux.Handle("DELETE /api/devices/{id}", private(http.HandlerFunc(s.deleteDevice)))
mux.Handle("PUT /api/devices/{id}/push", private(http.HandlerFunc(s.setPush)))
mux.Handle("DELETE /api/devices/{id}/push", private(http.HandlerFunc(s.deletePush)))
mux.Handle("GET /api/contacts", private(http.HandlerFunc(s.contacts)))
mux.Handle("POST /api/contacts", private(http.HandlerFunc(s.addContact)))
@@ -103,6 +118,7 @@ func New(cfg *config.Config, st *store.Store, static http.Handler, logw io.Write
return &Handler{
Handler: logging(logw, headers(auth.Origin(cfg.Origin, fail)(limitBody(mux)))),
hub: s.hub,
push: s.push,
}
}
+12 -2
View File
@@ -58,6 +58,12 @@ func newEnv(t *testing.T) *env { return invited(t, "") }
// invited — сервер на временной базе; непустой code включает инвайты.
func invited(t *testing.T, code string) *env {
t.Helper()
return envWith(t, func(cfg *config.Config) { cfg.InviteCode = code })
}
// envWith — сервер на временной базе; tweak правит конфигурацию до старта.
func envWith(t *testing.T, tweak func(*config.Config)) *env {
t.Helper()
static, err := web.New()
if err != nil {
@@ -74,10 +80,14 @@ func invited(t *testing.T, code string) *env {
DB: "bare.db",
Origin: origin,
VAPIDPublic: "vapid",
InviteCode: code,
}
tweak(cfg)
e := &env{t: t, st: st, log: &syncLog{}}
e.h = api.New(cfg, st, static, e.log)
h := api.New(cfg, st, static, e.log)
// Обработчик закрывается раньше базы: отправщики пушей дописывают
// начатое, а база им ещё нужна.
t.Cleanup(h.Close)
e.h = h
return e
}
-5
View File
@@ -80,11 +80,6 @@ func TestDevices(t *testing.T) {
if list[0].Current || !list[1].Current {
t.Errorf("текущее устройство второй сессии: %+v", list)
}
// Push-подписка — этап 4: пути ещё нет, а неизвестный путь отвечает
// 404 not_found (ADR-026).
expect(t, e.do(http.MethodPut, "/api/devices/"+id+"/push", map[string]any{}, with(c)),
http.StatusNotFound, "not_found")
}
// Занятый чужим идентификатор — 409: клиент берёт новый (ADR-017).
+40 -9
View File
@@ -8,6 +8,7 @@ import (
"github.com/xmatic-squad/bare/internal/auth"
"github.com/xmatic-squad/bare/internal/hub"
"github.com/xmatic-squad/bare/internal/push"
"github.com/xmatic-squad/bare/internal/store"
)
@@ -76,22 +77,29 @@ func (s *server) sendMessage(w http.ResponseWriter, r *http.Request) {
sess, _ := auth.From(r)
room := in.To.Room != ""
// Заголовок и адрес чата для пуша: сервер собирает их из того, что
// и так знает, — из ников и имени комнаты (ADR-023).
var signal push.Payload
if room {
member, knownKey, err := s.st.RoomAccess(r.Context(), in.To.Room, sess.Nick, in.KeyID)
access, err := s.st.RoomAccess(r.Context(), in.To.Room, sess.Nick, in.KeyID)
if err != nil {
s.internal(w, r, err)
return
}
if !member {
if !access.Member {
Error(w, http.StatusForbidden, "not_member", "вы не участник комнаты")
return
}
if !knownKey {
if !access.KnownKey {
Error(w, http.StatusBadRequest, "unknown_key", "у комнаты нет такого ключа")
return
}
} else if _, ok := s.peer(w, r, in.To.DM, sess.Nick); !ok {
return
signal = push.Payload{Title: "#" + access.Name, Chat: "room:" + in.To.Room}
} else {
if _, ok := s.peer(w, r, in.To.DM, sess.Nick); !ok {
return
}
signal = push.Payload{Title: "@" + sess.Nick, Chat: "dm:" + sess.Nick}
}
if wait, ok := s.msgs.take(sess.Nick, now); !ok {
@@ -122,7 +130,7 @@ func (s *server) sendMessage(w http.ResponseWriter, r *http.Request) {
Envelope: string(raw),
Now: env.TS,
}
var devices []string
var devices []store.Target
if room {
devices, err = s.st.DeliverRoom(r.Context(), delivery)
} else {
@@ -133,16 +141,39 @@ func (s *server) sendMessage(w http.ResponseWriter, r *http.Request) {
return
}
// Очередь уже записана: подключённое устройство получает конверт
// сразу, остальные — при подключении. Пуши — этап 4.
for _, id := range devices {
s.hub.Send(id, hub.Event{Name: "msg", Data: string(raw)})
// сразу, остальные — при подключении.
for _, target := range devices {
s.hub.Send(target.ID, hub.Event{Name: "msg", Data: string(raw)})
}
// Пуш — побочный эффект доставки, а не её часть: конверт уже
// в очереди, и ответ на запрос отправку пуша не ждёт (ADR-023).
s.push.Send(s.silent(devices, env.From), signal)
writeJSON(w, http.StatusAccepted, struct {
ID string `json:"id"`
TS int64 `json:"ts"`
}{env.ID, env.TS})
}
// silent — устройства, которым нужен пуш: чужие (устройства отправителя
// пуша не получают, ADR-045), подписанные и молчащие — те, что не держат
// поток событий (ADR-023).
//
// Устройство без подписки отсеивается здесь: отправить ему нечего,
// а место в очереди отправки оно заняло бы (ADR-048). Проверка на
// подключение — ранний отсев: решает её повтор в момент захвата права
// на пуш, потому что между этой строкой и отправкой устройство успевает
// подключиться (ADR-023).
func (s *server) silent(targets []store.Target, from string) []push.Target {
var out []push.Target
for _, target := range targets {
if target.Nick == from || !target.HasPush || s.hub.Connected(target.ID) {
continue
}
out = append(out, push.Target{Device: target.ID, Owner: target.Nick})
}
return out
}
// checkForm проверяет форму полей конверта (docs/crypto.md, «Что сервер
// проверяет») и отдаёт метку времени из ULID. Ответ об ошибке уже написан,
// если вернулось false.
+163
View File
@@ -0,0 +1,163 @@
package api
import (
"crypto/ecdh"
"encoding/base64"
"encoding/json"
"net/http"
"net/netip"
"net/url"
"github.com/xmatic-squad/bare/internal/auth"
"github.com/xmatic-squad/bare/internal/push"
)
// Push-подписка принадлежит устройству (ADR-023): её ставит и снимает
// само устройство. Сервер хранит подписку как непрозрачный JSON и лезет
// в неё только при отправке.
// Длины ключей подписки (RFC 8291): p256dh — несжатая точка P-256,
// auth — общий секрет.
const (
p256dhLen = 65
authLen = 16
// maxEndpoint — предел длины адреса подписки. Адреса вендоров —
// две-три сотни символов; всё остальное push-сервисом не является,
// а прочие поля протокола ограничены явно (docs/protocol.md).
maxEndpoint = 2 << 10
)
// subscriptionIn — объект PushSubscription.toJSON(). Поле expirationTime
// браузеры кладут рядом; сервер его не читает и не хранит — хранится
// ровно то, что нужно для отправки.
type subscriptionIn struct {
Endpoint string `json:"endpoint"`
Keys struct {
P256dh string `json:"p256dh"`
Auth string `json:"auth"`
} `json:"keys"`
}
// PUT /api/devices/{id}/push — подписка устройства на пуши. Сбрасывает
// неотработанный пуш: устройство снова готово его принять (ADR-023).
//
// X-Device на этом маршруте обязателен, и устройство в пути тоже обязано
// быть своим: чужому устройству подписку не поставить (docs/protocol.md,
// «Общие правила»).
func (s *server) setPush(w http.ResponseWriter, r *http.Request) {
var in struct {
Subscription subscriptionIn `json:"subscription"`
}
if !decode(w, r, &in) {
return
}
// Форма проверяется раньше прав (ADR-043).
subscription, ok := checkSubscription(w, in.Subscription)
if !ok {
return
}
if _, ok := s.device(w, r); !ok {
return
}
sess, _ := auth.From(r)
set, err := s.st.SetPush(r.Context(), r.PathValue("id"), sess.Nick, subscription)
if err != nil {
s.internal(w, r, err)
return
}
if !set {
unknownDevice(w)
return
}
noContent(w)
}
// DELETE /api/devices/{id}/push — снять подписку. Подписки не было —
// тот же 204: снимать нечего. Чужое устройство — 403, как и на PUT.
func (s *server) deletePush(w http.ResponseWriter, r *http.Request) {
if _, ok := s.device(w, r); !ok {
return
}
sess, _ := auth.From(r)
cleared, err := s.st.ClearPush(r.Context(), r.PathValue("id"), sess.Nick)
if err != nil {
s.internal(w, r, err)
return
}
if !cleared {
unknownDevice(w)
return
}
noContent(w)
}
// checkSubscription проверяет форму подписки и отдаёт её канонический
// JSON: три поля и ничего больше. Ответ об ошибке уже написан, если
// вернулось false.
func checkSubscription(w http.ResponseWriter, in subscriptionIn) (string, bool) {
if !validEndpoint(in.Endpoint) {
Invalid(w, "subscription", "endpoint — не публичный https-url до 2 КиБ")
return "", false
}
if !pushPoint(in.Keys.P256dh) {
Invalid(w, "subscription", "keys.p256dh — не точка p-256 в 65 байтах base64url")
return "", false
}
if _, ok := pushKey(in.Keys.Auth, authLen); !ok {
Invalid(w, "subscription", "keys.auth — не 16 байт base64url")
return "", false
}
out, err := json.Marshal(in)
if err != nil {
return "", false
}
return string(out), true
}
// validEndpoint — адрес push-сервиса. Выбирает его браузер, сервер знает
// о нём только то, что это абсолютный https-url разумной длины: без TLS
// пуш ушёл бы открытым текстом мимо всех обещаний.
//
// Литеральный непубличный адрес отвергается сразу: push-сервиса по нему
// не бывает, а внутренняя служба бывает (ADR-047). Имя здесь не
// разрешается — за именем всё равно может стоять внутренний адрес,
// поэтому решающая проверка идёт при соединении, в отправщике.
func validEndpoint(raw string) bool {
if raw == "" || len(raw) > maxEndpoint {
return false
}
u, err := url.Parse(raw)
if err != nil || u.Scheme != "https" || u.Host == "" {
return false
}
ip, err := netip.ParseAddr(u.Hostname())
if err != nil {
// Не литерал, а имя: его разберёт отправщик.
return true
}
return push.Public(ip)
}
// pushPoint — p256dh: несжатая точка кривой P-256. Одной длины мало:
// случайные 65 байт точкой не являются, отправка на них падает при
// каждом сообщении, а устройство остаётся с подпиской, которая никогда
// не заработает (docs/protocol.md, «Устройства»).
func pushPoint(s string) bool {
raw, ok := pushKey(s, p256dhLen)
if !ok {
return false
}
_, err := ecdh.P256().NewPublicKey(raw)
return err == nil
}
// pushKey — ключ подписки: ровно n байт base64url. Push API задаёт форму
// без паддинга, но браузер, добавивший паддинг, не должен остаться без
// уведомлений: webpush-go разбирает обе формы, и сервер принимает обе.
func pushKey(s string, n int) ([]byte, bool) {
if raw, err := b64.DecodeString(s); err == nil {
return raw, len(raw) == n
}
raw, err := base64.URLEncoding.DecodeString(s)
return raw, err == nil && len(raw) == n
}
+806
View File
@@ -0,0 +1,806 @@
package api_test
import (
"bytes"
"context"
"crypto/aes"
"crypto/cipher"
"crypto/ecdh"
"crypto/hkdf"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
"github.com/xmatic-squad/bare/internal/config"
)
// quiet — сколько ждём, чтобы убедиться, что пуша нет. Всё локально,
// задержек быть не должно.
const quiet = 300 * time.Millisecond
// pushEnv — сервер с настоящей парой VAPID-ключей: без неё пуши выключены
// (docs/deploy.md).
func pushEnv(t *testing.T) *env { return pushEnvWith(t, true) }
// pushEnvWith — то же; local разрешает отправку на 127.0.0.1, где живёт
// подменный push-сервис. Настоящий сервер ходит только по публичным
// адресам (ADR-047), и это проверяется отдельно.
func pushEnvWith(t *testing.T, local bool) *env {
t.Helper()
key, err := ecdh.P256().GenerateKey(rand.Reader)
if err != nil {
t.Fatalf("vapid: %v", err)
}
return envWith(t, func(cfg *config.Config) {
cfg.VAPIDPublic = raw64(key.PublicKey().Bytes())
cfg.VAPIDPrivate = raw64(key.Bytes())
cfg.VAPIDSubject = "mailto:bare@bare.test"
// Push-сервис вендора подменён сервером на 127.0.0.1: в работе
// отправщик ходит только по публичным адресам (ADR-047).
cfg.PushLocal = local
})
}
func raw64(b []byte) string { return base64.RawURLEncoding.EncodeToString(b) }
// padded64 — то же, что bytesOf, но с паддингом: браузер вправе прислать
// ключи подписки и в такой форме.
func padded64(n int, seed byte) string {
raw := make([]byte, n)
for i := range raw {
raw[i] = seed + byte(i)
}
return base64.URLEncoding.EncodeToString(raw)
}
// point65 — p256dh настоящей подписки: несжатая точка P-256 в 65 байтах.
// Случайные байты той же длины точкой не являются, и сервер их не примет
// (docs/protocol.md, «Устройства»).
func point65(t *testing.T) []byte {
t.Helper()
key, err := ecdh.P256().GenerateKey(rand.Reader)
if err != nil {
t.Fatalf("ключ подписки: %v", err)
}
return key.PublicKey().Bytes()
}
// pushService — push-сервис вендора в тесте. Настоящий FCM тестам не нужен
// и не годится: проверяется, что уходит и что сервер делает с ответом.
type pushService struct {
t *testing.T
url string
got chan delivered
status atomic.Int32
}
// delivered — то, что увидел push-сервис.
type delivered struct {
device string // хвост endpoint: по нему видно, чей это пуш
ttl string
urgency string
encoding string
auth string
record []byte
}
func newPushService(t *testing.T) *pushService {
t.Helper()
open := make(chan struct{})
close(open)
return pushServiceWith(t, open)
}
// newSlowPushService — push-сервис, который принимает запрос и молчит,
// пока тест не отпустит его. Так видно, что делает сервер, пока отправка
// ещё идёт. Отпускать обязательно: иначе остановка сервера ждёт таймаута.
func newSlowPushService(t *testing.T) (*pushService, func()) {
t.Helper()
gate := make(chan struct{})
var once sync.Once
return pushServiceWith(t, gate), func() { once.Do(func() { close(gate) }) }
}
// pushServiceWith — push-сервис, отвечающий не раньше, чем закроется gate.
func pushServiceWith(t *testing.T, gate <-chan struct{}) *pushService {
t.Helper()
p := &pushService{t: t, got: make(chan delivered, 512)}
p.status.Store(http.StatusCreated)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
record, _ := io.ReadAll(r.Body)
got := delivered{
device: strings.TrimPrefix(r.URL.Path, "/push/"),
ttl: r.Header.Get("TTL"),
urgency: r.Header.Get("Urgency"),
encoding: r.Header.Get("Content-Encoding"),
auth: r.Header.Get("Authorization"),
record: record,
}
select {
case p.got <- got:
default:
}
select {
case <-gate:
case <-r.Context().Done():
return
}
w.WriteHeader(int(p.status.Load()))
}))
t.Cleanup(srv.Close)
p.url = srv.URL
return p
}
// next — следующий пуш; его отсутствие — ошибка теста.
func (p *pushService) next() delivered {
p.t.Helper()
select {
case got := <-p.got:
return got
case <-time.After(wait):
p.t.Fatal("пуш не пришёл")
}
return delivered{}
}
// silent требует, чтобы других пушей не было.
func (p *pushService) silent() {
p.t.Helper()
select {
case got := <-p.got:
p.t.Fatalf("лишний пуш устройству %s", got.device)
case <-time.After(quiet):
}
}
// subscriber — устройство с push-подпиской. Ключи настоящие: тест
// расшифровывает пуш ровно так, как это сделал бы браузер (RFC 8291),
// и потому видит, что в нём лежит.
type subscriber struct {
device string
key *ecdh.PrivateKey
auth []byte
}
// subscribe кладёт подписку устройства прямо в базу. Через PUT её сюда
// не поставить: тестовый push-сервис живёт на http, а эндпоинт принимает
// только https. Форму подписки проверяют TestPushSubscription
// и TestPushSubscriptionForm, правила отправки от неё не зависят.
func (e *env) subscribe(nick, device string, p *pushService) *subscriber {
e.t.Helper()
key, err := ecdh.P256().GenerateKey(rand.Reader)
if err != nil {
e.t.Fatalf("ключ подписки: %v", err)
}
auth := make([]byte, 16)
if _, err := rand.Read(auth); err != nil {
e.t.Fatalf("секрет подписки: %v", err)
}
raw, err := json.Marshal(map[string]any{
"endpoint": p.url + "/push/" + device,
"keys": map[string]string{
"p256dh": raw64(key.PublicKey().Bytes()),
"auth": raw64(auth),
},
})
if err != nil {
e.t.Fatalf("подписка: %v", err)
}
set, err := e.st.SetPush(context.Background(), device, nick, string(raw))
if err != nil || !set {
e.t.Fatalf("SetPush: %v (поставлена: %v)", err, set)
}
return &subscriber{device: device, key: key, auth: auth}
}
// open расшифровывает пуш: aes128gcm по RFC 8291, как это делает браузер.
// Без расшифровки нельзя утверждать, что в пуше нет ничего лишнего.
func (s *subscriber) open(t *testing.T, record []byte) map[string]string {
t.Helper()
check := func(what string, err error) {
t.Helper()
if err != nil {
t.Fatalf("%s: %v", what, err)
}
}
// Заголовок записи: соль, размер записи, длина открытого ключа.
const header = 16 + 4 + 1
if len(record) < header {
t.Fatalf("запись короче заголовка: %d байт", len(record))
}
salt := record[:16]
keyLen := int(record[20])
if len(record) < header+keyLen {
t.Fatalf("запись короче ключа отправителя: %d байт", len(record))
}
sender, ct := record[header:header+keyLen], record[header+keyLen:]
remote, err := ecdh.P256().NewPublicKey(sender)
check("ключ отправителя", err)
shared, err := s.key.ECDH(remote)
check("ecdh", err)
info := append([]byte("WebPush: info\x00"), s.key.PublicKey().Bytes()...)
info = append(info, sender...)
ikm, err := hkdf.Key(sha256.New, shared, s.auth, string(info), 32)
check("ikm", err)
cek, err := hkdf.Key(sha256.New, ikm, salt, "Content-Encoding: aes128gcm\x00", 16)
check("ключ записи", err)
nonce, err := hkdf.Key(sha256.New, ikm, salt, "Content-Encoding: nonce\x00", 12)
check("nonce", err)
block, err := aes.NewCipher(cek)
check("aes", err)
gcm, err := cipher.NewGCM(block)
check("gcm", err)
plain, err := gcm.Open(nil, nonce, ct, nil)
check("расшифровка", err)
// Хвост записи — набивка: нули после разделителя 0x02.
plain = bytes.TrimSuffix(bytes.TrimRight(plain, "\x00"), []byte{2})
var out map[string]string
if err := json.Unmarshal(plain, &out); err != nil {
t.Fatalf("нагрузка %q: %v", plain, err)
}
return out
}
// hasPush — что о подписке устройства говорит GET /api/devices.
func (e *env) hasPush(c *http.Cookie, device string) bool {
e.t.Helper()
rec := e.do(http.MethodGet, "/api/devices", nil, with(c))
expect(e.t, rec, http.StatusOK, "")
var list []struct {
ID string `json:"id"`
HasPush bool `json:"hasPush"`
}
decodeBody(e.t, rec, &list)
for _, got := range list {
if got.ID == device {
return got.HasPush
}
}
e.t.Fatalf("устройства %s нет в списке", device)
return false
}
// waitPushGone ждёт, пока подписка исчезнет: снимает её отправщик, уже
// после того, как push-сервис ответил.
func (e *env) waitPushGone(c *http.Cookie, device string) {
e.t.Helper()
for deadline := time.Now().Add(wait); time.Now().Before(deadline); {
if !e.hasPush(c, device) {
return
}
time.Sleep(5 * time.Millisecond)
}
e.t.Fatalf("подписка устройства %s не снята", device)
}
// send — обычная отправка личного сообщения.
func (e *env) send(c *http.Cookie, device, to string, seed byte) {
e.t.Helper()
expect(e.t, e.do(http.MethodPost, "/api/messages", message(ulid(nowMillis(), seed), to),
with(c), withDevice(device)), http.StatusAccepted, "")
}
// pushEventually шлёт сообщения, пока не придёт пуш. И разрыв потока,
// и возврат права на пуш случаются после ответа на запрос: момент их
// наступления не назначить, поэтому попытка повторяется.
func (e *env) pushEventually(p *pushService, c *http.Cookie, device, to string) delivered {
e.t.Helper()
for i := 0; i < 8; i++ {
e.send(c, device, to, byte(50+i))
select {
case got := <-p.got:
return got
case <-time.After(200 * time.Millisecond):
}
}
e.t.Fatal("пуш так и не пришёл")
return delivered{}
}
// subscription — тело PUT /api/devices/{id}/push в форме
// PushSubscription.toJSON().
func subscription(t *testing.T) map[string]any {
t.Helper()
return map[string]any{
"endpoint": "https://push.example/one",
"expirationTime": nil,
"keys": map[string]string{"p256dh": raw64(point65(t)), "auth": bytesOf(16, 7)},
}
}
// Подписка ставится и снимается, hasPush честный, чужое устройство — 403
// (docs/protocol.md, «Устройства», «Общие правила»).
func TestPushSubscription(t *testing.T) {
e := newEnv(t)
marta, m1 := e.join("marta", 1)
petya, p1 := e.join("petya", 2)
body := map[string]any{"subscription": subscription(t)}
if e.hasPush(marta, m1) {
t.Error("hasPush до подписки: true")
}
expect(t, e.do(http.MethodPut, "/api/devices/"+m1+"/push", body, with(marta), withDevice(m1)),
http.StatusNoContent, "")
if !e.hasPush(marta, m1) {
t.Error("hasPush после подписки: false")
}
// Подписка принадлежит устройству: у соседа её не появилось.
if e.hasPush(petya, p1) {
t.Error("подписка досталась чужому устройству")
}
expect(t, e.do(http.MethodDelete, "/api/devices/"+m1+"/push", nil, with(marta), withDevice(m1)),
http.StatusNoContent, "")
if e.hasPush(marta, m1) {
t.Error("hasPush после снятия: true")
}
// Снимать нечего — тот же 204.
expect(t, e.do(http.MethodDelete, "/api/devices/"+m1+"/push", nil, with(marta), withDevice(m1)),
http.StatusNoContent, "")
// Чужое устройство в пути — 403, и подписки у него не появилось.
expect(t, e.do(http.MethodPut, "/api/devices/"+p1+"/push", body, with(marta), withDevice(m1)),
http.StatusForbidden, "unknown_device")
expect(t, e.do(http.MethodDelete, "/api/devices/"+p1+"/push", nil, with(marta), withDevice(m1)),
http.StatusForbidden, "unknown_device")
if e.hasPush(petya, p1) {
t.Error("подписка поставлена чужому устройству")
}
// X-Device обязателен и обязан быть своим.
for _, opts := range [][]func(*http.Request){
{with(marta)},
{with(marta), withDevice(p1)},
{with(marta), withDevice("мусор")},
{with(marta), withDevice(deviceOf(9))},
} {
expect(t, e.do(http.MethodPut, "/api/devices/"+m1+"/push", body, opts...),
http.StatusForbidden, "unknown_device")
expect(t, e.do(http.MethodDelete, "/api/devices/"+m1+"/push", nil, opts...),
http.StatusForbidden, "unknown_device")
}
if e.hasPush(marta, m1) {
t.Error("подписка появилась после отказа")
}
}
// Форма подписки: абсолютный https-адрес и два ключа нужной длины.
func TestPushSubscriptionForm(t *testing.T) {
e := newEnv(t)
marta, m1 := e.join("marta", 1)
_, p1 := e.join("petya", 2)
cases := []struct {
name string
change func(map[string]any)
}{
{"нет endpoint", func(s map[string]any) { delete(s, "endpoint") }},
{"endpoint без tls", func(s map[string]any) { s["endpoint"] = "http://push.example/one" }},
{"endpoint без хоста", func(s map[string]any) { s["endpoint"] = "https:///one" }},
{"endpoint не url", func(s map[string]any) { s["endpoint"] = "какой же это url" }},
{"нет ключей", func(s map[string]any) { delete(s, "keys") }},
{"endpoint на loopback", func(s map[string]any) { s["endpoint"] = "https://127.0.0.1:9/push" }},
{"endpoint на link-local", func(s map[string]any) {
s["endpoint"] = "https://169.254.169.254/latest/meta-data/"
}},
{"endpoint в приватной сети", func(s map[string]any) { s["endpoint"] = "https://10.0.0.1/push" }},
{"endpoint на ::1", func(s map[string]any) { s["endpoint"] = "https://[::1]:8411/api/me" }},
{"endpoint длиннее 2 КиБ", func(s map[string]any) {
s["endpoint"] = "https://push.example/" + strings.Repeat("a", 2048)
}},
{"p256dh не 65 байт", func(s map[string]any) {
s["keys"] = map[string]string{"p256dh": bytesOf(32, 5), "auth": bytesOf(16, 7)}
}},
{"p256dh не точка на кривой", func(s map[string]any) {
s["keys"] = map[string]string{"p256dh": bytesOf(65, 5), "auth": bytesOf(16, 7)}
}},
{"auth не 16 байт", func(s map[string]any) {
s["keys"] = map[string]string{"p256dh": bytesOf(65, 5), "auth": bytesOf(32, 7)}
}},
{"ключ не base64url", func(s map[string]any) {
s["keys"] = map[string]string{"p256dh": strings.Repeat("!", 87), "auth": bytesOf(16, 7)}
}},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
sub := subscription(t)
c.change(sub)
rec := e.do(http.MethodPut, "/api/devices/"+m1+"/push",
map[string]any{"subscription": sub}, with(marta), withDevice(m1))
expect(t, rec, http.StatusBadRequest, "invalid")
var field struct {
Field string `json:"field"`
}
decodeBody(t, rec, &field)
if field.Field != "subscription" {
t.Errorf("field: получено %q, ожидалось \"subscription\"", field.Field)
}
})
}
if e.hasPush(marta, m1) {
t.Error("подписка не по форме поставилась")
}
// Паддинг в base64url тоже принимается: браузер вправе его добавить.
padded := subscription(t)
padded["keys"] = map[string]string{
"p256dh": base64.URLEncoding.EncodeToString(point65(t)),
"auth": padded64(16, 7),
}
expect(t, e.do(http.MethodPut, "/api/devices/"+m1+"/push",
map[string]any{"subscription": padded}, with(marta), withDevice(m1)), http.StatusNoContent, "")
// Форма проверяется раньше прав: на запрос к чужому устройству
// приходит отказ по форме, а не по правам (ADR-043).
expect(t, e.do(http.MethodPut, "/api/devices/"+p1+"/push", "не json", with(marta), withDevice(m1)),
http.StatusBadRequest, "bad_json")
broken := subscription(t)
broken["endpoint"] = "http://push.example/one"
expect(t, e.do(http.MethodPut, "/api/devices/"+p1+"/push",
map[string]any{"subscription": broken}, with(marta), withDevice(m1)), http.StatusBadRequest, "invalid")
}
// Пуш уходит только отключённому устройству с подпиской (ADR-023).
// Он несёт заголовок, «новое сообщение» и адрес чата — и ничего больше.
func TestPushToSilentDevice(t *testing.T) {
svc := newPushService(t)
e := pushEnv(t)
marta, m1 := e.join("marta", 1)
petya, p1 := e.join("petya", 2)
p2 := e.addDevice(petya, deviceOf(3))
e.subscribe("petya", p1, svc) // подключено по SSE
silent := e.subscribe("petya", p2, svc) // молчит
stream := e.open(p1, petya)
stream.untilReady()
e.send(marta, m1, "petya", 4)
got := svc.next()
if got.device != p2 {
t.Fatalf("пуш ушёл устройству %s, ожидалось %s", got.device, p2)
}
// TTL сутки, urgency normal (ADR-023).
if got.ttl != "86400" {
t.Errorf("TTL: получено %q, ожидалось \"86400\"", got.ttl)
}
if got.urgency != "normal" {
t.Errorf("Urgency: получено %q, ожидалось \"normal\"", got.urgency)
}
if got.encoding != "aes128gcm" {
t.Errorf("Content-Encoding: получено %q, ожидалось \"aes128gcm\"", got.encoding)
}
if !strings.HasPrefix(got.auth, "vapid t=") {
t.Errorf("Authorization: получено %q, ожидался vapid", got.auth)
}
payload := silent.open(t, got.record)
if len(payload) != 3 {
t.Errorf("поля нагрузки: %v", payload)
}
if payload["title"] != "@marta" {
t.Errorf("title: получено %q, ожидалось \"@marta\"", payload["title"])
}
if payload["body"] != "новое сообщение" {
t.Errorf("body: получено %q, ожидалось \"новое сообщение\"", payload["body"])
}
if payload["chat"] != "dm:marta" {
t.Errorf("chat: получено %q, ожидалось \"dm:marta\"", payload["chat"])
}
// Шифротекста сообщения в пуше нет ни в каком виде: сервер его
// не пересылает, а плейнтекста он и не знает (ADR-011).
if bytes.Contains(got.record, []byte(bytesOf(48, 23))) {
t.Error("шифротекст сообщения попал в пуш")
}
svc.silent()
}
// Одно молчащее устройство получает один пуш, а не ленту (ADR-023).
func TestPushOncePerSilentDevice(t *testing.T) {
svc := newPushService(t)
e := pushEnv(t)
marta, m1 := e.join("marta", 1)
_, p1 := e.join("petya", 2)
e.subscribe("petya", p1, svc)
e.send(marta, m1, "petya", 3)
if got := svc.next(); got.device != p1 {
t.Fatalf("пуш ушёл устройству %s, ожидалось %s", got.device, p1)
}
// Второе и третье сообщение подряд пуша не порождают.
e.send(marta, m1, "petya", 4)
e.send(marta, m1, "petya", 5)
svc.silent()
}
// Подключение по SSE сбрасывает неотработанный пуш: следующее сообщение
// молчащему устройству снова даёт пуш (ADR-023).
func TestPushAgainAfterStream(t *testing.T) {
svc := newPushService(t)
e := pushEnv(t)
marta, m1 := e.join("marta", 1)
petya, p1 := e.join("petya", 2)
e.subscribe("petya", p1, svc)
e.send(marta, m1, "petya", 3)
svc.next()
e.send(marta, m1, "petya", 4)
svc.silent()
stream := e.open(p1, petya)
stream.untilReady()
stream.close()
if got := e.pushEventually(svc, marta, m1, "petya"); got.device != p1 {
t.Fatalf("пуш ушёл устройству %s, ожидалось %s", got.device, p1)
}
}
// 404 и 410 от push-сервиса означают, что подписки больше нет (ADR-011).
func TestPushDeadSubscription(t *testing.T) {
for _, status := range []int{http.StatusNotFound, http.StatusGone} {
t.Run(http.StatusText(status), func(t *testing.T) {
svc := newPushService(t)
svc.status.Store(int32(status))
e := pushEnv(t)
marta, m1 := e.join("marta", 1)
petya, p1 := e.join("petya", 2)
e.subscribe("petya", p1, svc)
e.send(marta, m1, "petya", 3)
svc.next()
e.waitPushGone(petya, p1)
})
}
}
// Прочие отказы push-сервиса подписку не трогают и доставку сообщения
// не роняют. Право на пуш при этом возвращается: иначе одна ошибка
// затыкала бы уведомления устройства до самого подключения.
func TestPushServiceError(t *testing.T) {
svc := newPushService(t)
svc.status.Store(http.StatusInternalServerError)
e := pushEnv(t)
marta, m1 := e.join("marta", 1)
petya, p1 := e.join("petya", 2)
e.subscribe("petya", p1, svc)
e.send(marta, m1, "petya", 3)
svc.next()
if !e.hasPush(petya, p1) {
t.Error("подписка снята по ответу 500")
}
svc.status.Store(http.StatusCreated)
if got := e.pushEventually(svc, marta, m1, "petya"); got.device != p1 {
t.Fatalf("пуш ушёл устройству %s, ожидалось %s", got.device, p1)
}
if !e.hasPush(petya, p1) {
t.Error("подписка снята после успешного пуша")
}
}
// Отправитель пуша о собственном сообщении не получает — ни на то
// устройство, с которого писал, ни на остальные свои (ADR-045).
func TestPushNotToSender(t *testing.T) {
svc := newPushService(t)
e := pushEnv(t)
marta, m1 := e.join("marta", 1)
m2 := e.addDevice(marta, deviceOf(2))
_, p1 := e.join("petya", 3)
e.subscribe("marta", m1, svc)
e.subscribe("marta", m2, svc)
to := e.subscribe("petya", p1, svc)
e.send(marta, m1, "petya", 4)
got := svc.next()
if got.device != p1 {
t.Fatalf("пуш ушёл устройству отправителя %s", got.device)
}
svc.silent()
if payload := to.open(t, got.record); payload["chat"] != "dm:marta" {
t.Errorf("chat: получено %q, ожидалось \"dm:marta\"", payload["chat"])
}
}
// Пуш из комнаты: заголовок — имя комнаты, адрес чата — её идентификатор
// (ADR-023).
func TestPushFromRoom(t *testing.T) {
svc := newPushService(t)
e := pushEnv(t)
marta, m1 := e.join("marta", 1)
_, p1 := e.join("petya", 2)
room := e.makeRoom(marta, "marta", "общая", 40, withDevice(m1))
expect(t, e.changeMembers(marta, room.ID, []string{"petya"}, nil, []string{"marta", "petya"}, 41),
http.StatusOK, "")
member := e.subscribe("petya", p1, svc)
expect(t, e.do(http.MethodPost, "/api/messages",
roomMessage(ulid(nowMillis(), 5), room.ID, keyID(41)), with(marta), withDevice(m1)),
http.StatusAccepted, "")
got := svc.next()
if got.device != p1 {
t.Fatalf("пуш ушёл устройству %s, ожидалось %s", got.device, p1)
}
payload := member.open(t, got.record)
if payload["title"] != "#общая" {
t.Errorf("title: получено %q, ожидалось \"#общая\"", payload["title"])
}
if payload["chat"] != "room:"+room.ID {
t.Errorf("chat: получено %q, ожидалось %q", payload["chat"], "room:"+room.ID)
}
if payload["body"] != "новое сообщение" {
t.Errorf("body: получено %q", payload["body"])
}
svc.silent()
}
// Без VAPID-ключей пуши выключены: подписка ставится, отправки нет.
func TestPushOffWithoutKeys(t *testing.T) {
svc := newPushService(t)
e := newEnv(t)
marta, m1 := e.join("marta", 1)
_, p1 := e.join("petya", 2)
e.subscribe("petya", p1, svc)
e.send(marta, m1, "petya", 3)
svc.silent()
}
// Аккаунт с молчащим push-сервисом не отбирает отправку у остальных:
// доля одного аккаунта в отправщиках ограничена (ADR-048).
func TestPushShareBetweenAccounts(t *testing.T) {
e := pushEnv(t)
stuck, release := newSlowPushService(t)
defer release()
live := newPushService(t)
marta, m1 := e.join("marta", 1)
greedy, g1 := e.join("greedy", 2)
e.subscribe("greedy", g1, stuck)
for seed := byte(10); seed < 30; seed++ {
e.subscribe("greedy", e.addDevice(greedy, deviceOf(seed)), stuck)
}
_, c1 := e.join("carol", 3)
e.subscribe("carol", c1, live)
// Двадцать одно молчащее устройство одного аккаунта: часть заданий
// отбрасывается сразу, остальные занимают не больше своей доли.
e.send(marta, m1, "greedy", 40)
e.send(marta, m1, "carol", 41)
select {
case got := <-live.got:
if got.device != c1 {
t.Fatalf("пуш ушёл устройству %s, ожидалось %s", got.device, c1)
}
case <-time.After(wait):
t.Fatal("пуш постороннему аккаунту не ушёл: отправщики заняты чужим")
}
}
// Устройство, подключившееся по SSE во время отправки, не остаётся
// с неотработанным пушем: право возвращается, и следующее сообщение
// после ухода в офлайн снова даёт пуш (ADR-023).
func TestPushReleasedWhenDeviceConnects(t *testing.T) {
e := pushEnv(t)
svc, release := newSlowPushService(t)
defer release()
marta, m1 := e.join("marta", 1)
petya, p1 := e.join("petya", 2)
e.subscribe("petya", p1, svc)
e.send(marta, m1, "petya", 3)
// Отправка уже началась: push-сервис получил запрос и держит его.
if got := svc.next(); got.device != p1 {
t.Fatalf("пуш ушёл устройству %s, ожидалось %s", got.device, p1)
}
// Пока пуш в пути, устройство подключилось: подключение сбрасывает
// неотработанный пуш, а отправщик поставил его позже.
stream := e.open(p1, petya)
stream.untilReady()
release()
// Право на пуш свободно: захват удаётся.
for deadline := time.Now().Add(wait); ; {
claimed, ok, err := e.st.ClaimPush(context.Background(), p1)
if err != nil {
t.Fatalf("ClaimPush: %v", err)
}
if ok {
if claimed == "" {
t.Error("подписка пуста")
}
return
}
if time.Now().After(deadline) {
t.Fatal("неотработанный пуш остался висеть на подключённом устройстве")
}
time.Sleep(5 * time.Millisecond)
}
}
// Пуш на непубличный адрес не уходит вовсе: соединения не случается,
// право на пуш возвращается, а адрес подписки в журнал не попадает
// (ADR-047, docs/deploy.md, «Логи»).
func TestPushSkipsLocalEndpoint(t *testing.T) {
svc := newPushService(t)
e := pushEnvWith(t, false)
marta, m1 := e.join("marta", 1)
petya, p1 := e.join("petya", 2)
e.subscribe("petya", p1, svc)
e.send(marta, m1, "petya", 3)
svc.silent()
if !e.hasPush(petya, p1) {
t.Error("подписка снята, хотя push-сервис не отвечал")
}
// Право на пуш вернулось: следующее сообщение попробует снова.
claimed, ok, err := e.st.ClaimPush(context.Background(), p1)
if err != nil {
t.Fatalf("ClaimPush: %v", err)
}
if !ok || claimed == "" {
t.Error("право на пуш осталось захваченным")
}
log := e.log.String()
if !strings.Contains(log, "адрес подписки не публичный") {
t.Errorf("в журнале нет причины отказа: %q", log)
}
if strings.Contains(log, "127.0.0.1") || strings.Contains(log, strings.TrimPrefix(svc.url, "http://")) {
t.Errorf("адрес подписки попал в журнал: %q", log)
}
}
// Одно сообщение — несколько молчащих устройств: каждое получает свою
// расшифровываемую нагрузку. Нагрузка на всех одна (ADR-045), но
// шифруется она для каждой подписки отдельно.
func TestPushPayloadPerDevice(t *testing.T) {
svc := newPushService(t)
e := pushEnv(t)
marta, m1 := e.join("marta", 1)
petya, p1 := e.join("petya", 2)
p2 := e.addDevice(petya, deviceOf(3))
p3 := e.addDevice(petya, deviceOf(4))
subs := map[string]*subscriber{
p1: e.subscribe("petya", p1, svc),
p2: e.subscribe("petya", p2, svc),
p3: e.subscribe("petya", p3, svc),
}
e.send(marta, m1, "petya", 5)
seen := make(map[string]bool)
for i := 0; i < len(subs); i++ {
got := svc.next()
to, ok := subs[got.device]
if !ok {
t.Fatalf("пуш ушёл неизвестному устройству %s", got.device)
}
if seen[got.device] {
t.Fatalf("устройство %s получило второй пуш", got.device)
}
seen[got.device] = true
payload := to.open(t, got.record)
if payload["title"] != "@marta" || payload["chat"] != "dm:marta" || payload["body"] != "новое сообщение" {
t.Errorf("нагрузка устройства %s: %v", got.device, payload)
}
}
svc.silent()
}
+28 -2
View File
@@ -5,6 +5,7 @@ import (
"errors"
"net/http"
"time"
"unicode"
"unicode/utf8"
"github.com/xmatic-squad/bare/internal/auth"
@@ -379,7 +380,32 @@ func uniqueNicks(list []string) ([]string, bool) {
return out, true
}
// validRoomName — имя комнаты: непустое, до 64 символов (ADR-021).
// validRoomName — имя комнаты: непустое, до 64 рун, без управляющих
// символов, без переопределений направления письма и не из одних
// пробелов (ADR-021).
//
// Форма строже, чем «до 64 символов», с этапа 4: имя комнаты уходит
// в заголовок системного уведомления (ADR-045), а туда нельзя ни перевод
// строки, ни разворот текста — на экране блокировки такое имя выглядит
// не строкой списка, а сообщением от системы.
func validRoomName(name string) bool {
return name != "" && utf8.RuneCountInString(name) <= maxRoomName
if name == "" || utf8.RuneCountInString(name) > maxRoomName {
return false
}
blank := true
for _, r := range name {
if unicode.IsControl(r) || bidi(r) {
return false
}
if !unicode.IsSpace(r) {
blank = false
}
}
return !blank
}
// bidi — переопределения направления письма: U+202A…U+202E и U+2066…U+2069.
// Они переставляют текст на экране местами, оставаясь невидимыми.
func bidi(r rune) bool {
return (r >= 0x202A && r <= 0x202E) || (r >= 0x2066 && r <= 0x2069)
}
+11
View File
@@ -225,6 +225,17 @@ func TestCreateRoomRejects(t *testing.T) {
{"имя длиннее 64", func(m map[string]any) {
m["name"] = strings.Repeat("я", 65)
}, http.StatusBadRequest, "invalid", "name"},
// Имя уходит в заголовок системного уведомления (ADR-045):
// ни перевода строки, ни разворота текста в нём быть не должно.
{"имя с переводом строки", func(m map[string]any) {
m["name"] = "общая\nсрочно: перезагрузите телефон"
}, http.StatusBadRequest, "invalid", "name"},
{"имя с bidi", func(m map[string]any) {
m["name"] = "общая\u202eяандекс"
}, http.StatusBadRequest, "invalid", "name"},
{"имя из пробелов", func(m map[string]any) {
m["name"] = " "
}, http.StatusBadRequest, "invalid", "name"},
{"кривой keyId", func(m map[string]any) { m["keyId"] = "dm" }, http.StatusBadRequest, "invalid", "keyId"},
{"нет ключа", func(m map[string]any) { m["keys"] = []any{} }, http.StatusBadRequest, "keys_mismatch", ""},
{"ключ чужому", func(m map[string]any) {
+6
View File
@@ -16,6 +16,12 @@ type Config struct {
VAPIDPrivate string // BARE_VAPID_PRIVATE
VAPIDSubject string // BARE_VAPID_SUBJECT
InviteCode string // BARE_INVITE_CODE — пусто означает открытую регистрацию
// PushLocal разрешает отправку пушей на непубличные адреса. Из
// окружения не читается и в работе всегда false: сервер ходит
// только по публичным адресам (ADR-047). Поле существует ради
// тестов, где push-сервис вендора подменён сервером на 127.0.0.1.
PushLocal bool
}
// Значения по умолчанию — локальный запуск без окружения.
+8
View File
@@ -75,6 +75,14 @@ func (h *Hub) Send(device string, ev Event) {
}
}
// Connected — держит ли устройство открытый поток. Пуш уходит только
// молчащему устройству (ADR-023).
func (h *Hub) Connected(device string) bool {
h.mu.Lock()
defer h.mu.Unlock()
return h.streams[device] != nil
}
// Close закрывает поток устройства: устройство удалили (docs/protocol.md,
// «Устройства»).
func (h *Hub) Close(device string) {
+467
View File
@@ -0,0 +1,467 @@
// Package push отправляет веб-пуши устройствам (ADR-011, ADR-023).
//
// Пуш — сигнал, а не транспорт: он говорит, что для устройства что-то
// есть, а содержимое устройство забирает очередью при подключении.
// Плейнтекста сервер не знает, поэтому текст пуша — константа, а не поле.
package push
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net"
"net/http"
"net/netip"
"sync"
"syscall"
"time"
webpush "github.com/SherClockHolmes/webpush-go"
"github.com/xmatic-squad/bare/internal/config"
)
// Параметры отправки из ADR-023.
const (
ttl = 24 * time.Hour
urgency = webpush.UrgencyNormal
)
// body — текст пуша. Константа, а не поле полезной нагрузки: сервер
// не знает плейнтекста сообщения и не может положить его в пуш даже
// по ошибке (ADR-011, docs/ui.md, «Уведомления»).
const body = "новое сообщение"
// Пределы отправки (ADR-048). Пуш — побочный эффект доставки, ответа на
// POST /api/messages он не ждёт, но и «выстрелил и забыл» без границ
// не годится: недоступный push-сервис держит соединение до таймаута,
// и без предела такие отправки копились бы горутинами и сокетами,
// пока хватает памяти. Поэтому фиксированная очередь, фиксированное
// число отправщиков и доля одного аккаунта в них.
const (
workers = 8
// queueSize — сколько пушей ждут отправщика. Переполнение означает,
// что push-сервисы не справляются; лишний пуш отбрасывается, а не
// копится. Потери в этом нет: право на пуш забирается перед самой
// отправкой, поэтому у отброшенного устройства push_pending остаётся
// нулём и следующее сообщение попробует снова.
queueSize = 256
// perAccount — сколько заданий одного аккаунта бывает в очереди и в
// работе одновременно. Без этой доли аккаунт с сотней устройств на
// молчащем эндпоинте занимал бы всех отправщиков, и пуши остальных
// пользователей отбрасывались бы (ADR-048).
perAccount = 4
// requestTimeout — сколько ждём push-сервис. Вендоры отвечают за
// секунды; всё, что дольше, — уже недоступный сервис, а таймаут
// на задание задаёт пропускную способность отправки.
requestTimeout = 5 * time.Second
// dialTimeout — сколько ждём соединения с push-сервисом.
dialTimeout = 3 * time.Second
// storeTimeout — сколько ждём базу, когда правим подписку по итогам
// отправки.
storeTimeout = 5 * time.Second
// dropEvery — как часто в журнал уходит счётчик отброшенных пушей.
// Строка на каждый отброшенный пуш была бы усилителем заливки
// журнала: одно сообщение аккаунту с сотней устройств давало бы
// сотню строк (ADR-048).
dropEvery = time.Minute
)
// Devices — что отправителю нужно от хранилища. Правило «одно молчащее
// устройство — один пуш» держится на атомарном захвате (ADR-023).
type Devices interface {
ClaimPush(ctx context.Context, device string) (subscription string, claimed bool, err error)
ReleasePush(ctx context.Context, device string) error
DropPush(ctx context.Context, device string) error
}
// Payload — полезная нагрузка пуша (ADR-023). Заголовок — «@nick»
// отправителя или «#имя комнаты», chat — идентификатор чата для
// перехода: «dm:<nick>» или «room:<id>». Текста сообщения здесь нет
// и быть не может.
type Payload struct {
Title string
Chat string
}
// Target — кому нужен пуш: устройство и аккаунт, которому оно
// принадлежит. Аккаунт нужен, чтобы отмерить его долю в отправке
// (ADR-048).
type Target struct {
Device string
Owner string
}
// wire — полезная нагрузка на проводе.
type wire struct {
Title string `json:"title"`
Body string `json:"body"`
Chat string `json:"chat"`
}
// Sender — очередь отправки и отправщики за ней.
type Sender struct {
devices Devices
// connected — держит ли устройство поток событий. Спрашивается
// в момент захвата права на пуш, а не при постановке в очередь
// (ADR-023).
connected func(device string) bool
public string
private string
subject string
client *http.Client
logw io.Writer
jobs chan job
done chan struct{}
stop sync.Once
wg sync.WaitGroup
mu sync.Mutex
// share — сколько заданий аккаунта в очереди и в работе.
share map[string]int
// dropped — сколько пушей отброшено с прошлой строки в журнале.
dropped int
reported time.Time
}
// job — один пуш: кому, от чьего имени доля и что.
type job struct {
device string
owner string
payload []byte
}
// New собирает отправителя. Без полной пары VAPID-ключей и subject пуши
// выключены: отправлять их всё равно нечем (ADR-022, docs/deploy.md).
// Выключенный отправитель не заводит горутин и молча ничего не делает.
//
// connected отвечает, подключено ли устройство по SSE; nil означает
// «никто не подключён».
func New(cfg *config.Config, devices Devices, connected func(device string) bool, logw io.Writer) *Sender {
if connected == nil {
connected = func(string) bool { return false }
}
s := &Sender{
devices: devices,
connected: connected,
public: cfg.VAPIDPublic,
private: cfg.VAPIDPrivate,
subject: cfg.VAPIDSubject,
client: &http.Client{
Timeout: requestTimeout,
// Push-сервисы редиректов не шлют. Следование за ними
// означало бы, что проверка «endpoint — https» ничего
// не значит: один 307 уводит запрос вместе с VAPID-заголовком
// куда угодно, в том числе на plain http внутрь периметра
// (ADR-047).
CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse },
Transport: transport(cfg.PushLocal),
},
logw: logw,
share: make(map[string]int),
}
if !s.on() {
return s
}
s.jobs = make(chan job, queueSize)
s.done = make(chan struct{})
s.wg.Add(workers)
for i := 0; i < workers; i++ {
go s.work()
}
return s
}
// on — есть ли чем подписывать пуши.
func (s *Sender) on() bool {
return s.public != "" && s.private != "" && s.subject != ""
}
// Send ставит пуш каждому из устройств в очередь отправки и возвращается
// сразу: конверт уже в очереди устройства, ответ на POST /api/messages
// пуша не ждёт (ADR-023).
//
// Заданий одного аккаунта в работе не больше perAccount: лишние
// отбрасываются здесь же, не занимая отправщика (ADR-048).
func (s *Sender) Send(targets []Target, p Payload) {
if !s.on() || len(targets) == 0 {
return
}
raw, err := json.Marshal(wire{Title: p.Title, Body: body, Chat: p.Chat})
if err != nil {
s.report("сборка нагрузки: %v", err)
return
}
for _, t := range targets {
if !s.reserve(t.Owner) {
continue
}
// У каждого задания своя копия нагрузки: webpush-go дописывает
// набивку прямо в переданный срез, а одно сообщение уходит сразу
// нескольким устройствам и в разных отправщиках.
select {
case s.jobs <- job{device: t.Device, owner: t.Owner, payload: bytes.Clone(raw)}:
default:
s.free(t.Owner)
s.countDrop()
}
}
s.reportDrops(dropEvery)
}
// Close останавливает отправщиков и дожидается начатых отправок.
func (s *Sender) Close() {
if !s.on() {
return
}
s.stop.Do(func() { close(s.done) })
s.wg.Wait()
s.reportDrops(0)
}
func (s *Sender) work() {
defer s.wg.Done()
for {
select {
case <-s.done:
return
case j := <-s.jobs:
s.deliver(j)
s.free(j.owner)
}
}
}
// deliver забирает право на пуш и отправляет его. Контекст здесь свой:
// запрос, породивший пуш, к этому моменту давно отвечен.
func (s *Sender) deliver(j job) {
ctx, cancel := context.WithTimeout(context.Background(), requestTimeout)
defer cancel()
// Подключённому устройству пуш не нужен, и права на пуш ему брать
// нельзя: захваченное право сбрасывается только подключением, и на
// подключённом устройстве оно провисело бы всю сессию, съев пуш
// после ухода в офлайн. Поэтому проверка идёт здесь, рядом
// с захватом, а не при постановке в очередь (ADR-023).
if s.connected(j.device) {
return
}
subscription, claimed, err := s.devices.ClaimPush(ctx, j.device)
if err != nil {
s.report("захват: %v", err)
return
}
// Права нет: устройство без подписки или с неотработанным пушем.
// Одно молчащее устройство получает один пуш, не ленту (ADR-023).
if !claimed {
return
}
// Между проверкой и захватом устройство успевает подключиться:
// подключение сбрасывает право, а мы забрали его следом.
if s.connected(j.device) {
s.release(j.device)
return
}
var to webpush.Subscription
if err := json.Unmarshal([]byte(subscription), &to); err != nil {
// Подписку в таком виде мог записать только сервер, и всё же:
// неразбираемая подписка не заработает никогда, снимаем.
s.report("подписка не разобрана")
s.drop(j.device)
return
}
resp, err := webpush.SendNotificationWithContext(ctx, j.payload, &to, &webpush.Options{
HTTPClient: s.client,
Subscriber: s.subject,
VAPIDPublicKey: s.public,
VAPIDPrivateKey: s.private,
TTL: int(ttl.Seconds()),
Urgency: urgency,
})
if err != nil {
s.report("отправка: %s", reason(err))
s.release(j.device)
return
}
defer resp.Body.Close()
// Тело ответа push-сервиса нам не нужно, но дочитать его стоит:
// иначе соединение не переиспользуется.
io.Copy(io.Discard, resp.Body)
switch {
case resp.StatusCode < 300:
// Пуш принят: у устройства висит неотработанный пуш. Если оно
// успело подключиться, пока шла отправка, право возвращается:
// подключение сбрасывает его раньше, чем мы поставили.
if s.connected(j.device) {
s.release(j.device)
}
case resp.StatusCode == http.StatusNotFound || resp.StatusCode == http.StatusGone:
// Подписки больше нет — чистим мёртвую (ADR-011).
s.drop(j.device)
default:
s.report("push-сервис ответил %d", resp.StatusCode)
s.release(j.device)
}
}
// reserve занимает долю аккаунта в отправке. Доля израсходована — пуш
// отбрасывается: устройству от этого ничего не грозит, право на пуш
// ещё не забрано (ADR-048).
func (s *Sender) reserve(owner string) bool {
s.mu.Lock()
defer s.mu.Unlock()
if s.share[owner] >= perAccount {
s.dropped++
return false
}
s.share[owner]++
return true
}
// free возвращает долю аккаунта.
func (s *Sender) free(owner string) {
s.mu.Lock()
defer s.mu.Unlock()
if n := s.share[owner]; n > 1 {
s.share[owner] = n - 1
} else {
delete(s.share, owner)
}
}
// countDrop считает отброшенный пуш.
func (s *Sender) countDrop() {
s.mu.Lock()
defer s.mu.Unlock()
s.dropped++
}
// reportDrops пишет счётчик отброшенных пушей, но не чаще чем раз
// в every (ADR-048).
func (s *Sender) reportDrops(every time.Duration) {
s.mu.Lock()
n := s.dropped
if n == 0 || time.Since(s.reported) < every {
s.mu.Unlock()
return
}
s.dropped = 0
s.reported = time.Now()
s.mu.Unlock()
s.report("отброшено пушей: %d", n)
}
// release возвращает право на пуш: отправка не состоялась, ждать
// устройству нечего. Контекст здесь свой: отправка могла кончиться
// именно таймаутом, а на просроченном контексте запись не прошла бы
// и push_pending остался бы висеть.
func (s *Sender) release(device string) {
ctx, cancel := context.WithTimeout(context.Background(), storeTimeout)
defer cancel()
if err := s.devices.ReleasePush(ctx, device); err != nil {
s.report("возврат: %v", err)
}
}
// drop снимает подписку по той же причине со своим контекстом.
func (s *Sender) drop(device string) {
ctx, cancel := context.WithTimeout(context.Background(), storeTimeout)
defer cancel()
if err := s.devices.DropPush(ctx, device); err != nil {
s.report("снятие подписки: %v", err)
}
}
// report пишет строку в журнал. Ни идентификатора устройства, ни адреса
// подписки в ней нет: и то и другое — данные пользователя
// (docs/deploy.md, «Логи»).
func (s *Sender) report(format string, args ...any) {
if s.logw == nil {
return
}
fmt.Fprintf(s.logw, "%s пуш: %s\n", time.Now().Format(time.RFC3339), fmt.Sprintf(format, args...))
}
// errLocalAddress — попытка соединиться с непубличным адресом (ADR-047).
var errLocalAddress = errors.New("push: адрес не публичный")
// reason сводит отказ отправки к классу. Текст ошибки транспорта
// в журнал не идёт вовсе: внутри него лежит адрес подписки — host, порт
// или имя, — а это данные пользователя (docs/deploy.md, «Логи»). Класс
// отвечает на вопрос «что чинить», адрес для этого не нужен.
func reason(err error) string {
if errors.Is(err, errLocalAddress) {
return "адрес подписки не публичный"
}
if errors.Is(err, context.DeadlineExceeded) {
return "таймаут"
}
var dns *net.DNSError
if errors.As(err, &dns) {
return "имя не разрешилось"
}
var ne net.Error
if errors.As(err, &ne) && ne.Timeout() {
return "таймаут сети"
}
return "отправка не удалась"
}
// transport — транспорт отправщика. Адрес push-сервиса выбирает браузер
// получателя, а сервер стоит во внутренней сети за nginx (ADR-022):
// без проверки любой вошедший пользователь заставил бы его стучаться
// внутрь периметра. Проверяется адрес соединения, то есть уже
// разрешённое имя, — подмена DNS не помогает (ADR-047).
//
// local снимает проверку и включается только в тестах: настоящий
// push-сервис в них подменён сервером на 127.0.0.1. Из окружения этот
// флаг не читается.
func transport(local bool) http.RoundTripper {
dialer := &net.Dialer{Timeout: dialTimeout, KeepAlive: 30 * time.Second}
if !local {
dialer.Control = onlyPublic
}
t := http.DefaultTransport.(*http.Transport).Clone()
t.DialContext = dialer.DialContext
return t
}
// onlyPublic отказывает в соединении с непубличным адресом.
func onlyPublic(network, address string, _ syscall.RawConn) error {
host, _, err := net.SplitHostPort(address)
if err != nil {
return errLocalAddress
}
ip, err := netip.ParseAddr(host)
if err != nil {
return errLocalAddress
}
if !Public(ip) {
return errLocalAddress
}
return nil
}
// Public — публичный ли адрес. Непубличными считаются loopback,
// link-local, приватные сети (RFC 1918 и RFC 4193), multicast
// и неопределённый адрес: push-сервиса по таким адресам не бывает,
// а внутренние службы бывают (ADR-047).
func Public(ip netip.Addr) bool {
ip = ip.Unmap()
if !ip.IsValid() {
return false
}
return !ip.IsLoopback() &&
!ip.IsPrivate() &&
!ip.IsLinkLocalUnicast() &&
!ip.IsLinkLocalMulticast() &&
!ip.IsInterfaceLocalMulticast() &&
!ip.IsMulticast() &&
!ip.IsUnspecified()
}
+145
View File
@@ -0,0 +1,145 @@
package push
import (
"context"
"errors"
"fmt"
"net"
"net/http"
"net/http/httptest"
"net/netip"
"net/url"
"strings"
"testing"
"github.com/xmatic-squad/bare/internal/config"
)
// sender без VAPID-ключей: отправщиков он не заводит, а клиент собирает —
// именно клиент здесь и проверяется.
func client(t *testing.T, local bool) *http.Client {
t.Helper()
return New(&config.Config{PushLocal: local}, nil, nil, nil).client
}
// Публичный адрес отличается от того, по которому push-сервиса не бывает
// (ADR-047).
func TestPublicAddress(t *testing.T) {
cases := []struct {
addr string
public bool
}{
{"93.184.216.34", true},
{"2606:2800:220:1:248:1893:25c8:1946", true},
{"127.0.0.1", false},
{"::1", false},
{"10.0.0.1", false},
{"172.16.5.4", false},
{"192.168.1.1", false},
{"169.254.169.254", false},
{"fe80::1", false},
{"fc00::1", false},
{"0.0.0.0", false},
{"::", false},
{"224.0.0.1", false},
{"::ffff:127.0.0.1", false},
{"::ffff:10.0.0.1", false},
}
for _, c := range cases {
ip, err := netip.ParseAddr(c.addr)
if err != nil {
t.Fatalf("%s: %v", c.addr, err)
}
if got := Public(ip); got != c.public {
t.Errorf("Public(%s): получено %v, ожидалось %v", c.addr, got, c.public)
}
}
}
// Соединения с непубличным адресом не случается: проверка стоит на самом
// dial, поэтому её не обойти ни именем, ни редиректом (ADR-047).
func TestClientRefusesLocalAddress(t *testing.T) {
got := make(chan struct{}, 1)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
got <- struct{}{}
}))
defer srv.Close()
resp, err := client(t, false).Get(srv.URL)
if err == nil {
resp.Body.Close()
t.Fatal("соединение с 127.0.0.1 состоялось")
}
if !errors.Is(err, errLocalAddress) {
t.Errorf("ошибка: %v, ожидался отказ по адресу", err)
}
if reason(err) != "адрес подписки не публичный" {
t.Errorf("класс отказа: %q", reason(err))
}
select {
case <-got:
t.Error("внутренняя служба получила запрос")
default:
}
}
// Редиректы push-сервиса не выполняются: иначе один 307 уводил бы запрос
// вместе с VAPID-заголовком куда угодно, и проверка «endpoint — https»
// не значила бы ничего (ADR-047).
func TestClientDoesNotFollowRedirect(t *testing.T) {
inside := make(chan struct{}, 1)
internal := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
inside <- struct{}{}
w.WriteHeader(http.StatusGone)
}))
defer internal.Close()
vendor := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, internal.URL+"/latest/meta-data/", http.StatusTemporaryRedirect)
}))
defer vendor.Close()
// local: сами тестовые серверы живут на 127.0.0.1, проверяется здесь
// именно политика редиректов.
resp, err := client(t, true).Get(vendor.URL + "/push")
if err != nil {
t.Fatalf("запрос: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusTemporaryRedirect {
t.Errorf("статус: получено %d, ожидалось 307", resp.StatusCode)
}
select {
case <-inside:
t.Error("запрос ушёл по редиректу на внутренний адрес")
default:
}
}
// Отказ отправки сводится к классу: адреса подписки в журнале нет
// (docs/deploy.md, «Логи»).
func TestReasonWithoutEndpoint(t *testing.T) {
const endpoint = "secret-host.push.example"
cases := []struct {
err error
want string
}{
{fmt.Errorf("dial: %w", errLocalAddress), "адрес подписки не публичный"},
{fmt.Errorf("post: %w", context.DeadlineExceeded), "таймаут"},
{&url.Error{Op: "Post", URL: "https://" + endpoint + "/x",
Err: &net.DNSError{Err: "no such host", Name: endpoint}}, "имя не разрешилось"},
{&url.Error{Op: "Post", URL: "https://" + endpoint + "/x",
Err: &net.OpError{Op: "read", Net: "tcp",
Addr: &net.TCPAddr{IP: net.IPv4(10, 1, 2, 3), Port: 443},
Err: errors.New("connection reset by peer")}}, "отправка не удалась"},
}
for _, c := range cases {
got := reason(c.err)
if got != c.want {
t.Errorf("reason(%v): получено %q, ожидалось %q", c.err, got, c.want)
}
if strings.Contains(got, endpoint) || strings.Contains(got, "10.1.2.3") {
t.Errorf("адрес подписки попал в журнал: %q", got)
}
}
}
+78
View File
@@ -127,3 +127,81 @@ func (s *Store) TouchDevice(ctx context.Context, id string, now int64) error {
}
return nil
}
// Push-подписка принадлежит устройству (ADR-023). Сервер хранит её как
// непрозрачный JSON: разбирает его только отправитель пушей.
// SetPush ставит подписку устройства и сбрасывает неотработанный пуш:
// устройство снова готово его принять (ADR-023). Первое значение — было
// ли такое устройство у этого пользователя.
func (s *Store) SetPush(ctx context.Context, id, nick, subscription string) (bool, error) {
res, err := s.db.ExecContext(ctx, `
UPDATE devices SET push_subscription = ?, push_pending = 0
WHERE id = ? AND nick = ?`, subscription, id, nick)
if err != nil {
return false, fmt.Errorf("store: push-подписка: %w", err)
}
n, err := res.RowsAffected()
if err != nil {
return false, fmt.Errorf("store: push-подписка: %w", err)
}
return n > 0, nil
}
// ClearPush снимает подписку устройства. Подписки не было — это не
// ошибка: снимать нечего.
func (s *Store) ClearPush(ctx context.Context, id, nick string) (bool, error) {
res, err := s.db.ExecContext(ctx, `
UPDATE devices SET push_subscription = NULL WHERE id = ? AND nick = ?`, id, nick)
if err != nil {
return false, fmt.Errorf("store: снятие push-подписки: %w", err)
}
n, err := res.RowsAffected()
if err != nil {
return false, fmt.Errorf("store: снятие push-подписки: %w", err)
}
return n > 0, nil
}
// ClaimPush забирает право на пуш: устройству с подпиской и без
// неотработанного пуша ставит push_pending = 1 и отдаёт подписку.
// Второе значение — досталось ли право.
//
// Захват и проверка — один запрос: два сообщения подряд приходят
// в разных горутинах, а молчащее устройство получает один пуш, не ленту
// (ADR-023). Проигравший запрос уходит ни с чем.
func (s *Store) ClaimPush(ctx context.Context, id string) (string, bool, error) {
var subscription string
err := s.db.QueryRowContext(ctx, `
UPDATE devices SET push_pending = 1
WHERE id = ? AND push_pending = 0 AND push_subscription IS NOT NULL
RETURNING push_subscription`, id).Scan(&subscription)
if errors.Is(err, sql.ErrNoRows) {
return "", false, nil
}
if err != nil {
return "", false, fmt.Errorf("store: захват пуша: %w", err)
}
return subscription, true, nil
}
// ReleasePush возвращает право на пуш: отправка не состоялась, значит
// и неотработанного пуша у устройства нет. Иначе одна ошибка push-сервиса
// затыкала бы уведомления устройства до следующего подключения по SSE.
func (s *Store) ReleasePush(ctx context.Context, id string) error {
if _, err := s.db.ExecContext(ctx, `
UPDATE devices SET push_pending = 0 WHERE id = ?`, id); err != nil {
return fmt.Errorf("store: возврат пуша: %w", err)
}
return nil
}
// DropPush снимает мёртвую подписку: push-сервис ответил 404 или 410
// (ADR-011). Неотработанного пуша заодно не остаётся — он никуда не ушёл.
func (s *Store) DropPush(ctx context.Context, id string) error {
if _, err := s.db.ExecContext(ctx, `
UPDATE devices SET push_subscription = NULL, push_pending = 0 WHERE id = ?`, id); err != nil {
return fmt.Errorf("store: снятие мёртвой push-подписки: %w", err)
}
return nil
}
+40 -28
View File
@@ -55,6 +55,17 @@ func (s *Store) Ack(ctx context.Context, device string, ids []string) error {
return nil
}
// Target — устройство, которому конверт лёг в очередь. Ник рядом
// с идентификатором нужен пушу: устройства отправителя пуша не получают
// (ADR-045), а доля аккаунта в отправке ограничена (ADR-048). Признак
// подписки — оттуда же: устройству без неё пуш не отправить, и место
// в очереди отправки на него не тратится.
type Target struct {
ID string
Nick string
HasPush bool
}
// Delivery — одна доставка: готовый конверт и всё, что нужно, чтобы
// разложить его по очередям. Envelope сервер не разбирает, поэтому id
// приходит отдельным полем. Заполнено ровно одно из To и Room — адресат
@@ -78,7 +89,7 @@ type Delivery struct {
// не хранит, повтор порождает повторную доставку, а склеивает её клиент
// (ADR-017). Поэтому вставка молча пропускает уже лежащую в очереди
// строку, а список устройств от этого не зависит.
func (s *Store) DeliverDM(ctx context.Context, d Delivery) ([]string, error) {
func (s *Store) DeliverDM(ctx context.Context, d Delivery) ([]Target, error) {
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return nil, fmt.Errorf("store: доставка: %w", err)
@@ -97,11 +108,11 @@ func (s *Store) DeliverDM(ctx context.Context, d Delivery) ([]string, error) {
if err != nil {
return nil, err
}
for _, id := range devices {
for _, device := range devices {
if _, err := tx.ExecContext(ctx, `
INSERT INTO queue (device_id, msg_id, envelope, created_at) VALUES (?, ?, ?, ?)
ON CONFLICT(device_id, msg_id) DO NOTHING`,
id, d.MsgID, d.Envelope, d.Now); err != nil {
device.ID, d.MsgID, d.Envelope, d.Now); err != nil {
return nil, fmt.Errorf("store: доставка (очередь): %w", err)
}
}
@@ -118,7 +129,7 @@ func (s *Store) DeliverDM(ctx context.Context, d Delivery) ([]string, error) {
// Членство и keyId проверены раньше, отдельным запросом: между проверкой
// и этой транзакцией состав мог измениться, поэтому получателей она берёт
// из состава на момент доставки.
func (s *Store) DeliverRoom(ctx context.Context, d Delivery) ([]string, error) {
func (s *Store) DeliverRoom(ctx context.Context, d Delivery) ([]Target, error) {
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return nil, fmt.Errorf("store: доставка в комнату: %w", err)
@@ -129,11 +140,11 @@ func (s *Store) DeliverRoom(ctx context.Context, d Delivery) ([]string, error) {
if err != nil {
return nil, err
}
for _, id := range devices {
for _, device := range devices {
if _, err := tx.ExecContext(ctx, `
INSERT INTO queue (device_id, msg_id, envelope, created_at) VALUES (?, ?, ?, ?)
ON CONFLICT(device_id, msg_id) DO NOTHING`,
id, d.MsgID, d.Envelope, d.Now); err != nil {
device.ID, d.MsgID, d.Envelope, d.Now); err != nil {
return nil, fmt.Errorf("store: доставка в комнату (очередь): %w", err)
}
}
@@ -144,48 +155,49 @@ func (s *Store) DeliverRoom(ctx context.Context, d Delivery) ([]string, error) {
}
// deviceIDs — устройства обоих собеседников, кроме отправившего.
func deviceIDs(ctx context.Context, tx *sql.Tx, from, to, exclude string) ([]string, error) {
func deviceIDs(ctx context.Context, tx *sql.Tx, from, to, exclude string) ([]Target, error) {
rows, err := tx.QueryContext(ctx, `
SELECT id FROM devices WHERE nick IN (?, ?) AND id <> ? ORDER BY id`, from, to, exclude)
SELECT id, nick, push_subscription IS NOT NULL
FROM devices WHERE nick IN (?, ?) AND id <> ? ORDER BY id`, from, to, exclude)
if err != nil {
return nil, fmt.Errorf("store: доставка (устройства): %w", err)
}
defer rows.Close()
var out []string
for rows.Next() {
var id string
if err := rows.Scan(&id); err != nil {
return nil, fmt.Errorf("store: доставка (устройства): %w", err)
}
out = append(out, id)
}
if err := rows.Err(); err != nil {
out, err := targets(rows)
if err != nil {
return nil, fmt.Errorf("store: доставка (устройства): %w", err)
}
return out, nil
}
// roomDeviceIDs — устройства всех участников комнаты, кроме отправившего.
func roomDeviceIDs(ctx context.Context, tx *sql.Tx, room, exclude string) ([]string, error) {
func roomDeviceIDs(ctx context.Context, tx *sql.Tx, room, exclude string) ([]Target, error) {
rows, err := tx.QueryContext(ctx, `
SELECT d.id FROM devices d JOIN room_members m ON m.nick = d.nick
SELECT d.id, d.nick, d.push_subscription IS NOT NULL
FROM devices d JOIN room_members m ON m.nick = d.nick
WHERE m.room_id = ? AND d.id <> ? ORDER BY d.id`, room, exclude)
if err != nil {
return nil, fmt.Errorf("store: доставка в комнату (устройства): %w", err)
}
defer rows.Close()
var out []string
for rows.Next() {
var id string
if err := rows.Scan(&id); err != nil {
return nil, fmt.Errorf("store: доставка в комнату (устройства): %w", err)
}
out = append(out, id)
}
if err := rows.Err(); err != nil {
out, err := targets(rows)
if err != nil {
return nil, fmt.Errorf("store: доставка в комнату (устройства): %w", err)
}
return out, nil
}
// targets собирает устройства получателей из выборки «id, nick, подписка».
func targets(rows *sql.Rows) ([]Target, error) {
var out []Target
for rows.Next() {
var t Target
if err := rows.Scan(&t.ID, &t.Nick, &t.HasPush); err != nil {
return nil, err
}
out = append(out, t)
}
return out, rows.Err()
}
+26 -11
View File
@@ -501,18 +501,33 @@ func (s *Store) DeleteRoom(ctx context.Context, roomID, owner string) (RoomChang
return change, nil
}
// RoomAccess — что сервер проверяет перед отправкой в комнату
// (docs/protocol.md, «Сообщения»). keyId считается ключом комнаты, если
// есть хоть одна строка room_keys с таким key_id (docs/storage.md).
func (s *Store) RoomAccess(ctx context.Context, roomID, nick, keyID string) (member, knownKey bool, err error) {
err = s.db.QueryRowContext(ctx, `
SELECT EXISTS(SELECT 1 FROM room_members WHERE room_id = ? AND nick = ?),
EXISTS(SELECT 1 FROM room_keys WHERE room_id = ? AND key_id = ?)`,
roomID, nick, roomID, keyID).Scan(&member, &knownKey)
if err != nil {
return false, false, fmt.Errorf("store: доступ к комнате: %w", err)
// Access — что сервер знает о комнате перед отправкой в неё
// (docs/protocol.md, «Сообщения»). Имя нужно заголовку пуша: «#имя
// комнаты» (ADR-023).
type Access struct {
Member bool
KnownKey bool
Name string
}
// RoomAccess — что сервер проверяет перед отправкой в комнату. keyId
// считается ключом комнаты, если есть хоть одна строка room_keys с таким
// key_id (docs/storage.md). Несуществующая комната отвечает пустым
// Access: снаружи она неотличима от чужой.
func (s *Store) RoomAccess(ctx context.Context, roomID, nick, keyID string) (Access, error) {
var a Access
err := s.db.QueryRowContext(ctx, `
SELECT r.name,
EXISTS(SELECT 1 FROM room_members WHERE room_id = r.id AND nick = ?),
EXISTS(SELECT 1 FROM room_keys WHERE room_id = r.id AND key_id = ?)
FROM rooms r WHERE r.id = ?`, nick, keyID, roomID).Scan(&a.Name, &a.Member, &a.KnownKey)
if errors.Is(err, sql.ErrNoRows) {
return Access{}, nil
}
return member, knownKey, nil
if err != nil {
return Access{}, fmt.Errorf("store: доступ к комнате: %w", err)
}
return a, nil
}
// currentKeysQuery — текущий ключ участника: строка room_keys с максимальным
+6 -3
View File
@@ -155,9 +155,12 @@ func TestRoomKeysWithinOneMillisecond(t *testing.T) {
}
}
// Свежим ключом можно писать: он остался ключом комнаты.
member, known, err := s.RoomAccess(ctx, "room-1", "marta", "aaa")
if err != nil || !member || !known {
t.Errorf("доступ по свежему ключу: member=%v known=%v err=%v", member, known, err)
access, err := s.RoomAccess(ctx, "room-1", "marta", "aaa")
if err != nil || !access.Member || !access.KnownKey {
t.Errorf("доступ по свежему ключу: %+v, %v", access, err)
}
if access.Name != "общая" {
t.Errorf("имя комнаты: получено %q, ожидалось \"общая\"", access.Name)
}
}
+68
View File
@@ -0,0 +1,68 @@
package web_test
import (
"io/fs"
"regexp"
"testing"
bare "github.com/xmatic-squad/bare"
)
// shellRe — массив SHELL из web/sw.js: перечень оболочки списком строк.
var shellRe = regexp.MustCompile(`(?s)const SHELL = \[(.*?)\];`)
var pathRe = regexp.MustCompile(`"([^"]+)"`)
// Оболочка в sw.js перечислена вручную (у Cache API нет масок), и забытый
// в ней файл ломает только офлайн — молча. Поэтому список сверяется
// с содержимым embed: оболочка — всё из web/, кроме самого sw.js;
// index.html лежит в кэше под адресом «/» (ADR-023).
func TestShellCoversStatic(t *testing.T) {
root, err := fs.Sub(bare.Web, "web")
if err != nil {
t.Fatalf("embed: %v", err)
}
worker, err := fs.ReadFile(root, "sw.js")
if err != nil {
t.Fatalf("sw.js: %v", err)
}
block := shellRe.FindSubmatch(worker)
if block == nil {
t.Fatal("в sw.js нет массива SHELL")
}
shell := make(map[string]bool)
for _, m := range pathRe.FindAllSubmatch(block[1], -1) {
shell[string(m[1])] = true
}
want := make(map[string]bool)
err = fs.WalkDir(root, ".", func(p string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
switch {
case d.IsDir(), p == "sw.js":
// Обновление воркера ведёт браузер, в кэш он не кладётся.
return nil
case p == "index.html":
want["/"] = true
default:
want["/"+p] = true
}
return nil
})
if err != nil {
t.Fatalf("обход embed: %v", err)
}
for p := range want {
if !shell[p] {
t.Errorf("%s есть в web/, но не в SHELL: офлайн он не откроется", p)
}
}
for p := range shell {
if !want[p] {
t.Errorf("%s есть в SHELL, но не в web/: install воркера упадёт целиком", p)
}
}
}