Files
bare/internal/api/push.go
T
mayatnikovandClaude Opus 5 8f67f4aa4d Этап 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
2026-08-23 00:50:58 +03:00

164 lines
6.2 KiB
Go

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
}