Этап 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:
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user