package api import ( "encoding/json" "errors" "net/http" "time" "unicode" "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. keys присутствует всегда, // пустой — []; в GET /api/rooms это все удерживаемые сервером ключи // запрашивающего, от старого к новому, в событии room — только новый // (ADR-059). 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"` Keys []keyOut `json:"keys"` 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), а участник, пропустивший // rekey в офлайне, — недостающий ключ (ADR-059). 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.Keys)) } writeJSON(w, http.StatusOK, out) } // POST /api/rooms — создание комнаты. Идентификатор выдаёт клиент // (ADR-037), ключ приходит ровно один и заворачивается создателем себе: // его другие устройства получают комнату вместе с ключом (ADR-018). func (s *server) createRoom(w http.ResponseWriter, r *http.Request) { 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 } // X-Device здесь необязателен, но чужой и кривой — 403, как и везде, // где устройство важно (docs/protocol.md, «Общие правила»). Проверка // идёт после формы тела: права — после неё (ADR-043). device, ok := s.optionalDevice(w, r) if !ok { 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, keysFor(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, keysFor(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, keyList(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 протокола: состав и ключи всегда списки, // пустые — []. func roomJSON(room store.Room, keys []store.RoomKey) roomOut { out := roomOut{ ID: room.ID, Name: room.Name, Owner: room.Owner, Members: room.Members, CreatedAt: room.CreatedAt, Keys: make([]keyOut, 0, len(keys)), NeedsRekey: room.NeedsRekey, } if out.Members == nil { out.Members = []string{} } for _, key := range keys { out.Keys = append(out.Keys, keyOut{KeyID: key.KeyID, From: key.From, IV: key.IV, CT: key.CT}) } return out } // keyList — ключ события: он один, новый (ADR-059). Остальные свои ключи // получатель уже видел, а отключённый доберёт их из GET /api/rooms. func keyList(key *store.RoomKey) []store.RoomKey { if key == nil { return nil } return []store.RoomKey{*key} } // keysFor — ключ участника в итоге изменения: у каждого он свой. func keysFor(change store.RoomChange, nick string) []store.RoomKey { for _, member := range change.Members { if member.Nick == nick { return keyList(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). // // Форма строже, чем «до 64 символов», с этапа 4: имя комнаты уходит // в заголовок системного уведомления (ADR-045), а туда нельзя ни перевод // строки, ни разворот текста — на экране блокировки такое имя выглядит // не строкой списка, а сообщением от системы. func validRoomName(name string) bool { if name == "" || utf8.RuneCountInString(name) > maxRoomName { return false } blank := true for _, r := range name { if unicode.IsControl(r) || bidi(r) { return false } if !unicode.IsSpace(r) { blank = false } } return !blank } // bidi — переопределения направления письма: U+202A…U+202E и U+2066…U+2069. // Они переставляют текст на экране местами, оставаясь невидимыми. func bidi(r rune) bool { return (r >= 0x202A && r <= 0x202E) || (r >= 0x2066 && r <= 0x2069) }