Files
bare/internal/api/rooms.go
T
mayatnikovandClaude Opus 5 8f67f4aa4d Этап 4: PWA и пуши — service worker, Web Push с VAPID, баннер установки
Сервер: пакет push на webpush-go (третья и последняя прямая зависимость),
подписка устройства, правило ADR-023 «пуш только молчащему устройству и только
один» через атомарный захват push_pending, удаление подписки на 404 и 410,
TTL сутки, urgency normal. В нагрузке только {title, body, chat} — тело
собирается из константы, плейнтекст туда не попадает даже по ошибке.

Клиент: service worker с версионированным кэшем оболочки и никогда — /api/*,
push и notificationclick, подписка на VAPID-ключ сервера, запрос разрешения
после первого отправленного сообщения, разделы настроек «уведомления»
и «установить приложение», баннер установки на iOS.

ADR-045: пуш адресован получателю — по букве ADR-023 он уходил бы и молчащему
устройству отправителя с бессмысленным заголовком из собственного ника.
ADR-047: сервер ходит на endpoint подписки, который выбирает браузер. Проверка
«только https» обходилась редиректом, а имя могло смотреть внутрь сети — теперь
запрет редиректов и проверка разрешённого адреса на уровне сокета.
ADR-048: пределы отправки — недоступный push-сервис одного аккаунта больше
не съедает пуши всего сервера.
ADR-049: явно выключенные уведомления сами не включаются обратно.

Попутно: webpush-go дописывает набивку в переданный срез, а одна нагрузка
уходила всем устройствам сообщения — гонка, пойманная go test -race.
Теперь у каждого задания своя копия.

Приёмка на боевом: подписки, hasPush, чужое устройство, Origin, оболочка
из девяти файлов, Service-Worker-Allowed. Отдельно шесть непубличных адресов
и endpoint на 3 КиБ — все отбиты.

Чеклист ручной проверки на iPhone и Android — в docs/plan.md.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015DbCjVfTFq4ZFG8juD45YJ
2026-08-23 00:50:58 +03:00

412 lines
15 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package api
import (
"encoding/json"
"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. 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).
//
// Форма строже, чем «до 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)
}