Files
bare/internal/api/messages.go
T
mayatnikovandClaude Opus 5 63a7a1ef52 Этап 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
2026-08-22 18:18:04 +03:00

202 lines
6.1 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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", "ник: 232 символа, az, 09, _")
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", "слишком часто, попробуйте позже")
}