Этап 2: чат 1:1 — устройства, очередь, SSE, шифрование сообщений
Сервер: регистрация устройств и X-Device, hub с одним потоком на устройство, очередь per-device с фан-аутом без эха отправителю, POST /api/messages с проверками в порядке protocol.md, ACK, SSE с воспроизведением очереди, ready и пингом раз в 20 секунд, контакты в обе стороны при первом сообщении, лимит 30 сообщений в минуту. Клиент: ULID, ключ 1:1 из ECDH через HKDF, шифрование конверта с AAD, sync.js как единственный писатель в IndexedDB, ACK строго после записи, список чатов, экран чата по эталону, разделители дат и «новые», pending и failed с повтором, полоса «нет соединения». ADR-033: у неотправленного есть текст отказа — clock_skew стало видно. ADR-034: входящее с известным id не перезаписывает запись. Собеседник знает открытый id конверта и подменял им чужое сообщение в чужой истории — вплоть до стирания своего присланного, чего «удалить у всех не существует» не допускает. ADR-035: один поток событий на браузерный профиль (locks + BroadcastChannel): две вкладки отбирали поток друг у друга и оставались без живой доставки. ADR-036: повтор отправки сохраняет ULID, пока он в пределах окна часов, — иначе потерянный ответ давал у собеседника два сообщения вместо одного. Приёмка на боевом сервере: два аккаунта, пять устройств, живая доставка, копия на второе устройство, очередь офлайн-устройству, ACK, подмена from игнорируется, чужой deviceId и запрос без Origin отбиваются, плейнтекста в базе и WAL ноль вхождений. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015DbCjVfTFq4ZFG8juD45YJ
This commit is contained in:
@@ -0,0 +1,201 @@
|
||||
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/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
|
||||
}
|
||||
|
||||
if in.To.Room != "" {
|
||||
// Комнаты — этап 3. Участников нет ни у одной комнаты, потому что
|
||||
// нет и самих комнат: единственный возможный ответ — not_member.
|
||||
Error(w, http.StatusForbidden, "not_member", "вы не участник комнаты")
|
||||
return
|
||||
}
|
||||
sess, _ := auth.From(r)
|
||||
if _, ok := s.peer(w, r, in.To.DM, sess.Nick); !ok {
|
||||
return
|
||||
}
|
||||
|
||||
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},
|
||||
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
|
||||
}
|
||||
devices, err := s.st.DeliverDM(r.Context(), store.Delivery{
|
||||
From: env.From,
|
||||
To: env.To.DM,
|
||||
Exclude: device,
|
||||
MsgID: env.ID,
|
||||
Envelope: string(raw),
|
||||
Now: env.TS,
|
||||
})
|
||||
if err != nil {
|
||||
s.internal(w, r, err)
|
||||
return
|
||||
}
|
||||
// Очередь уже записана: подключённое устройство получает конверт
|
||||
// сразу, остальные — при подключении. Пуши — этап 4.
|
||||
for _, id := range devices {
|
||||
s.hub.Send(id, hub.Event{Name: "msg", Data: string(raw)})
|
||||
}
|
||||
writeJSON(w, http.StatusAccepted, struct {
|
||||
ID string `json:"id"`
|
||||
TS int64 `json:"ts"`
|
||||
}{env.ID, env.TS})
|
||||
}
|
||||
|
||||
// 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", "слишком часто, попробуйте позже")
|
||||
}
|
||||
Reference in New Issue
Block a user