Сервер: регистрация устройств и 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
98 lines
3.0 KiB
Go
98 lines
3.0 KiB
Go
package api
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"net/http"
|
|
"time"
|
|
|
|
"github.com/xmatic-squad/bare/internal/auth"
|
|
"github.com/xmatic-squad/bare/internal/store"
|
|
)
|
|
|
|
// Контакт — строка в списке чатов, не разрешение на переписку: писать
|
|
// можно любому нику, согласия не требуется (ADR-019).
|
|
|
|
// GET /api/contacts — список чатов 1:1 с публичными ключами собеседников.
|
|
func (s *server) contacts(w http.ResponseWriter, r *http.Request) {
|
|
sess, _ := auth.From(r)
|
|
list, err := s.st.Contacts(r.Context(), sess.Nick)
|
|
if err != nil {
|
|
s.internal(w, r, err)
|
|
return
|
|
}
|
|
type contactOut struct {
|
|
Nick string `json:"nick"`
|
|
PublicKey json.RawMessage `json:"publicKey"`
|
|
CreatedAt int64 `json:"createdAt"`
|
|
}
|
|
out := make([]contactOut, 0, len(list))
|
|
for _, c := range list {
|
|
out = append(out, contactOut{c.Nick, json.RawMessage(c.PublicKey), c.CreatedAt})
|
|
}
|
|
writeJSON(w, http.StatusOK, out)
|
|
}
|
|
|
|
// POST /api/contacts — завести чат с ником вручную, до первого сообщения.
|
|
func (s *server) addContact(w http.ResponseWriter, r *http.Request) {
|
|
var in struct {
|
|
Nick string `json:"nick"`
|
|
}
|
|
if !decode(w, r, &in) {
|
|
return
|
|
}
|
|
sess, _ := auth.From(r)
|
|
peer, ok := s.peer(w, r, in.Nick, sess.Nick)
|
|
if !ok {
|
|
return
|
|
}
|
|
created, err := s.st.AddContact(r.Context(), sess.Nick, peer.Nick, time.Now().UnixMilli())
|
|
if err != nil {
|
|
s.internal(w, r, err)
|
|
return
|
|
}
|
|
status := http.StatusOK
|
|
if created {
|
|
status = http.StatusCreated
|
|
}
|
|
writeJSON(w, status, struct {
|
|
Nick string `json:"nick"`
|
|
PublicKey json.RawMessage `json:"publicKey"`
|
|
}{peer.Nick, json.RawMessage(peer.PublicKey)})
|
|
}
|
|
|
|
// DELETE /api/contacts/{nick} — убрать чат из списка. Зеркальная строка
|
|
// у собеседника остаётся: это не блокировка (ADR-019). Строки не было —
|
|
// тот же 204, удалять нечего.
|
|
func (s *server) deleteContact(w http.ResponseWriter, r *http.Request) {
|
|
sess, _ := auth.From(r)
|
|
if err := s.st.DeleteContact(r.Context(), sess.Nick, r.PathValue("nick")); err != nil {
|
|
s.internal(w, r, err)
|
|
return
|
|
}
|
|
noContent(w)
|
|
}
|
|
|
|
// peer читает собеседника по нику. Порядок отказов — docs/protocol.md:
|
|
// сначала существование ника, потом запрет писать себе.
|
|
func (s *server) peer(w http.ResponseWriter, r *http.Request, nick, me string) (store.User, bool) {
|
|
if !validNick(nick) {
|
|
unknownUser(w)
|
|
return store.User{}, false
|
|
}
|
|
u, err := s.st.User(r.Context(), nick)
|
|
if errors.Is(err, store.ErrNotFound) {
|
|
unknownUser(w)
|
|
return store.User{}, false
|
|
}
|
|
if err != nil {
|
|
s.internal(w, r, err)
|
|
return store.User{}, false
|
|
}
|
|
if u.Nick == me {
|
|
Error(w, http.StatusBadRequest, "self", "нельзя писать себе")
|
|
return store.User{}, false
|
|
}
|
|
return u, true
|
|
}
|