Этап 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,120 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/xmatic-squad/bare/internal/auth"
|
||||
"github.com/xmatic-squad/bare/internal/store"
|
||||
)
|
||||
|
||||
// deviceID — тело и ответ POST /api/devices: идентификатор выдаёт клиент
|
||||
// (ADR-017), сервер только проверяет форму и принадлежность.
|
||||
type deviceID struct {
|
||||
ID string `json:"id"`
|
||||
}
|
||||
|
||||
// POST /api/devices — регистрация устройства. Уже заведённое своё —
|
||||
// 200 и обновлённый last_seen; занятое чужим — 409, клиент берёт новый id.
|
||||
func (s *server) createDevice(w http.ResponseWriter, r *http.Request) {
|
||||
var in deviceID
|
||||
if !decode(w, r, &in) {
|
||||
return
|
||||
}
|
||||
if !validID(in.ID) {
|
||||
Invalid(w, "id", "id — не 16 байт base64url")
|
||||
return
|
||||
}
|
||||
sess, _ := auth.From(r)
|
||||
created, err := s.st.RegisterDevice(r.Context(), in.ID, sess.Nick, sess.TokenHash, time.Now().UnixMilli())
|
||||
if errors.Is(err, store.ErrDeviceTaken) {
|
||||
Error(w, http.StatusConflict, "device_conflict", "такое устройство уже есть")
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
s.internal(w, r, err)
|
||||
return
|
||||
}
|
||||
status := http.StatusOK
|
||||
if created {
|
||||
status = http.StatusCreated
|
||||
}
|
||||
writeJSON(w, status, deviceID{in.ID})
|
||||
}
|
||||
|
||||
// deviceOut — строка ответа GET /api/devices.
|
||||
type deviceOut struct {
|
||||
ID string `json:"id"`
|
||||
CreatedAt int64 `json:"createdAt"`
|
||||
LastSeen int64 `json:"lastSeen"`
|
||||
HasPush bool `json:"hasPush"`
|
||||
Current bool `json:"current"`
|
||||
}
|
||||
|
||||
// GET /api/devices — устройства аккаунта. Самой push-подписки в ответе
|
||||
// нет, только факт её наличия.
|
||||
func (s *server) devices(w http.ResponseWriter, r *http.Request) {
|
||||
sess, _ := auth.From(r)
|
||||
list, err := s.st.Devices(r.Context(), sess.Nick)
|
||||
if err != nil {
|
||||
s.internal(w, r, err)
|
||||
return
|
||||
}
|
||||
out := make([]deviceOut, 0, len(list))
|
||||
for _, d := range list {
|
||||
out = append(out, deviceOut{
|
||||
ID: d.ID,
|
||||
CreatedAt: d.CreatedAt,
|
||||
LastSeen: d.LastSeen,
|
||||
HasPush: d.HasPush,
|
||||
Current: d.ID == sess.DeviceID,
|
||||
})
|
||||
}
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
}
|
||||
|
||||
// DELETE /api/devices/{id} — удаление устройства: очередь, подписка
|
||||
// и сессии уходят каскадом, открытый поток событий закрывается.
|
||||
//
|
||||
// Чужое и несуществующее устройство отвечают тем же 204: удалять нечего,
|
||||
// а отдельного кода на этот случай в протоколе нет (docs/protocol.md).
|
||||
func (s *server) deleteDevice(w http.ResponseWriter, r *http.Request) {
|
||||
sess, _ := auth.From(r)
|
||||
id := r.PathValue("id")
|
||||
deleted, err := s.st.DeleteDevice(r.Context(), id, sess.Nick)
|
||||
if err != nil {
|
||||
s.internal(w, r, err)
|
||||
return
|
||||
}
|
||||
if deleted {
|
||||
s.hub.Close(id)
|
||||
}
|
||||
noContent(w)
|
||||
}
|
||||
|
||||
// device читает X-Device и проверяет, что устройство принадлежит
|
||||
// пользователю сессии. Заголовка нет, форма кривая, устройство чужое —
|
||||
// всё это 403 unknown_device (docs/protocol.md, «Общие правила»).
|
||||
func (s *server) device(w http.ResponseWriter, r *http.Request) (string, bool) {
|
||||
sess, _ := auth.From(r)
|
||||
id := r.Header.Get("X-Device")
|
||||
if !validID(id) {
|
||||
unknownDevice(w)
|
||||
return "", false
|
||||
}
|
||||
owned, err := s.st.DeviceOwned(r.Context(), id, sess.Nick)
|
||||
if err != nil {
|
||||
s.internal(w, r, err)
|
||||
return "", false
|
||||
}
|
||||
if !owned {
|
||||
unknownDevice(w)
|
||||
return "", false
|
||||
}
|
||||
return id, true
|
||||
}
|
||||
|
||||
func unknownDevice(w http.ResponseWriter) {
|
||||
Error(w, http.StatusForbidden, "unknown_device", "это устройство не ваше")
|
||||
}
|
||||
Reference in New Issue
Block a user