Сервер: пакет 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
249 lines
8.2 KiB
Go
249 lines
8.2 KiB
Go
package api
|
||
|
||
import (
|
||
"encoding/json"
|
||
"net/http"
|
||
"strconv"
|
||
"time"
|
||
|
||
"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"
|
||
)
|
||
|
||
// clockSkew — на сколько метка времени ULID вправе разойтись с часами
|
||
// сервера (ADR-017).
|
||
const clockSkew = 5 * time.Minute
|
||
|
||
// dmKeyID — keyId личного чата: ключ выводится из ECDH, идентификатора
|
||
// у него нет (docs/crypto.md, «Сообщение»).
|
||
const dmKeyID = "dm"
|
||
|
||
// maxAck — сколько идентификаторов принимает один ACK.
|
||
const maxAck = 500
|
||
|
||
// target — адресат конверта: ровно одно из двух.
|
||
type target struct {
|
||
DM string `json:"dm,omitempty"`
|
||
Room string `json:"room,omitempty"`
|
||
}
|
||
|
||
// envelope — конверт из docs/protocol.md. Порядок полей — как в нём.
|
||
// from и ts ставит сервер: клиентские значения не читаются вовсе (ADR-017).
|
||
type envelope struct {
|
||
ID string `json:"id"`
|
||
To target `json:"to"`
|
||
From string `json:"from"`
|
||
KeyID string `json:"keyId"`
|
||
IV string `json:"iv"`
|
||
CT string `json:"ct"`
|
||
TS int64 `json:"ts"`
|
||
}
|
||
|
||
// messageIn — тело POST /api/messages. Полей from и ts здесь нет
|
||
// намеренно: что бы клиент ни прислал, сервер ставит своё (ADR-017).
|
||
type messageIn struct {
|
||
ID string `json:"id"`
|
||
To target `json:"to"`
|
||
KeyID string `json:"keyId"`
|
||
IV string `json:"iv"`
|
||
CT string `json:"ct"`
|
||
}
|
||
|
||
// POST /api/messages — отправка. Сервер не умеет проверять шифротекст,
|
||
// он проверяет форму и раскладывает конверт по очередям (ADR-008).
|
||
// Порядок проверок — docs/protocol.md, «Сообщения».
|
||
func (s *server) sendMessage(w http.ResponseWriter, r *http.Request) {
|
||
device, ok := s.device(w, r)
|
||
if !ok {
|
||
return
|
||
}
|
||
var in messageIn
|
||
if !decode(w, r, &in) {
|
||
return
|
||
}
|
||
ms, ok := checkForm(w, in)
|
||
if !ok {
|
||
return
|
||
}
|
||
|
||
now := time.Now()
|
||
if d := now.Sub(time.UnixMilli(ms)); d > clockSkew || d < -clockSkew {
|
||
Error(w, http.StatusBadRequest, "clock_skew",
|
||
"проверьте часы на устройстве: расхождение больше 5 минут")
|
||
return
|
||
}
|
||
|
||
sess, _ := auth.From(r)
|
||
room := in.To.Room != ""
|
||
// Заголовок и адрес чата для пуша: сервер собирает их из того, что
|
||
// и так знает, — из ников и имени комнаты (ADR-023).
|
||
var signal push.Payload
|
||
if room {
|
||
access, err := s.st.RoomAccess(r.Context(), in.To.Room, sess.Nick, in.KeyID)
|
||
if err != nil {
|
||
s.internal(w, r, err)
|
||
return
|
||
}
|
||
if !access.Member {
|
||
Error(w, http.StatusForbidden, "not_member", "вы не участник комнаты")
|
||
return
|
||
}
|
||
if !access.KnownKey {
|
||
Error(w, http.StatusBadRequest, "unknown_key", "у комнаты нет такого ключа")
|
||
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 {
|
||
s.rateLimited(w, wait)
|
||
return
|
||
}
|
||
|
||
env := envelope{
|
||
ID: in.ID,
|
||
To: target{DM: in.To.DM, Room: in.To.Room},
|
||
From: sess.Nick,
|
||
KeyID: in.KeyID,
|
||
IV: in.IV,
|
||
CT: in.CT,
|
||
TS: now.UnixMilli(),
|
||
}
|
||
raw, err := json.Marshal(env)
|
||
if err != nil {
|
||
s.internal(w, r, err)
|
||
return
|
||
}
|
||
delivery := store.Delivery{
|
||
From: env.From,
|
||
To: env.To.DM,
|
||
Room: env.To.Room,
|
||
Exclude: device,
|
||
MsgID: env.ID,
|
||
Envelope: string(raw),
|
||
Now: env.TS,
|
||
}
|
||
var devices []store.Target
|
||
if room {
|
||
devices, err = s.st.DeliverRoom(r.Context(), delivery)
|
||
} else {
|
||
devices, err = s.st.DeliverDM(r.Context(), delivery)
|
||
}
|
||
if err != nil {
|
||
s.internal(w, r, err)
|
||
return
|
||
}
|
||
// Очередь уже записана: подключённое устройство получает конверт
|
||
// сразу, остальные — при подключении.
|
||
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.
|
||
func checkForm(w http.ResponseWriter, in messageIn) (int64, bool) {
|
||
ms, ok := ulidTime(in.ID)
|
||
if !ok {
|
||
Invalid(w, "id", "id — не ulid из 26 символов")
|
||
return 0, false
|
||
}
|
||
if (in.To.DM == "") == (in.To.Room == "") {
|
||
Invalid(w, "to", "to — ровно одно из dm и room")
|
||
return 0, false
|
||
}
|
||
if in.To.DM != "" {
|
||
if !validNick(in.To.DM) {
|
||
Invalid(w, "to", "ник: 2–32 символа, a–z, 0–9, _")
|
||
return 0, false
|
||
}
|
||
if in.KeyID != dmKeyID {
|
||
Invalid(w, "keyId", `keyId личного чата — "dm"`)
|
||
return 0, false
|
||
}
|
||
} else {
|
||
if !validID(in.To.Room) {
|
||
Invalid(w, "to", "room — не 16 байт base64url")
|
||
return 0, false
|
||
}
|
||
if !validID(in.KeyID) {
|
||
Invalid(w, "keyId", "keyId — не 16 байт base64url")
|
||
return 0, false
|
||
}
|
||
}
|
||
if _, ok := decodeExactly(in.IV, ivLen); !ok {
|
||
Invalid(w, "iv", "iv — не 12 байт base64url")
|
||
return 0, false
|
||
}
|
||
if ct, err := b64.DecodeString(in.CT); err != nil || len(ct) < minCTLen {
|
||
Invalid(w, "ct", "ct — не base64url или слишком короткий")
|
||
return 0, false
|
||
}
|
||
return ms, true
|
||
}
|
||
|
||
// POST /api/ack — клиент записал сообщения в IndexedDB: из очереди
|
||
// устройства их можно убрать (ADR-008).
|
||
func (s *server) ack(w http.ResponseWriter, r *http.Request) {
|
||
device, ok := s.device(w, r)
|
||
if !ok {
|
||
return
|
||
}
|
||
var in struct {
|
||
IDs []string `json:"ids"`
|
||
}
|
||
if !decode(w, r, &in) {
|
||
return
|
||
}
|
||
if len(in.IDs) > maxAck {
|
||
Invalid(w, "ids", "не больше 500 идентификаторов")
|
||
return
|
||
}
|
||
if err := s.st.Ack(r.Context(), device, in.IDs); err != nil {
|
||
s.internal(w, r, err)
|
||
return
|
||
}
|
||
noContent(w)
|
||
}
|
||
|
||
// rateLimited — 429 с Retry-After в секундах (ADR-021).
|
||
func (s *server) rateLimited(w http.ResponseWriter, wait time.Duration) {
|
||
w.Header().Set("Retry-After", strconv.Itoa(retryAfter(wait)))
|
||
Error(w, http.StatusTooManyRequests, "rate_limited", "слишком часто, попробуйте позже")
|
||
}
|