Этап 3: TOFU и комнаты — ключ комнаты, атомарный rekey, участники
Сервер: комнаты, состав и завёрнутые ключи; смена состава и rekey одним атомарным запросом с проверкой keys[].to против итогового состава; обрезка до двух последних ключей; передача владения по joined_at; события room каждому со своим ключом и room_left удалённым; членство и keyId в проверках сообщения; миграция 002. Клиент: TOFU на всех путях, по которым публичный ключ доходит до клиента; предупреждение о смене ключа с блокировкой отправки и повторной расшифровкой сохранённого raw; заворачивание и разворачивание ключей комнаты, включая себе — тем же кодом, без ветвления; расшифровка любым известным keyId; экраны участников, создание комнаты, карточка контакта с двумя отпечатками. ADR-037: roomId генерирует клиент. crypto.md вплетает roomId в заворачивание, а оно происходит до запроса — создатель обязан привязать ключ к идентификатору, которого по прежнему протоколу ещё не существовало. ADR-039: завёрнутый ключ принимается только от участника. Иначе сервер подставляет ключ, завёрнутый посторонним аккаунтом, TOFU молчит — ник незнакомый, первый ключ запоминается молча, — и комната уезжает на ключ сервера. Одно подменённое поле в ответе, без сговора и подмены кода. ADR-041: долг по rekey — состояние комнаты, а не свойство события. Владелец, офлайн в момент выхода участника, не узнавал о долге никогда, и комната навсегда оставалась на ключе, который вышедший знает. ADR-038, 040, 042, 043, 044: тексты экранов комнат, снятие pending при возврате прежнего ключа, порядок ключей, форма раньше прав, экран покинутой комнаты. Приёмка на боевом: комната на троих, добавленный четвёртый читает только новое, вышедший после rekey новых не получает и писать не может, keys_mismatch, key_exists, not_owner, owner, room_conflict, чужой X-Device. Подмена public_key в базе даёт у собеседника предупреждение и блокирует отправку — проверено в Chrome. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015DbCjVfTFq4ZFG8juD45YJ
This commit is contained in:
+10
-4
@@ -256,13 +256,19 @@ func (s *server) deleteMe(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.confirm(w, in.AuthKey, u) {
|
||||
return
|
||||
}
|
||||
// Устройства, сессии, контакты, членство и очереди уносит каскад.
|
||||
// Комнаты, где пользователь владелец, требуют передачи владения
|
||||
// (ADR-018) — это этап 3, до появления комнат случай не наступает.
|
||||
if err := s.st.DeleteUser(r.Context(), u.Nick); err != nil {
|
||||
// Устройства, сессии, контакты, членство, ключи комнат и очереди уносит
|
||||
// каскад; комнаты, где пользователь владелец, меняют владельца или
|
||||
// удаляются пустыми (ADR-018) — всё в одной транзакции хранилища.
|
||||
changes, err := s.st.DeleteUser(r.Context(), u.Nick)
|
||||
if err != nil {
|
||||
s.internal(w, r, err)
|
||||
return
|
||||
}
|
||||
// Удаление аккаунта — выход из всех его комнат: оставшимся уходит room
|
||||
// с needsRekey, каждому со своим ключом (ADR-041).
|
||||
for _, change := range changes {
|
||||
s.sendRoom(r, change, "")
|
||||
}
|
||||
auth.ClearCookie(w)
|
||||
noContent(w)
|
||||
}
|
||||
|
||||
@@ -84,6 +84,12 @@ func New(cfg *config.Config, st *store.Store, static http.Handler, logw io.Write
|
||||
mux.Handle("POST /api/contacts", private(http.HandlerFunc(s.addContact)))
|
||||
mux.Handle("DELETE /api/contacts/{nick}", private(http.HandlerFunc(s.deleteContact)))
|
||||
|
||||
mux.Handle("GET /api/rooms", private(http.HandlerFunc(s.rooms)))
|
||||
mux.Handle("POST /api/rooms", private(http.HandlerFunc(s.createRoom)))
|
||||
mux.Handle("POST /api/rooms/{id}/members", private(http.HandlerFunc(s.updateMembers)))
|
||||
mux.Handle("POST /api/rooms/{id}/leave", private(http.HandlerFunc(s.leaveRoom)))
|
||||
mux.Handle("DELETE /api/rooms/{id}", private(http.HandlerFunc(s.deleteRoom)))
|
||||
|
||||
mux.Handle("GET /api/events", private(http.HandlerFunc(s.events)))
|
||||
mux.Handle("POST /api/messages", private(http.HandlerFunc(s.sendMessage)))
|
||||
mux.Handle("POST /api/ack", private(http.HandlerFunc(s.ack)))
|
||||
|
||||
@@ -115,6 +115,18 @@ func (s *server) device(w http.ResponseWriter, r *http.Request) (string, bool) {
|
||||
return id, true
|
||||
}
|
||||
|
||||
// optionalDevice — то же для маршрутов, где заголовок необязателен:
|
||||
// он всего лишь просит не возвращать эхо отправившему устройству. Пустой
|
||||
// X-Device — пусто, непустой обязан быть своим устройством: правило
|
||||
// принадлежности общее для всех маршрутов, где устройство важно
|
||||
// (docs/protocol.md, «Общие правила»).
|
||||
func (s *server) optionalDevice(w http.ResponseWriter, r *http.Request) (string, bool) {
|
||||
if r.Header.Get("X-Device") == "" {
|
||||
return "", true
|
||||
}
|
||||
return s.device(w, r)
|
||||
}
|
||||
|
||||
func unknownDevice(w http.ResponseWriter) {
|
||||
Error(w, http.StatusForbidden, "unknown_device", "это устройство не ваше")
|
||||
}
|
||||
|
||||
@@ -151,6 +151,36 @@ func TestForeignDevice(t *testing.T) {
|
||||
// Со своим устройством — обычная отправка.
|
||||
expect(t, e.do(http.MethodPost, "/api/messages", message(ulid(nowMillis(), 4), "marta"),
|
||||
with(petya), withDevice(petyaDevice)), http.StatusAccepted, "")
|
||||
|
||||
// Комнаты: заголовок здесь необязателен — он всего лишь просит не слать
|
||||
// событие отправившему устройству, — но принадлежность проверяется
|
||||
// та же (docs/protocol.md, «Общие правила», «Комнаты»).
|
||||
room := map[string]any{
|
||||
"id": roomIDOf(40),
|
||||
"name": "общая",
|
||||
"keyId": keyID(40),
|
||||
"keys": keysFor([]string{"petya"}, 40),
|
||||
}
|
||||
expect(t, e.do(http.MethodPost, "/api/rooms", room, with(petya), withDevice(martaDevice)),
|
||||
http.StatusForbidden, "unknown_device")
|
||||
expect(t, e.do(http.MethodPost, "/api/rooms", room, with(petya), withDevice("мусор")),
|
||||
http.StatusForbidden, "unknown_device")
|
||||
expect(t, e.do(http.MethodPost, "/api/rooms", room, with(petya), withDevice(deviceOf(9))),
|
||||
http.StatusForbidden, "unknown_device")
|
||||
// Отказ ничего не создал: идентификатор комнаты свободен.
|
||||
expect(t, e.do(http.MethodPost, "/api/rooms", room, with(petya)), http.StatusCreated, "")
|
||||
|
||||
leave := "/api/rooms/" + roomIDOf(40) + "/leave"
|
||||
expect(t, e.do(http.MethodPost, leave, nil, with(petya), withDevice(martaDevice)),
|
||||
http.StatusForbidden, "unknown_device")
|
||||
expect(t, e.do(http.MethodPost, leave, nil, with(petya), withDevice("мусор")),
|
||||
http.StatusForbidden, "unknown_device")
|
||||
// Отказ ничего не изменил: из комнаты никто не вышел.
|
||||
if got := e.room(petya, roomIDOf(40)); got == nil {
|
||||
t.Fatal("комната пропала после отказа по устройству")
|
||||
}
|
||||
expect(t, e.do(http.MethodPost, leave, nil, with(petya), withDevice(petyaDevice)),
|
||||
http.StatusNoContent, "")
|
||||
}
|
||||
|
||||
// Удаление устройства уносит очередь и сессии устройства.
|
||||
|
||||
+26
-10
@@ -74,14 +74,23 @@ func (s *server) sendMessage(w http.ResponseWriter, r *http.Request) {
|
||||
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 {
|
||||
room := in.To.Room != ""
|
||||
if room {
|
||||
member, knownKey, err := s.st.RoomAccess(r.Context(), in.To.Room, sess.Nick, in.KeyID)
|
||||
if err != nil {
|
||||
s.internal(w, r, err)
|
||||
return
|
||||
}
|
||||
if !member {
|
||||
Error(w, http.StatusForbidden, "not_member", "вы не участник комнаты")
|
||||
return
|
||||
}
|
||||
if !knownKey {
|
||||
Error(w, http.StatusBadRequest, "unknown_key", "у комнаты нет такого ключа")
|
||||
return
|
||||
}
|
||||
} else if _, ok := s.peer(w, r, in.To.DM, sess.Nick); !ok {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -92,7 +101,7 @@ func (s *server) sendMessage(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
env := envelope{
|
||||
ID: in.ID,
|
||||
To: target{DM: in.To.DM},
|
||||
To: target{DM: in.To.DM, Room: in.To.Room},
|
||||
From: sess.Nick,
|
||||
KeyID: in.KeyID,
|
||||
IV: in.IV,
|
||||
@@ -104,14 +113,21 @@ func (s *server) sendMessage(w http.ResponseWriter, r *http.Request) {
|
||||
s.internal(w, r, err)
|
||||
return
|
||||
}
|
||||
devices, err := s.st.DeliverDM(r.Context(), store.Delivery{
|
||||
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 []string
|
||||
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
|
||||
|
||||
@@ -0,0 +1,385 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/xmatic-squad/bare/internal/auth"
|
||||
"github.com/xmatic-squad/bare/internal/hub"
|
||||
"github.com/xmatic-squad/bare/internal/store"
|
||||
)
|
||||
|
||||
// Комнаты (ADR-018): владелец меняет состав, ключи заворачивают клиенты.
|
||||
// Сервер проверяет форму и права, хранит шифротекст и раздаёт события.
|
||||
|
||||
// maxRoomName — имя комнаты, символов (ADR-021). Имя открыто: это
|
||||
// метаданные, как и состав.
|
||||
const maxRoomName = 64
|
||||
|
||||
// roomOut — тип Room из docs/protocol.md. key присутствует всегда,
|
||||
// пустой — null; needsRekey — состояние комнаты, а не свойство события,
|
||||
// поэтому идёт и в списке, и в событии (ADR-041).
|
||||
type roomOut struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Owner string `json:"owner"`
|
||||
Members []string `json:"members"`
|
||||
CreatedAt int64 `json:"createdAt"`
|
||||
Key *keyOut `json:"key"`
|
||||
NeedsRekey bool `json:"needsRekey"`
|
||||
}
|
||||
|
||||
// keyOut — завёрнутый ключ комнаты для того, кто его получает.
|
||||
type keyOut struct {
|
||||
KeyID string `json:"keyId"`
|
||||
From string `json:"from"`
|
||||
IV string `json:"iv"`
|
||||
CT string `json:"ct"`
|
||||
}
|
||||
|
||||
// keyIn — запись keys[] запроса: WrappedKey из docs/protocol.md.
|
||||
type keyIn struct {
|
||||
To string `json:"to"`
|
||||
IV string `json:"iv"`
|
||||
CT string `json:"ct"`
|
||||
}
|
||||
|
||||
// GET /api/rooms — комнаты, где пользователь участник, каждая с его
|
||||
// текущим ключом и признаком needsRekey: владелец, пропустивший событие,
|
||||
// поднимает долг по ключу отсюда (ADR-041).
|
||||
func (s *server) rooms(w http.ResponseWriter, r *http.Request) {
|
||||
sess, _ := auth.From(r)
|
||||
list, err := s.st.Rooms(r.Context(), sess.Nick)
|
||||
if err != nil {
|
||||
s.internal(w, r, err)
|
||||
return
|
||||
}
|
||||
out := make([]roomOut, 0, len(list))
|
||||
for _, room := range list {
|
||||
out = append(out, roomJSON(room, room.Key))
|
||||
}
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
}
|
||||
|
||||
// POST /api/rooms — создание комнаты. Идентификатор выдаёт клиент
|
||||
// (ADR-037), ключ приходит ровно один и заворачивается создателем себе:
|
||||
// его другие устройства получают комнату вместе с ключом (ADR-018).
|
||||
func (s *server) createRoom(w http.ResponseWriter, r *http.Request) {
|
||||
// X-Device здесь необязателен, но чужой и кривой — 403, как и везде,
|
||||
// где устройство важно (docs/protocol.md, «Общие правила»).
|
||||
device, ok := s.optionalDevice(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var in struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
KeyID string `json:"keyId"`
|
||||
Keys []keyIn `json:"keys"`
|
||||
}
|
||||
if !decode(w, r, &in) {
|
||||
return
|
||||
}
|
||||
// Идентификатор комнаты генерирует клиент: ключ заворачивается до
|
||||
// запроса и привязан к roomId в info и AAD (ADR-037).
|
||||
if !validID(in.ID) {
|
||||
Invalid(w, "id", "id комнаты — не 16 байт base64url")
|
||||
return
|
||||
}
|
||||
if !validRoomName(in.Name) {
|
||||
Invalid(w, "name", "имя комнаты: 1–64 символа")
|
||||
return
|
||||
}
|
||||
if !validID(in.KeyID) {
|
||||
Invalid(w, "keyId", "keyId — не 16 байт base64url")
|
||||
return
|
||||
}
|
||||
keys, ok := wrappedKeys(w, in.Keys)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
sess, _ := auth.From(r)
|
||||
// Состав новой комнаты — один создатель, поэтому и ключ ровно один.
|
||||
// Несовпадение — то же самое, что при rekey: keys не по составу.
|
||||
if len(keys) != 1 || keys[0].To != sess.Nick {
|
||||
keysMismatch(w)
|
||||
return
|
||||
}
|
||||
change, err := s.st.CreateRoom(r.Context(), store.NewRoom{
|
||||
ID: in.ID,
|
||||
Name: in.Name,
|
||||
Owner: sess.Nick,
|
||||
KeyID: in.KeyID,
|
||||
Key: keys[0],
|
||||
Now: time.Now().UnixMilli(),
|
||||
})
|
||||
if errors.Is(err, store.ErrRoomExists) {
|
||||
// Занятый идентификатор не присоединяет к чужой комнате и не
|
||||
// перезаписывает свою: клиент берёт новый (ADR-037).
|
||||
Error(w, http.StatusConflict, "room_conflict", "такая комната уже есть")
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
s.internal(w, r, err)
|
||||
return
|
||||
}
|
||||
// Комната уже записана: остальным устройствам создателя она уходит
|
||||
// событием, отправившему — ответом на запрос.
|
||||
s.sendRoom(r, change, device)
|
||||
writeJSON(w, http.StatusCreated, roomJSON(change.Room, keyFor(change, sess.Nick)))
|
||||
}
|
||||
|
||||
// POST /api/rooms/{id}/members — смена состава и rekey одним запросом
|
||||
// (ADR-018). Пустые add и remove — чистый rekey.
|
||||
func (s *server) updateMembers(w http.ResponseWriter, r *http.Request) {
|
||||
var in struct {
|
||||
Add []string `json:"add"`
|
||||
Remove []string `json:"remove"`
|
||||
KeyID string `json:"keyId"`
|
||||
Keys []keyIn `json:"keys"`
|
||||
}
|
||||
if !decode(w, r, &in) {
|
||||
return
|
||||
}
|
||||
add, ok := uniqueNicks(in.Add)
|
||||
if !ok {
|
||||
// Форма — это форма: несуществующий ник верной формы отвечает
|
||||
// unknown_user, а ник не по форме — invalid, как и в remove
|
||||
// (ADR-043).
|
||||
Invalid(w, "add", "добавить можно только ник a–z, 0–9, _")
|
||||
return
|
||||
}
|
||||
remove, ok := uniqueNicks(in.Remove)
|
||||
if !ok {
|
||||
Invalid(w, "remove", "убрать можно только участника комнаты")
|
||||
return
|
||||
}
|
||||
for _, nick := range remove {
|
||||
for _, other := range add {
|
||||
if nick == other {
|
||||
Invalid(w, "remove", "один ник нельзя добавить и убрать одним запросом")
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
if !validID(in.KeyID) {
|
||||
Invalid(w, "keyId", "keyId — не 16 байт base64url")
|
||||
return
|
||||
}
|
||||
keys, ok := wrappedKeys(w, in.Keys)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
sess, _ := auth.From(r)
|
||||
change, err := s.st.UpdateMembers(r.Context(), store.MembersChange{
|
||||
RoomID: r.PathValue("id"),
|
||||
Owner: sess.Nick,
|
||||
Add: add,
|
||||
Remove: remove,
|
||||
KeyID: in.KeyID,
|
||||
Keys: keys,
|
||||
Now: time.Now().UnixMilli(),
|
||||
})
|
||||
if err != nil {
|
||||
s.roomError(w, r, err)
|
||||
return
|
||||
}
|
||||
// Событие room уходит и участникам, и — как room_left — убранным;
|
||||
// каждому участнику со своим ключом (docs/protocol.md, «Комнаты»).
|
||||
s.sendRoom(r, change, "")
|
||||
writeJSON(w, http.StatusOK, roomJSON(change.Room, keyFor(change, sess.Nick)))
|
||||
}
|
||||
|
||||
// POST /api/rooms/{id}/leave — выход из комнаты. Владение переходит
|
||||
// участнику с наименьшим joined_at, опустевшая комната удаляется;
|
||||
// оставшимся уходит room с needsRekey (ADR-018), другим устройствам
|
||||
// вышедшего — room_left (ADR-041).
|
||||
//
|
||||
// Не участник и несуществующая комната отвечают тем же 204: выходить
|
||||
// неоткуда, а отдельного кода на этот случай в протоколе нет.
|
||||
func (s *server) leaveRoom(w http.ResponseWriter, r *http.Request) {
|
||||
// Заголовок необязателен, но чужой и кривой — 403, как и везде,
|
||||
// где устройство важно (docs/protocol.md, «Общие правила»).
|
||||
device, ok := s.optionalDevice(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
sess, _ := auth.From(r)
|
||||
change, err := s.st.LeaveRoom(r.Context(), r.PathValue("id"), sess.Nick)
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
noContent(w)
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
s.internal(w, r, err)
|
||||
return
|
||||
}
|
||||
s.sendRoom(r, change, device)
|
||||
noContent(w)
|
||||
}
|
||||
|
||||
// DELETE /api/rooms/{id} — удаление комнаты владельцем. Всем участникам,
|
||||
// включая его самого, уходит room_left.
|
||||
func (s *server) deleteRoom(w http.ResponseWriter, r *http.Request) {
|
||||
sess, _ := auth.From(r)
|
||||
change, err := s.st.DeleteRoom(r.Context(), r.PathValue("id"), sess.Nick)
|
||||
if err != nil {
|
||||
s.roomError(w, r, err)
|
||||
return
|
||||
}
|
||||
s.sendRoom(r, change, "")
|
||||
noContent(w)
|
||||
}
|
||||
|
||||
// roomError переводит отказы хранилища в коды протокола
|
||||
// (docs/protocol.md, «Комнаты»).
|
||||
func (s *server) roomError(w http.ResponseWriter, r *http.Request, err error) {
|
||||
switch {
|
||||
case errors.Is(err, store.ErrNotOwner):
|
||||
Error(w, http.StatusForbidden, "not_owner", "комнату меняет её владелец")
|
||||
case errors.Is(err, store.ErrUnknownUser):
|
||||
unknownUser(w)
|
||||
case errors.Is(err, store.ErrNotMember):
|
||||
Invalid(w, "remove", "убрать можно только участника комнаты")
|
||||
case errors.Is(err, store.ErrOwnerRemoval):
|
||||
Error(w, http.StatusBadRequest, "owner", "владельца убрать нельзя")
|
||||
case errors.Is(err, store.ErrKeyExists):
|
||||
Error(w, http.StatusConflict, "key_exists", "такой ключ у комнаты уже был")
|
||||
case errors.Is(err, store.ErrKeysMismatch):
|
||||
keysMismatch(w)
|
||||
default:
|
||||
s.internal(w, r, err)
|
||||
}
|
||||
}
|
||||
|
||||
func keysMismatch(w http.ResponseWriter) {
|
||||
Error(w, http.StatusBadRequest, "keys_mismatch", "ключи не совпадают с составом комнаты")
|
||||
}
|
||||
|
||||
// sendRoom раздаёт события изменившейся комнаты: room участникам, каждому
|
||||
// с его собственным ключом, и room_left выбывшим. exclude — устройство,
|
||||
// которому событие не нужно; пусто — нужно всем.
|
||||
//
|
||||
// Событие в очередь не кладётся: клиент после каждого ready перечитывает
|
||||
// GET /api/rooms, а всё, что несёт room, включая needsRekey, есть и там,
|
||||
// поэтому пропуск во время офлайна ничего не ломает (docs/protocol.md,
|
||||
// «События», ADR-041).
|
||||
func (s *server) sendRoom(r *http.Request, change store.RoomChange, exclude string) {
|
||||
for _, member := range change.Members {
|
||||
raw, err := json.Marshal(roomJSON(change.Room, member.Key))
|
||||
if err != nil {
|
||||
s.report(r, err)
|
||||
continue
|
||||
}
|
||||
s.send(member.Devices, exclude, hub.Event{Name: "room", Data: string(raw)})
|
||||
}
|
||||
if len(change.Left) == 0 {
|
||||
return
|
||||
}
|
||||
raw, err := json.Marshal(struct {
|
||||
ID string `json:"id"`
|
||||
}{change.Room.ID})
|
||||
if err != nil {
|
||||
s.report(r, err)
|
||||
return
|
||||
}
|
||||
for _, gone := range change.Left {
|
||||
s.send(gone.Devices, exclude, hub.Event{Name: "room_left", Data: string(raw)})
|
||||
}
|
||||
}
|
||||
|
||||
// send отдаёт событие подключённым устройствам, кроме exclude.
|
||||
func (s *server) send(devices []string, exclude string, ev hub.Event) {
|
||||
for _, device := range devices {
|
||||
if device == exclude {
|
||||
continue
|
||||
}
|
||||
s.hub.Send(device, ev)
|
||||
}
|
||||
}
|
||||
|
||||
// roomJSON собирает Room протокола: состав всегда список, ключ — null,
|
||||
// если его нет.
|
||||
func roomJSON(room store.Room, key *store.RoomKey) roomOut {
|
||||
out := roomOut{
|
||||
ID: room.ID,
|
||||
Name: room.Name,
|
||||
Owner: room.Owner,
|
||||
Members: room.Members,
|
||||
CreatedAt: room.CreatedAt,
|
||||
NeedsRekey: room.NeedsRekey,
|
||||
}
|
||||
if out.Members == nil {
|
||||
out.Members = []string{}
|
||||
}
|
||||
if key != nil {
|
||||
out.Key = &keyOut{KeyID: key.KeyID, From: key.From, IV: key.IV, CT: key.CT}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// keyFor — ключ участника в итоге изменения: у каждого он свой.
|
||||
func keyFor(change store.RoomChange, nick string) *store.RoomKey {
|
||||
for _, member := range change.Members {
|
||||
if member.Nick == nick {
|
||||
return member.Key
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// wrappedKeys проверяет форму завёрнутых ключей. Содержимое сервер
|
||||
// не проверяет и проверить не может: это шифротекст (ADR-018).
|
||||
func wrappedKeys(w http.ResponseWriter, in []keyIn) ([]store.WrappedKey, bool) {
|
||||
out := make([]store.WrappedKey, 0, len(in))
|
||||
seen := make(map[string]bool, len(in))
|
||||
for _, k := range in {
|
||||
if !validNick(k.To) {
|
||||
Invalid(w, "keys", "keys[].to — не ник")
|
||||
return nil, false
|
||||
}
|
||||
if seen[k.To] {
|
||||
// Два ключа одному участнику — это множество keys[].to,
|
||||
// не равное составу, а не отдельный отказ (ADR-043).
|
||||
keysMismatch(w)
|
||||
return nil, false
|
||||
}
|
||||
seen[k.To] = true
|
||||
if _, ok := decodeExactly(k.IV, ivLen); !ok {
|
||||
Invalid(w, "keys", "iv — не 12 байт base64url")
|
||||
return nil, false
|
||||
}
|
||||
if ct, err := b64.DecodeString(k.CT); err != nil || len(ct) < minCTLen {
|
||||
Invalid(w, "keys", "ct — не base64url или слишком короткий")
|
||||
return nil, false
|
||||
}
|
||||
out = append(out, store.WrappedKey{To: k.To, IV: k.IV, CT: k.CT})
|
||||
}
|
||||
return out, true
|
||||
}
|
||||
|
||||
// uniqueNicks разбирает список ников запроса: повторы схлопываются,
|
||||
// порядок сохраняется. Второе значение — прошёл ли список проверку формы.
|
||||
func uniqueNicks(list []string) ([]string, bool) {
|
||||
out := make([]string, 0, len(list))
|
||||
seen := make(map[string]bool, len(list))
|
||||
for _, nick := range list {
|
||||
if !validNick(nick) {
|
||||
return nil, false
|
||||
}
|
||||
if seen[nick] {
|
||||
continue
|
||||
}
|
||||
seen[nick] = true
|
||||
out = append(out, nick)
|
||||
}
|
||||
return out, true
|
||||
}
|
||||
|
||||
// validRoomName — имя комнаты: непустое, до 64 символов (ADR-021).
|
||||
func validRoomName(name string) bool {
|
||||
return name != "" && utf8.RuneCountInString(name) <= maxRoomName
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -44,26 +44,14 @@ func (s *Store) Cleanup(ctx context.Context, now time.Time) error {
|
||||
{"очередь", `DELETE FROM queue WHERE created_at < ?`, []any{ms - queueTTL.Milliseconds()}},
|
||||
{"устройства", `DELETE FROM devices WHERE last_seen < ?`, []any{ms - deviceTTL.Milliseconds()}},
|
||||
{"сессии", `DELETE FROM sessions WHERE expires_at < ?`, []any{ms}},
|
||||
// Ключи комнат: у каждой комнаты остаются два последних key_id.
|
||||
// Возраст key_id — время его самой поздней записи: ключ раздаётся
|
||||
// участникам не одной строкой, а по строке на участника.
|
||||
{"ключи комнат", `
|
||||
DELETE FROM room_keys WHERE (room_id, key_id) NOT IN (
|
||||
SELECT room_id, key_id FROM (
|
||||
SELECT room_id, key_id,
|
||||
ROW_NUMBER() OVER (
|
||||
PARTITION BY room_id
|
||||
ORDER BY MAX(created_at) DESC, key_id DESC
|
||||
) AS rn
|
||||
FROM room_keys
|
||||
GROUP BY room_id, key_id
|
||||
) WHERE rn <= ?
|
||||
)`, []any{roomKeysKept}},
|
||||
}
|
||||
for _, step := range steps {
|
||||
if _, err := s.db.ExecContext(ctx, step.query, step.args...); err != nil {
|
||||
return fmt.Errorf("store: чистка (%s): %w", step.what, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
// Ключи комнат: у каждой комнаты остаются два последних key_id. Тем же
|
||||
// запросом обрезает их rekey (internal/store/rooms.go): порядок один,
|
||||
// иначе чистка и rekey держали бы разные ключи.
|
||||
return trimRoomKeys(ctx, s.db, "")
|
||||
}
|
||||
|
||||
@@ -38,7 +38,7 @@ CREATE TABLE contacts (
|
||||
);
|
||||
|
||||
CREATE TABLE rooms (
|
||||
id TEXT PRIMARY KEY, -- base64url 16 байт, выдаёт сервер
|
||||
id TEXT PRIMARY KEY, -- base64url 16 байт, выдаёт клиент (ADR-037)
|
||||
name TEXT NOT NULL,
|
||||
owner TEXT NOT NULL REFERENCES users(nick),
|
||||
created_at INTEGER NOT NULL
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
-- Долг по ключу комнаты — состояние, а не свойство события (ADR-041):
|
||||
-- состав уменьшился, а нового ключа ещё не было. Ставится при выходе
|
||||
-- участника и удалении аккаунта, снимается при смене состава и rekey.
|
||||
ALTER TABLE rooms ADD COLUMN needs_rekey INTEGER NOT NULL DEFAULT 0;
|
||||
+60
-2
@@ -57,10 +57,12 @@ func (s *Store) Ack(ctx context.Context, device string, ids []string) error {
|
||||
|
||||
// Delivery — одна доставка: готовый конверт и всё, что нужно, чтобы
|
||||
// разложить его по очередям. Envelope сервер не разбирает, поэтому id
|
||||
// приходит отдельным полем.
|
||||
// приходит отдельным полем. Заполнено ровно одно из To и Room — адресат
|
||||
// у конверта один (docs/protocol.md, «Типы»).
|
||||
type Delivery struct {
|
||||
From string // отправитель, он же один из получателей
|
||||
To string // собеседник
|
||||
To string // собеседник личного чата
|
||||
Room string // комната
|
||||
Exclude string // устройство отправителя: эхо ему не нужно (ADR-017)
|
||||
MsgID string // id конверта, вторая половина ключа очереди
|
||||
Envelope string // готовый JSON конверта
|
||||
@@ -109,6 +111,38 @@ func (s *Store) DeliverDM(ctx context.Context, d Delivery) ([]string, error) {
|
||||
return devices, nil
|
||||
}
|
||||
|
||||
// DeliverRoom кладёт конверт комнаты в очередь всех устройств всех
|
||||
// участников, кроме отправившего (ADR-018), и возвращает эти устройства.
|
||||
// Контактов у комнаты нет: список комнат клиент берёт из GET /api/rooms.
|
||||
//
|
||||
// Членство и keyId проверены раньше, отдельным запросом: между проверкой
|
||||
// и этой транзакцией состав мог измениться, поэтому получателей она берёт
|
||||
// из состава на момент доставки.
|
||||
func (s *Store) DeliverRoom(ctx context.Context, d Delivery) ([]string, error) {
|
||||
tx, err := s.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("store: доставка в комнату: %w", err)
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
devices, err := roomDeviceIDs(ctx, tx, d.Room, d.Exclude)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, id := range devices {
|
||||
if _, err := tx.ExecContext(ctx, `
|
||||
INSERT INTO queue (device_id, msg_id, envelope, created_at) VALUES (?, ?, ?, ?)
|
||||
ON CONFLICT(device_id, msg_id) DO NOTHING`,
|
||||
id, d.MsgID, d.Envelope, d.Now); err != nil {
|
||||
return nil, fmt.Errorf("store: доставка в комнату (очередь): %w", err)
|
||||
}
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return nil, fmt.Errorf("store: доставка в комнату: %w", err)
|
||||
}
|
||||
return devices, nil
|
||||
}
|
||||
|
||||
// deviceIDs — устройства обоих собеседников, кроме отправившего.
|
||||
func deviceIDs(ctx context.Context, tx *sql.Tx, from, to, exclude string) ([]string, error) {
|
||||
rows, err := tx.QueryContext(ctx, `
|
||||
@@ -131,3 +165,27 @@ func deviceIDs(ctx context.Context, tx *sql.Tx, from, to, exclude string) ([]str
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// roomDeviceIDs — устройства всех участников комнаты, кроме отправившего.
|
||||
func roomDeviceIDs(ctx context.Context, tx *sql.Tx, room, exclude string) ([]string, error) {
|
||||
rows, err := tx.QueryContext(ctx, `
|
||||
SELECT d.id FROM devices d JOIN room_members m ON m.nick = d.nick
|
||||
WHERE m.room_id = ? AND d.id <> ? ORDER BY d.id`, room, exclude)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("store: доставка в комнату (устройства): %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []string
|
||||
for rows.Next() {
|
||||
var id string
|
||||
if err := rows.Scan(&id); err != nil {
|
||||
return nil, fmt.Errorf("store: доставка в комнату (устройства): %w", err)
|
||||
}
|
||||
out = append(out, id)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("store: доставка в комнату (устройства): %w", err)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,771 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Комнаты (ADR-007, ADR-018): владелец меняет состав, ключи заворачивают
|
||||
// клиенты. Сервер хранит и раздаёт завёрнутые ключи, но прочитать их
|
||||
// не может — это шифротекст, как и всё остальное в базе.
|
||||
|
||||
// Ошибки комнат, которые обработчику нужно различать. Остальное —
|
||||
// внутренние сбои.
|
||||
var (
|
||||
// ErrNotOwner — комнату меняет не её владелец. Несуществующая комната
|
||||
// отвечает тем же: знать о ней постороннему незачем.
|
||||
ErrNotOwner = errors.New("store: не владелец комнаты")
|
||||
// ErrUnknownUser — в add ник, которого нет.
|
||||
ErrUnknownUser = errors.New("store: нет такого ника")
|
||||
// ErrNotMember — в remove ник, который не участник комнаты.
|
||||
ErrNotMember = errors.New("store: не участник комнаты")
|
||||
// ErrOwnerRemoval — владельца из состава убрать нельзя.
|
||||
ErrOwnerRemoval = errors.New("store: владельца убрать нельзя")
|
||||
// ErrKeyExists — такой keyId у комнаты уже был.
|
||||
ErrKeyExists = errors.New("store: ключ комнаты уже есть")
|
||||
// ErrKeysMismatch — множество keys[].to не равно итоговому составу.
|
||||
ErrKeysMismatch = errors.New("store: ключи не по составу")
|
||||
// ErrRoomExists — идентификатор комнаты занят (ADR-037).
|
||||
ErrRoomExists = errors.New("store: такая комната уже есть")
|
||||
)
|
||||
|
||||
// RoomKey — завёрнутый ключ комнаты, каким его видит участник
|
||||
// (docs/protocol.md, «Типы»). Развернуть его может только он.
|
||||
type RoomKey struct {
|
||||
KeyID string
|
||||
From string // кто завернул
|
||||
IV string
|
||||
CT string
|
||||
}
|
||||
|
||||
// WrappedKey — запись keys[] запроса: кому предназначен ключ и что в нём.
|
||||
// Заворачивал тот, кто прислал запрос.
|
||||
type WrappedKey struct {
|
||||
To string
|
||||
IV string
|
||||
CT string
|
||||
}
|
||||
|
||||
// Room — комната и её состав. Key — текущий ключ того, кто спрашивает;
|
||||
// nil означает, что ключа у него нет. NeedsRekey — состав уменьшился,
|
||||
// а нового ключа ещё не было (ADR-041).
|
||||
type Room struct {
|
||||
ID string
|
||||
Name string
|
||||
Owner string
|
||||
Members []string // по joined_at
|
||||
CreatedAt int64
|
||||
Key *RoomKey
|
||||
NeedsRekey bool
|
||||
}
|
||||
|
||||
// Recipient — участник, его устройства и его текущий ключ: событие room
|
||||
// уходит каждому со своим ключом (docs/protocol.md, «Комнаты»).
|
||||
type Recipient struct {
|
||||
Nick string
|
||||
Devices []string
|
||||
Key *RoomKey
|
||||
}
|
||||
|
||||
// RoomChange — итог изменения комнаты: кому уходит room, а кому room_left.
|
||||
// Room.Key всегда nil — ключ у каждого получателя свой, он в Recipient.
|
||||
type RoomChange struct {
|
||||
Room Room
|
||||
Members []Recipient // итоговый состав
|
||||
Left []Recipient // выбывшие
|
||||
}
|
||||
|
||||
// Rooms — комнаты, где пользователь участник, каждая с его текущим
|
||||
// ключом (docs/protocol.md, «Комнаты»).
|
||||
func (s *Store) Rooms(ctx context.Context, nick string) ([]Room, error) {
|
||||
rows, err := s.db.QueryContext(ctx, `
|
||||
SELECT r.id, r.name, r.owner, r.created_at, r.needs_rekey
|
||||
FROM rooms r JOIN room_members m ON m.room_id = r.id
|
||||
WHERE m.nick = ? ORDER BY r.created_at, r.id`, nick)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("store: список комнат: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []Room
|
||||
at := make(map[string]int)
|
||||
for rows.Next() {
|
||||
var r Room
|
||||
if err := rows.Scan(&r.ID, &r.Name, &r.Owner, &r.CreatedAt, &r.NeedsRekey); err != nil {
|
||||
return nil, fmt.Errorf("store: список комнат: %w", err)
|
||||
}
|
||||
r.Members = []string{}
|
||||
at[r.ID] = len(out)
|
||||
out = append(out, r)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("store: список комнат: %w", err)
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
members, err := s.db.QueryContext(ctx, `
|
||||
SELECT room_id, nick FROM room_members
|
||||
WHERE room_id IN (SELECT room_id FROM room_members WHERE nick = ?)
|
||||
ORDER BY room_id, joined_at, nick`, nick)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("store: состав комнат: %w", err)
|
||||
}
|
||||
defer members.Close()
|
||||
|
||||
for members.Next() {
|
||||
var room, member string
|
||||
if err := members.Scan(&room, &member); err != nil {
|
||||
return nil, fmt.Errorf("store: состав комнат: %w", err)
|
||||
}
|
||||
if i, ok := at[room]; ok {
|
||||
out[i].Members = append(out[i].Members, member)
|
||||
}
|
||||
}
|
||||
if err := members.Err(); err != nil {
|
||||
return nil, fmt.Errorf("store: состав комнат: %w", err)
|
||||
}
|
||||
|
||||
keys, err := s.db.QueryContext(ctx, currentKeysQuery+` AND nick = ?`, nick)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("store: ключи комнат: %w", err)
|
||||
}
|
||||
defer keys.Close()
|
||||
|
||||
for keys.Next() {
|
||||
// Второй столбец — ник владельца ключа, здесь он всегда nick.
|
||||
var room, member string
|
||||
var k RoomKey
|
||||
if err := keys.Scan(&room, &member, &k.KeyID, &k.From, &k.IV, &k.CT); err != nil {
|
||||
return nil, fmt.Errorf("store: ключи комнат: %w", err)
|
||||
}
|
||||
if i, ok := at[room]; ok {
|
||||
key := k
|
||||
out[i].Key = &key
|
||||
}
|
||||
}
|
||||
if err := keys.Err(); err != nil {
|
||||
return nil, fmt.Errorf("store: ключи комнат: %w", err)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// NewRoom — что нужно, чтобы завести комнату. Идентификатор выдаёт
|
||||
// клиент (ADR-037), ключ ровно один — себе (docs/protocol.md, «Комнаты»).
|
||||
type NewRoom struct {
|
||||
ID string
|
||||
Name string
|
||||
Owner string
|
||||
KeyID string
|
||||
Key WrappedKey
|
||||
Now int64
|
||||
}
|
||||
|
||||
// CreateRoom заводит комнату, её единственного участника-владельца и его
|
||||
// завёрнутый ключ — в одной транзакции. Идентификатор приходит от клиента
|
||||
// (ADR-037); занятый — ErrRoomExists, без слияния с существующей комнатой.
|
||||
func (s *Store) CreateRoom(ctx context.Context, n NewRoom) (RoomChange, error) {
|
||||
tx, err := s.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return RoomChange{}, fmt.Errorf("store: создание комнаты: %w", err)
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
// Проверка до вставки, а не разбор ошибки драйвера: она читаема
|
||||
// и не зависит от текста, который вернёт SQLite.
|
||||
if _, err := roomRow(ctx, tx, n.ID); err == nil {
|
||||
return RoomChange{}, ErrRoomExists
|
||||
} else if !errors.Is(err, ErrNotFound) {
|
||||
return RoomChange{}, err
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, `
|
||||
INSERT INTO rooms (id, name, owner, created_at) VALUES (?, ?, ?, ?)`,
|
||||
n.ID, n.Name, n.Owner, n.Now); err != nil {
|
||||
return RoomChange{}, fmt.Errorf("store: создание комнаты: %w", err)
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, `
|
||||
INSERT INTO room_members (room_id, nick, joined_at) VALUES (?, ?, ?)`,
|
||||
n.ID, n.Owner, n.Now); err != nil {
|
||||
return RoomChange{}, fmt.Errorf("store: создание комнаты (участник): %w", err)
|
||||
}
|
||||
if err := insertKeys(ctx, tx, n.ID, n.Owner, n.KeyID, []WrappedKey{n.Key}, n.Now); err != nil {
|
||||
return RoomChange{}, err
|
||||
}
|
||||
devices, err := devicesOf(ctx, tx, []string{n.Owner})
|
||||
if err != nil {
|
||||
return RoomChange{}, err
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return RoomChange{}, fmt.Errorf("store: создание комнаты: %w", err)
|
||||
}
|
||||
|
||||
key := RoomKey{KeyID: n.KeyID, From: n.Owner, IV: n.Key.IV, CT: n.Key.CT}
|
||||
return RoomChange{
|
||||
Room: Room{
|
||||
ID: n.ID,
|
||||
Name: n.Name,
|
||||
Owner: n.Owner,
|
||||
Members: []string{n.Owner},
|
||||
CreatedAt: n.Now,
|
||||
},
|
||||
Members: []Recipient{{Nick: n.Owner, Devices: devices[n.Owner], Key: &key}},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// MembersChange — смена состава и rekey одним запросом (ADR-018).
|
||||
// Add и Remove — ники без повторов и без пересечения; пустые — чистый rekey.
|
||||
type MembersChange struct {
|
||||
RoomID string
|
||||
Owner string // от чьего имени идёт запрос: он обязан быть владельцем
|
||||
Add []string
|
||||
Remove []string
|
||||
KeyID string
|
||||
Keys []WrappedKey
|
||||
Now int64
|
||||
}
|
||||
|
||||
// UpdateMembers меняет состав и раздаёт новый ключ — всё в одной
|
||||
// транзакции: состав без ключа или ключ без состава невозможны (ADR-018).
|
||||
// Порядок проверок — docs/protocol.md, «Комнаты»; отказ на любой из них
|
||||
// не меняет ни строки.
|
||||
func (s *Store) UpdateMembers(ctx context.Context, c MembersChange) (RoomChange, error) {
|
||||
tx, err := s.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return RoomChange{}, fmt.Errorf("store: смена состава: %w", err)
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
room, err := roomRow(ctx, tx, c.RoomID)
|
||||
if errors.Is(err, ErrNotFound) {
|
||||
return RoomChange{}, ErrNotOwner
|
||||
}
|
||||
if err != nil {
|
||||
return RoomChange{}, err
|
||||
}
|
||||
if room.Owner != c.Owner {
|
||||
return RoomChange{}, ErrNotOwner
|
||||
}
|
||||
|
||||
current, err := roomMembers(ctx, tx, c.RoomID)
|
||||
if err != nil {
|
||||
return RoomChange{}, err
|
||||
}
|
||||
for _, nick := range c.Add {
|
||||
ok, err := userExists(ctx, tx, nick)
|
||||
if err != nil {
|
||||
return RoomChange{}, err
|
||||
}
|
||||
if !ok {
|
||||
return RoomChange{}, ErrUnknownUser
|
||||
}
|
||||
}
|
||||
member := make(map[string]bool, len(current))
|
||||
for _, nick := range current {
|
||||
member[nick] = true
|
||||
}
|
||||
for _, nick := range c.Remove {
|
||||
if !member[nick] {
|
||||
return RoomChange{}, ErrNotMember
|
||||
}
|
||||
}
|
||||
for _, nick := range c.Remove {
|
||||
if nick == room.Owner {
|
||||
return RoomChange{}, ErrOwnerRemoval
|
||||
}
|
||||
}
|
||||
used, err := keyUsed(ctx, tx, c.RoomID, c.KeyID)
|
||||
if err != nil {
|
||||
return RoomChange{}, err
|
||||
}
|
||||
if used {
|
||||
return RoomChange{}, ErrKeyExists
|
||||
}
|
||||
if !sameNicks(afterChange(current, c.Add, c.Remove), keyTargets(c.Keys)) {
|
||||
return RoomChange{}, ErrKeysMismatch
|
||||
}
|
||||
|
||||
for _, nick := range c.Remove {
|
||||
if _, err := tx.ExecContext(ctx, `
|
||||
DELETE FROM room_members WHERE room_id = ? AND nick = ?`, c.RoomID, nick); err != nil {
|
||||
return RoomChange{}, fmt.Errorf("store: смена состава (убрать): %w", err)
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, `
|
||||
DELETE FROM room_keys WHERE room_id = ? AND nick = ?`, c.RoomID, nick); err != nil {
|
||||
return RoomChange{}, fmt.Errorf("store: смена состава (ключи убранного): %w", err)
|
||||
}
|
||||
}
|
||||
for _, nick := range c.Add {
|
||||
// Уже состоящего участника запрос не двигает: joined_at остаётся
|
||||
// прежним, порядок состава не прыгает.
|
||||
if _, err := tx.ExecContext(ctx, `
|
||||
INSERT INTO room_members (room_id, nick, joined_at) VALUES (?, ?, ?)
|
||||
ON CONFLICT(room_id, nick) DO NOTHING`, c.RoomID, nick, c.Now); err != nil {
|
||||
return RoomChange{}, fmt.Errorf("store: смена состава (добавить): %w", err)
|
||||
}
|
||||
}
|
||||
if err := insertKeys(ctx, tx, c.RoomID, c.Owner, c.KeyID, c.Keys, c.Now); err != nil {
|
||||
return RoomChange{}, err
|
||||
}
|
||||
if err := trimRoomKeys(ctx, tx, c.RoomID); err != nil {
|
||||
return RoomChange{}, err
|
||||
}
|
||||
// Ключ роздан всему итоговому составу — долг закрыт (ADR-041).
|
||||
if _, err := tx.ExecContext(ctx, `
|
||||
UPDATE rooms SET needs_rekey = 0 WHERE id = ?`, c.RoomID); err != nil {
|
||||
return RoomChange{}, fmt.Errorf("store: снятие долга по ключу: %w", err)
|
||||
}
|
||||
room.NeedsRekey = false
|
||||
|
||||
final, err := roomMembers(ctx, tx, c.RoomID)
|
||||
if err != nil {
|
||||
return RoomChange{}, err
|
||||
}
|
||||
devices, err := devicesOf(ctx, tx, final)
|
||||
if err != nil {
|
||||
return RoomChange{}, err
|
||||
}
|
||||
left, err := devicesOf(ctx, tx, c.Remove)
|
||||
if err != nil {
|
||||
return RoomChange{}, err
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return RoomChange{}, fmt.Errorf("store: смена состава: %w", err)
|
||||
}
|
||||
|
||||
room.Members = final
|
||||
change := RoomChange{Room: room}
|
||||
wrapped := make(map[string]WrappedKey, len(c.Keys))
|
||||
for _, k := range c.Keys {
|
||||
wrapped[k.To] = k
|
||||
}
|
||||
for _, nick := range final {
|
||||
k := wrapped[nick]
|
||||
key := RoomKey{KeyID: c.KeyID, From: c.Owner, IV: k.IV, CT: k.CT}
|
||||
change.Members = append(change.Members, Recipient{Nick: nick, Devices: devices[nick], Key: &key})
|
||||
}
|
||||
for _, nick := range c.Remove {
|
||||
change.Left = append(change.Left, Recipient{Nick: nick, Devices: left[nick]})
|
||||
}
|
||||
return change, nil
|
||||
}
|
||||
|
||||
// LeaveRoom убирает участника и его ключи. Вышел владелец — владение
|
||||
// получает участник с наименьшим joined_at; не осталось никого — комната
|
||||
// удаляется (ADR-018). В RoomChange.Members — оставшиеся с их текущими
|
||||
// ключами: им уходит room с needsRekey. В RoomChange.Left — сам вышедший:
|
||||
// его другим устройствам уходит room_left, иначе комната висела бы у них
|
||||
// до следующего ready (ADR-041). Не участник — ErrNotFound.
|
||||
func (s *Store) LeaveRoom(ctx context.Context, roomID, nick string) (RoomChange, error) {
|
||||
tx, err := s.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return RoomChange{}, fmt.Errorf("store: выход из комнаты: %w", err)
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
room, err := roomRow(ctx, tx, roomID)
|
||||
if err != nil {
|
||||
return RoomChange{}, err
|
||||
}
|
||||
var one int
|
||||
err = tx.QueryRowContext(ctx, `
|
||||
SELECT 1 FROM room_members WHERE room_id = ? AND nick = ?`, roomID, nick).Scan(&one)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return RoomChange{}, ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return RoomChange{}, fmt.Errorf("store: выход из комнаты: %w", err)
|
||||
}
|
||||
gone, err := devicesOf(ctx, tx, []string{nick})
|
||||
if err != nil {
|
||||
return RoomChange{}, err
|
||||
}
|
||||
change, err := leaveRoom(ctx, tx, room, nick)
|
||||
if err != nil {
|
||||
return RoomChange{}, err
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return RoomChange{}, fmt.Errorf("store: выход из комнаты: %w", err)
|
||||
}
|
||||
change.Left = []Recipient{{Nick: nick, Devices: gone[nick]}}
|
||||
return change, nil
|
||||
}
|
||||
|
||||
// leaveRoom убирает участника и его ключи внутри чужой транзакции: это
|
||||
// общее у POST /api/rooms/{id}/leave и удаления аккаунта — по составу
|
||||
// комнаты это один и тот же выход участника (ADR-018, ADR-041).
|
||||
//
|
||||
// В RoomChange.Members — оставшиеся с их текущими ключами и признаком
|
||||
// needsRekey; пусто, если комната опустела и удалена. Проверку членства
|
||||
// и рассылку берут на себя вызывающие.
|
||||
func leaveRoom(ctx context.Context, tx *sql.Tx, room Room, nick string) (RoomChange, error) {
|
||||
if _, err := tx.ExecContext(ctx, `
|
||||
DELETE FROM room_members WHERE room_id = ? AND nick = ?`, room.ID, nick); err != nil {
|
||||
return RoomChange{}, fmt.Errorf("store: выход из комнаты: %w", err)
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, `
|
||||
DELETE FROM room_keys WHERE room_id = ? AND nick = ?`, room.ID, nick); err != nil {
|
||||
return RoomChange{}, fmt.Errorf("store: выход из комнаты (ключи): %w", err)
|
||||
}
|
||||
rest, err := roomMembers(ctx, tx, room.ID)
|
||||
if err != nil {
|
||||
return RoomChange{}, err
|
||||
}
|
||||
if len(rest) == 0 {
|
||||
if _, err := tx.ExecContext(ctx, `DELETE FROM rooms WHERE id = ?`, room.ID); err != nil {
|
||||
return RoomChange{}, fmt.Errorf("store: удаление пустой комнаты: %w", err)
|
||||
}
|
||||
// Комнаты больше нет: ключ ей не нужен, и долга за ней не остаётся.
|
||||
room.Members = nil
|
||||
room.NeedsRekey = false
|
||||
return RoomChange{Room: room}, nil
|
||||
}
|
||||
if room.Owner == nick {
|
||||
room.Owner = rest[0]
|
||||
if _, err := tx.ExecContext(ctx, `UPDATE rooms SET owner = ? WHERE id = ?`, room.Owner, room.ID); err != nil {
|
||||
return RoomChange{}, fmt.Errorf("store: передача владения: %w", err)
|
||||
}
|
||||
}
|
||||
// Состав уменьшился: комнате нужен новый ключ. Признак ждёт владельца
|
||||
// в базе, а не только в событии, — офлайн его больше не теряет (ADR-041).
|
||||
if _, err := tx.ExecContext(ctx, `
|
||||
UPDATE rooms SET needs_rekey = 1 WHERE id = ?`, room.ID); err != nil {
|
||||
return RoomChange{}, fmt.Errorf("store: долг по ключу комнаты: %w", err)
|
||||
}
|
||||
room.NeedsRekey = true
|
||||
keys, err := currentKeys(ctx, tx, room.ID)
|
||||
if err != nil {
|
||||
return RoomChange{}, err
|
||||
}
|
||||
devices, err := devicesOf(ctx, tx, rest)
|
||||
if err != nil {
|
||||
return RoomChange{}, err
|
||||
}
|
||||
|
||||
room.Members = rest
|
||||
change := RoomChange{Room: room}
|
||||
for _, member := range rest {
|
||||
r := Recipient{Nick: member, Devices: devices[member]}
|
||||
if k, ok := keys[member]; ok {
|
||||
key := k
|
||||
r.Key = &key
|
||||
}
|
||||
change.Members = append(change.Members, r)
|
||||
}
|
||||
return change, nil
|
||||
}
|
||||
|
||||
// DeleteRoom удаляет комнату целиком; членство и ключи уносит каскад.
|
||||
// В RoomChange.Left — все участники: им уходит room_left. Не владелец
|
||||
// и несуществующая комната — ErrNotOwner.
|
||||
func (s *Store) DeleteRoom(ctx context.Context, roomID, owner string) (RoomChange, error) {
|
||||
tx, err := s.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return RoomChange{}, fmt.Errorf("store: удаление комнаты: %w", err)
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
room, err := roomRow(ctx, tx, roomID)
|
||||
if errors.Is(err, ErrNotFound) {
|
||||
return RoomChange{}, ErrNotOwner
|
||||
}
|
||||
if err != nil {
|
||||
return RoomChange{}, err
|
||||
}
|
||||
if room.Owner != owner {
|
||||
return RoomChange{}, ErrNotOwner
|
||||
}
|
||||
members, err := roomMembers(ctx, tx, roomID)
|
||||
if err != nil {
|
||||
return RoomChange{}, err
|
||||
}
|
||||
devices, err := devicesOf(ctx, tx, members)
|
||||
if err != nil {
|
||||
return RoomChange{}, err
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, `DELETE FROM rooms WHERE id = ?`, roomID); err != nil {
|
||||
return RoomChange{}, fmt.Errorf("store: удаление комнаты: %w", err)
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return RoomChange{}, fmt.Errorf("store: удаление комнаты: %w", err)
|
||||
}
|
||||
|
||||
room.Members = members
|
||||
change := RoomChange{Room: room}
|
||||
for _, member := range members {
|
||||
change.Left = append(change.Left, Recipient{Nick: member, Devices: devices[member]})
|
||||
}
|
||||
return change, nil
|
||||
}
|
||||
|
||||
// RoomAccess — что сервер проверяет перед отправкой в комнату
|
||||
// (docs/protocol.md, «Сообщения»). keyId считается ключом комнаты, если
|
||||
// есть хоть одна строка room_keys с таким key_id (docs/storage.md).
|
||||
func (s *Store) RoomAccess(ctx context.Context, roomID, nick, keyID string) (member, knownKey bool, err error) {
|
||||
err = s.db.QueryRowContext(ctx, `
|
||||
SELECT EXISTS(SELECT 1 FROM room_members WHERE room_id = ? AND nick = ?),
|
||||
EXISTS(SELECT 1 FROM room_keys WHERE room_id = ? AND key_id = ?)`,
|
||||
roomID, nick, roomID, keyID).Scan(&member, &knownKey)
|
||||
if err != nil {
|
||||
return false, false, fmt.Errorf("store: доступ к комнате: %w", err)
|
||||
}
|
||||
return member, knownKey, nil
|
||||
}
|
||||
|
||||
// currentKeysQuery — текущий ключ участника: строка room_keys с максимальным
|
||||
// created_at (docs/storage.md). Порядок при совпадении времени тот же, что
|
||||
// у обрезки, — иначе «текущий» и «оставленный» могли бы разойтись.
|
||||
const currentKeysQuery = `
|
||||
SELECT room_id, nick, key_id, sender, iv, ct FROM (
|
||||
SELECT room_id, nick, key_id, sender, iv, ct,
|
||||
ROW_NUMBER() OVER (
|
||||
PARTITION BY room_id, nick
|
||||
ORDER BY created_at DESC, key_id DESC
|
||||
) AS rn
|
||||
FROM room_keys
|
||||
) WHERE rn = 1`
|
||||
|
||||
// currentKeys — текущие ключи всех участников комнаты.
|
||||
func currentKeys(ctx context.Context, tx *sql.Tx, roomID string) (map[string]RoomKey, error) {
|
||||
rows, err := tx.QueryContext(ctx, currentKeysQuery+` AND room_id = ?`, roomID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("store: ключи комнаты: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
out := make(map[string]RoomKey)
|
||||
for rows.Next() {
|
||||
var room, nick string
|
||||
var k RoomKey
|
||||
if err := rows.Scan(&room, &nick, &k.KeyID, &k.From, &k.IV, &k.CT); err != nil {
|
||||
return nil, fmt.Errorf("store: ключи комнаты: %w", err)
|
||||
}
|
||||
out[nick] = k
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("store: ключи комнаты: %w", err)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// execer — то общее у *sql.DB и *sql.Tx, что нужно обрезке ключей: её
|
||||
// зовут и транзакция rekey, и фоновая чистка.
|
||||
type execer interface {
|
||||
ExecContext(ctx context.Context, query string, args ...any) (sql.Result, error)
|
||||
}
|
||||
|
||||
// trimRoomKeys оставляет у комнаты два последних keyId (ADR-018). Пустой
|
||||
// room — все комнаты: это фоновая чистка (docs/storage.md). Запрос один
|
||||
// на оба случая, иначе rekey и чистка держали бы разные ключи.
|
||||
//
|
||||
// Текущий ключ участника обрезка не трогает: новый keyId раздаётся всем
|
||||
// участникам сразу (иначе keys_mismatch), поэтому самый свежий key_id
|
||||
// комнаты есть у каждого, а он остаётся всегда. Какой из ключей свежий —
|
||||
// однозначно: время ключа строго растёт (insertKeys).
|
||||
//
|
||||
// Возраст key_id — время его самой поздней записи: ключ раздаётся не одной
|
||||
// строкой, а по строке на участника.
|
||||
func trimRoomKeys(ctx context.Context, x execer, room string) error {
|
||||
_, err := x.ExecContext(ctx, `
|
||||
DELETE FROM room_keys
|
||||
WHERE (? = '' OR room_id = ?)
|
||||
AND (room_id, key_id) NOT IN (
|
||||
SELECT room_id, key_id FROM (
|
||||
SELECT room_id, key_id,
|
||||
ROW_NUMBER() OVER (
|
||||
PARTITION BY room_id
|
||||
ORDER BY MAX(created_at) DESC, key_id DESC
|
||||
) AS rn
|
||||
FROM room_keys
|
||||
GROUP BY room_id, key_id
|
||||
) WHERE rn <= ?
|
||||
)`, room, room, roomKeysKept)
|
||||
if err != nil {
|
||||
return fmt.Errorf("store: обрезка ключей комнаты: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// roomRow читает комнату без состава и ключей. Нет такой — ErrNotFound.
|
||||
func roomRow(ctx context.Context, tx *sql.Tx, roomID string) (Room, error) {
|
||||
var r Room
|
||||
err := tx.QueryRowContext(ctx, `
|
||||
SELECT id, name, owner, created_at, needs_rekey FROM rooms WHERE id = ?`, roomID).
|
||||
Scan(&r.ID, &r.Name, &r.Owner, &r.CreatedAt, &r.NeedsRekey)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return Room{}, ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return Room{}, fmt.Errorf("store: чтение комнаты: %w", err)
|
||||
}
|
||||
return r, nil
|
||||
}
|
||||
|
||||
// roomMembers — состав комнаты по joined_at (docs/protocol.md, «Типы»).
|
||||
func roomMembers(ctx context.Context, tx *sql.Tx, roomID string) ([]string, error) {
|
||||
rows, err := tx.QueryContext(ctx, `
|
||||
SELECT nick FROM room_members WHERE room_id = ? ORDER BY joined_at, nick`, roomID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("store: состав комнаты: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []string
|
||||
for rows.Next() {
|
||||
var nick string
|
||||
if err := rows.Scan(&nick); err != nil {
|
||||
return nil, fmt.Errorf("store: состав комнаты: %w", err)
|
||||
}
|
||||
out = append(out, nick)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("store: состав комнаты: %w", err)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// userExists — есть ли такой ник.
|
||||
func userExists(ctx context.Context, tx *sql.Tx, nick string) (bool, error) {
|
||||
var one int
|
||||
err := tx.QueryRowContext(ctx, `SELECT 1 FROM users WHERE nick = ?`, nick).Scan(&one)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("store: проверка ника: %w", err)
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// keyUsed — был ли уже такой keyId у комнаты.
|
||||
func keyUsed(ctx context.Context, tx *sql.Tx, roomID, keyID string) (bool, error) {
|
||||
var one int
|
||||
err := tx.QueryRowContext(ctx, `
|
||||
SELECT 1 FROM room_keys WHERE room_id = ? AND key_id = ? LIMIT 1`, roomID, keyID).Scan(&one)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("store: проверка ключа комнаты: %w", err)
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// insertKeys раскладывает завёрнутые ключи по участникам.
|
||||
//
|
||||
// Время ключа — строго позже всех прежних ключей комнаты. Текущий ключ
|
||||
// участника — строка с максимальным created_at (docs/storage.md), а два
|
||||
// rekey подряд укладываются в одну миллисекунду. Без этого «последним»
|
||||
// оказался бы прежний ключ, обрезка до двух последних keyId выбросила бы
|
||||
// свежий, и комната откатилась бы на ключ, которого у новых участников нет.
|
||||
func insertKeys(ctx context.Context, tx *sql.Tx, roomID, sender, keyID string, keys []WrappedKey, now int64) error {
|
||||
var last int64
|
||||
if err := tx.QueryRowContext(ctx, `
|
||||
SELECT COALESCE(MAX(created_at), 0) FROM room_keys WHERE room_id = ?`, roomID).Scan(&last); err != nil {
|
||||
return fmt.Errorf("store: время ключа комнаты: %w", err)
|
||||
}
|
||||
if now <= last {
|
||||
now = last + 1
|
||||
}
|
||||
for _, k := range keys {
|
||||
if _, err := tx.ExecContext(ctx, `
|
||||
INSERT INTO room_keys (room_id, nick, key_id, sender, iv, ct, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
||||
roomID, k.To, keyID, sender, k.IV, k.CT, now); err != nil {
|
||||
return fmt.Errorf("store: раздача ключа комнаты: %w", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// devicesOf — устройства перечисленных пользователей.
|
||||
func devicesOf(ctx context.Context, tx *sql.Tx, nicks []string) (map[string][]string, error) {
|
||||
out := make(map[string][]string, len(nicks))
|
||||
if len(nicks) == 0 {
|
||||
return out, nil
|
||||
}
|
||||
args := make([]any, 0, len(nicks))
|
||||
for _, nick := range nicks {
|
||||
args = append(args, nick)
|
||||
}
|
||||
rows, err := tx.QueryContext(ctx, `
|
||||
SELECT nick, id FROM devices WHERE nick IN (?`+
|
||||
strings.Repeat(", ?", len(nicks)-1)+`) ORDER BY nick, id`, args...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("store: устройства участников: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
for rows.Next() {
|
||||
var nick, id string
|
||||
if err := rows.Scan(&nick, &id); err != nil {
|
||||
return nil, fmt.Errorf("store: устройства участников: %w", err)
|
||||
}
|
||||
out[nick] = append(out[nick], id)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("store: устройства участников: %w", err)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// afterChange — итоговый состав: текущий без remove плюс add.
|
||||
func afterChange(current, add, remove []string) []string {
|
||||
gone := make(map[string]bool, len(remove))
|
||||
for _, nick := range remove {
|
||||
gone[nick] = true
|
||||
}
|
||||
out := make([]string, 0, len(current)+len(add))
|
||||
seen := make(map[string]bool, len(current)+len(add))
|
||||
for _, nick := range current {
|
||||
if gone[nick] || seen[nick] {
|
||||
continue
|
||||
}
|
||||
seen[nick] = true
|
||||
out = append(out, nick)
|
||||
}
|
||||
for _, nick := range add {
|
||||
if seen[nick] {
|
||||
continue
|
||||
}
|
||||
seen[nick] = true
|
||||
out = append(out, nick)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// keyTargets — кому предназначены завёрнутые ключи.
|
||||
func keyTargets(keys []WrappedKey) []string {
|
||||
out := make([]string, 0, len(keys))
|
||||
for _, k := range keys {
|
||||
out = append(out, k.To)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// sameNicks — совпадают ли множества ников. Порядок не важен: ключи
|
||||
// приходят в порядке клиента, состав — по joined_at.
|
||||
//
|
||||
// Найденный ник из множества вычёркивается: два ключа одному участнику
|
||||
// вместо ключа другому — это keys_mismatch, а не совпадение по длине.
|
||||
// Иначе такой запрос дошёл бы до вставки и упал на ключе room_keys уже
|
||||
// внутри транзакции, отдав клиенту 500 вместо разбираемого кода.
|
||||
func sameNicks(a, b []string) bool {
|
||||
if len(a) != len(b) {
|
||||
return false
|
||||
}
|
||||
set := make(map[string]bool, len(a))
|
||||
for _, nick := range a {
|
||||
set[nick] = true
|
||||
}
|
||||
for _, nick := range b {
|
||||
if !set[nick] {
|
||||
return false
|
||||
}
|
||||
delete(set, nick)
|
||||
}
|
||||
return len(set) == 0
|
||||
}
|
||||
@@ -0,0 +1,334 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// room — комната из трёх ников для тестов хранилища.
|
||||
func newTestStore(t *testing.T, nicks ...string) (*Store, context.Context) {
|
||||
t.Helper()
|
||||
ctx := context.Background()
|
||||
s := open(t, filepath.Join(t.TempDir(), "bare.db"))
|
||||
for i, nick := range nicks {
|
||||
err := s.CreateUser(ctx, User{
|
||||
Nick: nick,
|
||||
Cred: Credential{Hash: []byte("hash"), Salt: []byte("salt"), Params: "argon2id,m=19456,t=2,p=1"},
|
||||
PublicKey: `{"kty":"EC"}`,
|
||||
KeyBlob: `{"v":1}`,
|
||||
CreatedAt: int64(i + 1),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateUser %s: %v", nick, err)
|
||||
}
|
||||
}
|
||||
return s, ctx
|
||||
}
|
||||
|
||||
// wrap — завёрнутый ключ участнику: содержимое хранилищу безразлично.
|
||||
func wrap(nicks ...string) []WrappedKey {
|
||||
out := make([]WrappedKey, 0, len(nicks))
|
||||
for _, nick := range nicks {
|
||||
out = append(out, WrappedKey{To: nick, IV: "iv-" + nick, CT: "ct-" + nick})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// rekey — смена состава и раздача нового ключа.
|
||||
func rekey(t *testing.T, s *Store, ctx context.Context, room, owner, keyID string, add, remove, to []string, now int64) RoomChange {
|
||||
t.Helper()
|
||||
change, err := s.UpdateMembers(ctx, MembersChange{
|
||||
RoomID: room,
|
||||
Owner: owner,
|
||||
Add: add,
|
||||
Remove: remove,
|
||||
KeyID: keyID,
|
||||
Keys: wrap(to...),
|
||||
Now: now,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("UpdateMembers %s: %v", keyID, err)
|
||||
}
|
||||
return change
|
||||
}
|
||||
|
||||
// makeRoom — комната с одним владельцем и его ключом.
|
||||
func makeRoom(t *testing.T, s *Store, ctx context.Context, owner string, now int64) RoomChange {
|
||||
t.Helper()
|
||||
change, err := s.CreateRoom(ctx, NewRoom{
|
||||
ID: "room-1",
|
||||
Name: "общая",
|
||||
Owner: owner,
|
||||
KeyID: "k1",
|
||||
Key: wrap(owner)[0],
|
||||
Now: now,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateRoom: %v", err)
|
||||
}
|
||||
return change
|
||||
}
|
||||
|
||||
// Занятый идентификатор комнаты — ErrRoomExists, и ни одной строки
|
||||
// существующая комната при этом не теряет (ADR-037).
|
||||
func TestCreateRoomTakenID(t *testing.T) {
|
||||
s, ctx := newTestStore(t, "marta", "petya")
|
||||
makeRoom(t, s, ctx, "marta", 1000)
|
||||
|
||||
_, err := s.CreateRoom(ctx, NewRoom{ID: "room-1", Name: "чужая", Owner: "petya",
|
||||
KeyID: "k2", Key: wrap("petya")[0], Now: 2000})
|
||||
if !errors.Is(err, ErrRoomExists) {
|
||||
t.Fatalf("CreateRoom с занятым id: получено %v, ожидалось ErrRoomExists", err)
|
||||
}
|
||||
rooms, err := s.Rooms(ctx, "marta")
|
||||
if err != nil {
|
||||
t.Fatalf("Rooms: %v", err)
|
||||
}
|
||||
if len(rooms) != 1 || rooms[0].Name != "общая" || rooms[0].Owner != "marta" ||
|
||||
rooms[0].Key == nil || rooms[0].Key.KeyID != "k1" {
|
||||
t.Errorf("комната после отказа: %+v", rooms)
|
||||
}
|
||||
if got, err := s.Rooms(ctx, "petya"); err != nil || len(got) != 0 {
|
||||
t.Errorf("занятый id присоединил чужого: %+v, %v", got, err)
|
||||
}
|
||||
}
|
||||
|
||||
// У комнаты живут два последних keyId; обрезка при rekey и фоновая чистка
|
||||
// держат одни и те же ключи и не трогают текущий ключ участника (ADR-018).
|
||||
func TestRoomKeysTrimmedToTwo(t *testing.T) {
|
||||
s, ctx := newTestStore(t, "marta", "petya", "kolya")
|
||||
makeRoom(t, s, ctx, "marta", 1000)
|
||||
rekey(t, s, ctx, "room-1", "marta", "k2", []string{"petya"}, nil, []string{"marta", "petya"}, 2000)
|
||||
rekey(t, s, ctx, "room-1", "marta", "k3", []string{"kolya"}, nil, []string{"marta", "petya", "kolya"}, 3000)
|
||||
|
||||
if got := ids(t, s, `SELECT DISTINCT key_id FROM room_keys ORDER BY key_id`); !equal(got, []string{"k2", "k3"}) {
|
||||
t.Errorf("ключи после rekey: получено %v, ожидалось [k2 k3]", got)
|
||||
}
|
||||
// Фоновая чистка ничего не добавляет к обрезке: запрос у них один.
|
||||
if err := s.Cleanup(ctx, time.Now()); err != nil {
|
||||
t.Fatalf("Cleanup: %v", err)
|
||||
}
|
||||
if got := ids(t, s, `SELECT DISTINCT key_id FROM room_keys ORDER BY key_id`); !equal(got, []string{"k2", "k3"}) {
|
||||
t.Errorf("ключи после чистки: получено %v, ожидалось [k2 k3]", got)
|
||||
}
|
||||
// Текущий ключ есть у каждого участника, и он последний.
|
||||
for _, nick := range []string{"marta", "petya", "kolya"} {
|
||||
rooms, err := s.Rooms(ctx, nick)
|
||||
if err != nil {
|
||||
t.Fatalf("Rooms %s: %v", nick, err)
|
||||
}
|
||||
if len(rooms) != 1 || rooms[0].Key == nil {
|
||||
t.Fatalf("комнаты %s: %+v", nick, rooms)
|
||||
}
|
||||
if rooms[0].Key.KeyID != "k3" || rooms[0].Key.CT != "ct-"+nick {
|
||||
t.Errorf("ключ %s: %+v", nick, rooms[0].Key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Два rekey в одну миллисекунду: текущим остаётся последний розданный ключ,
|
||||
// и обрезка его не выбрасывает (docs/storage.md, «Текущий ключ комнаты»).
|
||||
// Идентификаторы ключей случайны, поэтому свежий вполне может оказаться
|
||||
// меньше прежнего по порядку сортировки — на этом и построен случай.
|
||||
func TestRoomKeysWithinOneMillisecond(t *testing.T) {
|
||||
s, ctx := newTestStore(t, "marta", "petya")
|
||||
if _, err := s.CreateRoom(ctx, NewRoom{ID: "room-1", Name: "общая", Owner: "marta",
|
||||
KeyID: "ccc", Key: wrap("marta")[0], Now: 1000}); err != nil {
|
||||
t.Fatalf("CreateRoom: %v", err)
|
||||
}
|
||||
rekey(t, s, ctx, "room-1", "marta", "bbb", []string{"petya"}, nil, []string{"marta", "petya"}, 1000)
|
||||
rekey(t, s, ctx, "room-1", "marta", "aaa", nil, nil, []string{"marta", "petya"}, 1000)
|
||||
|
||||
if got := ids(t, s, `SELECT DISTINCT key_id FROM room_keys ORDER BY key_id`); !equal(got, []string{"aaa", "bbb"}) {
|
||||
t.Errorf("ключи: получено %v, ожидалось [aaa bbb]", got)
|
||||
}
|
||||
for _, nick := range []string{"marta", "petya"} {
|
||||
rooms, err := s.Rooms(ctx, nick)
|
||||
if err != nil || len(rooms) != 1 || rooms[0].Key == nil {
|
||||
t.Fatalf("комнаты %s: %+v, %v", nick, rooms, err)
|
||||
}
|
||||
if rooms[0].Key.KeyID != "aaa" {
|
||||
t.Errorf("текущий ключ %s: получено %q, ожидалось \"aaa\"", nick, rooms[0].Key.KeyID)
|
||||
}
|
||||
}
|
||||
// Свежим ключом можно писать: он остался ключом комнаты.
|
||||
member, known, err := s.RoomAccess(ctx, "room-1", "marta", "aaa")
|
||||
if err != nil || !member || !known {
|
||||
t.Errorf("доступ по свежему ключу: member=%v known=%v err=%v", member, known, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Убранный участник теряет и членство, и все свои ключи.
|
||||
func TestRoomRemoveDropsKeys(t *testing.T) {
|
||||
s, ctx := newTestStore(t, "marta", "petya")
|
||||
makeRoom(t, s, ctx, "marta", 1000)
|
||||
rekey(t, s, ctx, "room-1", "marta", "k2", []string{"petya"}, nil, []string{"marta", "petya"}, 2000)
|
||||
|
||||
change := rekey(t, s, ctx, "room-1", "marta", "k3", nil, []string{"petya"}, []string{"marta"}, 3000)
|
||||
if len(change.Left) != 1 || change.Left[0].Nick != "petya" {
|
||||
t.Errorf("выбывшие: %+v", change.Left)
|
||||
}
|
||||
if got := ids(t, s, `SELECT nick FROM room_keys ORDER BY nick, key_id`); !equal(got, []string{"marta", "marta"}) {
|
||||
t.Errorf("ключи после удаления участника: %v", got)
|
||||
}
|
||||
rooms, err := s.Rooms(ctx, "petya")
|
||||
if err != nil || len(rooms) != 0 {
|
||||
t.Errorf("комнаты убранного: %+v, %v", rooms, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Отказ в середине не оставляет следов: состав и ключи те же (docs/protocol.md).
|
||||
func TestRoomChangeIsAtomic(t *testing.T) {
|
||||
s, ctx := newTestStore(t, "marta", "petya", "kolya")
|
||||
makeRoom(t, s, ctx, "marta", 1000)
|
||||
rekey(t, s, ctx, "room-1", "marta", "k2", []string{"petya"}, nil, []string{"marta", "petya"}, 2000)
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
c MembersChange
|
||||
want error
|
||||
}{
|
||||
{"не владелец", MembersChange{RoomID: "room-1", Owner: "petya", KeyID: "k3",
|
||||
Keys: wrap("marta", "petya"), Now: 3000}, ErrNotOwner},
|
||||
{"нет комнаты", MembersChange{RoomID: "нет", Owner: "marta", KeyID: "k3",
|
||||
Keys: wrap("marta", "petya"), Now: 3000}, ErrNotOwner},
|
||||
{"нет ника", MembersChange{RoomID: "room-1", Owner: "marta", Add: []string{"никого"}, KeyID: "k3",
|
||||
Keys: wrap("marta", "petya", "никого"), Now: 3000}, ErrUnknownUser},
|
||||
{"не участник", MembersChange{RoomID: "room-1", Owner: "marta", Remove: []string{"kolya"}, KeyID: "k3",
|
||||
Keys: wrap("marta", "petya"), Now: 3000}, ErrNotMember},
|
||||
{"владелец", MembersChange{RoomID: "room-1", Owner: "marta", Remove: []string{"marta"}, KeyID: "k3",
|
||||
Keys: wrap("petya"), Now: 3000}, ErrOwnerRemoval},
|
||||
{"ключ уже был", MembersChange{RoomID: "room-1", Owner: "marta", Add: []string{"kolya"}, KeyID: "k2",
|
||||
Keys: wrap("marta", "petya", "kolya"), Now: 3000}, ErrKeyExists},
|
||||
{"ключи не по составу", MembersChange{RoomID: "room-1", Owner: "marta", Add: []string{"kolya"}, KeyID: "k3",
|
||||
Keys: wrap("marta", "petya"), Now: 3000}, ErrKeysMismatch},
|
||||
// Два ключа одному вместо ключа другому: длина сходится, состав — нет.
|
||||
{"два ключа одному", MembersChange{RoomID: "room-1", Owner: "marta", KeyID: "k3",
|
||||
Keys: wrap("marta", "marta"), Now: 3000}, ErrKeysMismatch},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
if _, err := s.UpdateMembers(ctx, c.c); !errors.Is(err, c.want) {
|
||||
t.Fatalf("получено %v, ожидалось %v", err, c.want)
|
||||
}
|
||||
if got := ids(t, s, `SELECT nick FROM room_members WHERE room_id = 'room-1' ORDER BY nick`); !equal(got, []string{"marta", "petya"}) {
|
||||
t.Errorf("состав: получено %v, ожидалось [marta petya]", got)
|
||||
}
|
||||
if got := ids(t, s, `SELECT DISTINCT key_id FROM room_keys ORDER BY key_id`); !equal(got, []string{"k1", "k2"}) {
|
||||
t.Errorf("ключи: получено %v, ожидалось [k1 k2]", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Удаление аккаунта передаёт владение участнику с наименьшим joined_at,
|
||||
// а комнату без участников удаляет (ADR-018).
|
||||
func TestDeleteUserRooms(t *testing.T) {
|
||||
s, ctx := newTestStore(t, "marta", "petya", "kolya")
|
||||
makeRoom(t, s, ctx, "marta", 1000)
|
||||
rekey(t, s, ctx, "room-1", "marta", "k2", []string{"kolya"}, nil, []string{"marta", "kolya"}, 2000)
|
||||
rekey(t, s, ctx, "room-1", "marta", "k3", []string{"petya"}, nil, []string{"marta", "kolya", "petya"}, 3000)
|
||||
|
||||
// Вторая комната — только владелец.
|
||||
if _, err := s.CreateRoom(ctx, NewRoom{ID: "room-2", Name: "своя", Owner: "marta",
|
||||
KeyID: "k1", Key: wrap("marta")[0], Now: 4000}); err != nil {
|
||||
t.Fatalf("CreateRoom: %v", err)
|
||||
}
|
||||
|
||||
if _, err := s.DeleteUser(ctx, "marta"); err != nil {
|
||||
t.Fatalf("DeleteUser: %v", err)
|
||||
}
|
||||
if got := ids(t, s, `SELECT id FROM rooms ORDER BY id`); !equal(got, []string{"room-1"}) {
|
||||
t.Errorf("комнаты: получено %v, ожидалось [room-1]", got)
|
||||
}
|
||||
if got := ids(t, s, `SELECT owner FROM rooms`); !equal(got, []string{"kolya"}) {
|
||||
t.Errorf("владелец: получено %v, ожидалось [kolya]", got)
|
||||
}
|
||||
if got := ids(t, s, `SELECT nick FROM room_members ORDER BY nick`); !equal(got, []string{"kolya", "petya"}) {
|
||||
t.Errorf("состав: получено %v, ожидалось [kolya petya]", got)
|
||||
}
|
||||
if got := ids(t, s, `SELECT DISTINCT nick FROM room_keys ORDER BY nick`); !equal(got, []string{"kolya", "petya"}) {
|
||||
t.Errorf("ключи: получено %v, ожидалось [kolya petya]", got)
|
||||
}
|
||||
}
|
||||
|
||||
// Удаление аккаунта простого участника чужую комнату не трогает: членство
|
||||
// и ключи уносит каскад, владелец и остальные на месте.
|
||||
func TestDeleteUserMember(t *testing.T) {
|
||||
s, ctx := newTestStore(t, "marta", "petya")
|
||||
if _, err := s.CreateRoom(ctx, NewRoom{ID: "room-1", Name: "общая", Owner: "petya",
|
||||
KeyID: "k1", Key: wrap("petya")[0], Now: 1000}); err != nil {
|
||||
t.Fatalf("CreateRoom: %v", err)
|
||||
}
|
||||
rekey(t, s, ctx, "room-1", "petya", "k2", []string{"marta"}, nil, []string{"petya", "marta"}, 2000)
|
||||
|
||||
if _, err := s.DeleteUser(ctx, "marta"); err != nil {
|
||||
t.Fatalf("DeleteUser: %v", err)
|
||||
}
|
||||
if got := ids(t, s, `SELECT owner FROM rooms`); !equal(got, []string{"petya"}) {
|
||||
t.Errorf("комнаты: получено %v, ожидалось [petya]", got)
|
||||
}
|
||||
if got := ids(t, s, `SELECT nick FROM room_members`); !equal(got, []string{"petya"}) {
|
||||
t.Errorf("состав: получено %v, ожидалось [petya]", got)
|
||||
}
|
||||
if got := ids(t, s, `SELECT DISTINCT nick FROM room_keys`); !equal(got, []string{"petya"}) {
|
||||
t.Errorf("ключи: получено %v, ожидалось [petya]", got)
|
||||
}
|
||||
}
|
||||
|
||||
// Выход владельца передаёт владение; выход последнего удаляет комнату.
|
||||
func TestLeaveRoom(t *testing.T) {
|
||||
s, ctx := newTestStore(t, "marta", "petya")
|
||||
makeRoom(t, s, ctx, "marta", 1000)
|
||||
rekey(t, s, ctx, "room-1", "marta", "k2", []string{"petya"}, nil, []string{"marta", "petya"}, 2000)
|
||||
|
||||
// Не участник — ErrNotFound, комната не тронута.
|
||||
if _, err := s.LeaveRoom(ctx, "нет", "marta"); !errors.Is(err, ErrNotFound) {
|
||||
t.Errorf("выход из несуществующей комнаты: %v", err)
|
||||
}
|
||||
|
||||
change, err := s.LeaveRoom(ctx, "room-1", "marta")
|
||||
if err != nil {
|
||||
t.Fatalf("LeaveRoom: %v", err)
|
||||
}
|
||||
if change.Room.Owner != "petya" {
|
||||
t.Errorf("владелец: получено %q, ожидалось \"petya\"", change.Room.Owner)
|
||||
}
|
||||
if len(change.Members) != 1 || change.Members[0].Nick != "petya" {
|
||||
t.Fatalf("оставшиеся: %+v", change.Members)
|
||||
}
|
||||
if change.Members[0].Key == nil || change.Members[0].Key.KeyID != "k2" {
|
||||
t.Errorf("ключ оставшегося: %+v", change.Members[0].Key)
|
||||
}
|
||||
if !change.Room.NeedsRekey {
|
||||
t.Error("needsRekey после выхода участника")
|
||||
}
|
||||
if len(change.Left) != 1 || change.Left[0].Nick != "marta" {
|
||||
t.Errorf("room_left вышедшему: %+v", change.Left)
|
||||
}
|
||||
if got := ids(t, s, `SELECT DISTINCT nick FROM room_keys`); !equal(got, []string{"petya"}) {
|
||||
t.Errorf("ключи после выхода: %v", got)
|
||||
}
|
||||
|
||||
last, err := s.LeaveRoom(ctx, "room-1", "petya")
|
||||
if err != nil {
|
||||
t.Fatalf("LeaveRoom: %v", err)
|
||||
}
|
||||
// Комнаты больше нет: room слать некому, room_left уходит другим
|
||||
// устройствам вышедшего (ADR-041).
|
||||
if len(last.Members) != 0 {
|
||||
t.Errorf("получатели после выхода последнего: %+v", last.Members)
|
||||
}
|
||||
if len(last.Left) != 1 || last.Left[0].Nick != "petya" {
|
||||
t.Errorf("room_left после выхода последнего: %+v", last.Left)
|
||||
}
|
||||
if got := ids(t, s, `SELECT id FROM rooms`); len(got) != 0 {
|
||||
t.Errorf("пустая комната осталась: %v", got)
|
||||
}
|
||||
if got := ids(t, s, `SELECT DISTINCT nick FROM room_keys`); len(got) != 0 {
|
||||
t.Errorf("ключи удалённой комнаты остались: %v", got)
|
||||
}
|
||||
}
|
||||
@@ -22,11 +22,12 @@ func TestMigrateAndRestart(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "bare.db")
|
||||
|
||||
first := open(t, path)
|
||||
if got := first.Applied(); len(got) != 1 || got[0] != "001_init.sql" {
|
||||
t.Fatalf("применённые миграции: получено %v, ожидалось [001_init.sql]", got)
|
||||
want := []string{"001_init.sql", "002_room_needs_rekey.sql"}
|
||||
if got := first.Applied(); !equal(got, want) {
|
||||
t.Fatalf("применённые миграции: получено %v, ожидалось %v", got, want)
|
||||
}
|
||||
if got := version(t, first); got != 1 {
|
||||
t.Errorf("user_version: получено %d, ожидалась 1", got)
|
||||
if got := version(t, first); got != len(want) {
|
||||
t.Errorf("user_version: получено %d, ожидалась %d", got, len(want))
|
||||
}
|
||||
// Все восемь таблиц из docs/storage.md на месте.
|
||||
for _, table := range []string{"users", "devices", "sessions", "contacts", "rooms", "room_members", "room_keys", "queue"} {
|
||||
@@ -52,8 +53,8 @@ func TestMigrateAndRestart(t *testing.T) {
|
||||
if got := second.Applied(); len(got) != 0 {
|
||||
t.Errorf("повторный старт применил %v, ожидалось ничего", got)
|
||||
}
|
||||
if got := version(t, second); got != 1 {
|
||||
t.Errorf("user_version после перезапуска: получено %d, ожидалась 1", got)
|
||||
if got := version(t, second); got != len(want) {
|
||||
t.Errorf("user_version после перезапуска: получено %d, ожидалась %d", got, len(want))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -124,7 +125,7 @@ func TestUsersAndSessions(t *testing.T) {
|
||||
}
|
||||
|
||||
// Удаление пользователя уносит сессии каскадом.
|
||||
if err := s.DeleteUser(ctx, "marta"); err != nil {
|
||||
if _, err := s.DeleteUser(ctx, "marta"); err != nil {
|
||||
t.Fatalf("DeleteUser: %v", err)
|
||||
}
|
||||
if _, err := s.Session(ctx, live, now); !errors.Is(err, ErrNotFound) {
|
||||
|
||||
+98
-10
@@ -104,16 +104,104 @@ func (s *Store) SetPassword(ctx context.Context, nick string, cred Credential, b
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteUser удаляет пользователя; устройства, сессии, контакты, членство
|
||||
// и очереди уносит каскад.
|
||||
// DeleteUser удаляет пользователя; устройства, сессии, контакты, членство,
|
||||
// ключи комнат и очереди уносит каскад.
|
||||
//
|
||||
// Комнаты, где пользователь владелец, каскадом не удаляются: rooms.owner
|
||||
// ссылается на users(nick) без ON DELETE, и удаление такого пользователя
|
||||
// упрётся в внешний ключ. Передача владения и удаление пустых комнат —
|
||||
// ADR-018, этап 3; до появления комнат случай не наступает.
|
||||
func (s *Store) DeleteUser(ctx context.Context, nick string) error {
|
||||
if _, err := s.db.ExecContext(ctx, `DELETE FROM users WHERE nick = ?`, nick); err != nil {
|
||||
return fmt.Errorf("store: удаление пользователя: %w", err)
|
||||
// Удаление аккаунта — выход из всех его комнат (ADR-041): по составу это
|
||||
// тот же уход участника, что и POST /api/rooms/{id}/leave. Членство и ключи
|
||||
// убираются до каскада, чтобы собрать оставшихся с их устройствами и
|
||||
// текущими ключами; владение переходит участнику с наименьшим joined_at,
|
||||
// опустевшая комната удаляется, у остальных выставляется needs_rekey.
|
||||
// Всё вместе — одна транзакция; события рассылает обработчик после неё.
|
||||
func (s *Store) DeleteUser(ctx context.Context, nick string) ([]RoomChange, error) {
|
||||
tx, err := s.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("store: удаление пользователя: %w", err)
|
||||
}
|
||||
return nil
|
||||
defer tx.Rollback()
|
||||
|
||||
rooms, err := memberRooms(ctx, tx, nick)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var changes []RoomChange
|
||||
for _, id := range rooms {
|
||||
room, err := roomRow(ctx, tx, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
change, err := leaveRoom(ctx, tx, room, nick)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Комната опустела и удалена — рассылать некому.
|
||||
if len(change.Members) > 0 {
|
||||
changes = append(changes, change)
|
||||
}
|
||||
}
|
||||
// Владелец всегда состоит в своей комнате: из состава его не убрать,
|
||||
// а выход передаёт владение (ADR-018), — так что здесь уже пусто.
|
||||
// Проверка остаётся ради внешнего ключа: rooms.owner ссылается на
|
||||
// users(nick) без ON DELETE, и забытая строка заперла бы удаление.
|
||||
owned, err := ownedRooms(ctx, tx, nick)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, room := range owned {
|
||||
var heir string
|
||||
err := tx.QueryRowContext(ctx, `
|
||||
SELECT nick FROM room_members
|
||||
WHERE room_id = ? AND nick <> ? ORDER BY joined_at, nick LIMIT 1`, room, nick).Scan(&heir)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
if _, err := tx.ExecContext(ctx, `DELETE FROM rooms WHERE id = ?`, room); err != nil {
|
||||
return nil, fmt.Errorf("store: удаление пустой комнаты: %w", err)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("store: передача владения: %w", err)
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, `UPDATE rooms SET owner = ? WHERE id = ?`, heir, room); err != nil {
|
||||
return nil, fmt.Errorf("store: передача владения: %w", err)
|
||||
}
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, `DELETE FROM users WHERE nick = ?`, nick); err != nil {
|
||||
return nil, fmt.Errorf("store: удаление пользователя: %w", err)
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return nil, fmt.Errorf("store: удаление пользователя: %w", err)
|
||||
}
|
||||
return changes, nil
|
||||
}
|
||||
|
||||
// ownedRooms — комнаты, где пользователь владелец.
|
||||
func ownedRooms(ctx context.Context, tx *sql.Tx, nick string) ([]string, error) {
|
||||
return roomIDs(ctx, tx, `SELECT id FROM rooms WHERE owner = ? ORDER BY created_at, id`, nick)
|
||||
}
|
||||
|
||||
// memberRooms — комнаты, где пользователь участник.
|
||||
func memberRooms(ctx context.Context, tx *sql.Tx, nick string) ([]string, error) {
|
||||
return roomIDs(ctx, tx, `SELECT room_id FROM room_members WHERE nick = ? ORDER BY joined_at, room_id`, nick)
|
||||
}
|
||||
|
||||
// roomIDs — идентификаторы комнат по запросу с одним параметром.
|
||||
func roomIDs(ctx context.Context, tx *sql.Tx, query, nick string) ([]string, error) {
|
||||
rows, err := tx.QueryContext(ctx, query, nick)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("store: комнаты пользователя: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []string
|
||||
for rows.Next() {
|
||||
var id string
|
||||
if err := rows.Scan(&id); err != nil {
|
||||
return nil, fmt.Errorf("store: комнаты пользователя: %w", err)
|
||||
}
|
||||
out = append(out, id)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("store: комнаты пользователя: %w", err)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user