From 63a7a1ef52803e5657c73c9957342eb0968f7092 Mon Sep 17 00:00:00 2001 From: Yuriy Mayatnikov Date: Sat, 22 Aug 2026 18:18:04 +0300 Subject: [PATCH] =?UTF-8?q?=D0=AD=D1=82=D0=B0=D0=BF=202:=20=D1=87=D0=B0?= =?UTF-8?q?=D1=82=201:1=20=E2=80=94=20=D1=83=D1=81=D1=82=D1=80=D0=BE=D0=B9?= =?UTF-8?q?=D1=81=D1=82=D0=B2=D0=B0,=20=D0=BE=D1=87=D0=B5=D1=80=D0=B5?= =?UTF-8?q?=D0=B4=D1=8C,=20SSE,=20=D1=88=D0=B8=D1=84=D1=80=D0=BE=D0=B2?= =?UTF-8?q?=D0=B0=D0=BD=D0=B8=D0=B5=20=D1=81=D0=BE=D0=BE=D0=B1=D1=89=D0=B5?= =?UTF-8?q?=D0=BD=D0=B8=D0=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Сервер: регистрация устройств и X-Device, hub с одним потоком на устройство, очередь per-device с фан-аутом без эха отправителю, POST /api/messages с проверками в порядке protocol.md, ACK, SSE с воспроизведением очереди, ready и пингом раз в 20 секунд, контакты в обе стороны при первом сообщении, лимит 30 сообщений в минуту. Клиент: ULID, ключ 1:1 из ECDH через HKDF, шифрование конверта с AAD, sync.js как единственный писатель в IndexedDB, ACK строго после записи, список чатов, экран чата по эталону, разделители дат и «новые», pending и failed с повтором, полоса «нет соединения». ADR-033: у неотправленного есть текст отказа — clock_skew стало видно. ADR-034: входящее с известным id не перезаписывает запись. Собеседник знает открытый id конверта и подменял им чужое сообщение в чужой истории — вплоть до стирания своего присланного, чего «удалить у всех не существует» не допускает. ADR-035: один поток событий на браузерный профиль (locks + BroadcastChannel): две вкладки отбирали поток друг у друга и оставались без живой доставки. ADR-036: повтор отправки сохраняет ULID, пока он в пределах окна часов, — иначе потерянный ответ давал у собеседника два сообщения вместо одного. Приёмка на боевом сервере: два аккаунта, пять устройств, живая доставка, копия на второе устройство, очередь офлайн-устройству, ACK, подмена from игнорируется, чужой deviceId и запрос без Origin отбиваются, плейнтекста в базе и WAL ноль вхождений. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_015DbCjVfTFq4ZFG8juD45YJ --- cmd/bare/main.go | 7 +- docs/decisions/033-failed-message-reason.md | 22 + docs/decisions/034-incoming-message-id.md | 22 + docs/decisions/035-one-stream-per-profile.md | 22 + docs/decisions/036-resend-keeps-ulid.md | 33 + docs/storage.md | 8 +- docs/ui.md | 3 + internal/api/api.go | 43 +- internal/api/api_test.go | 48 +- internal/api/contacts.go | 97 ++ internal/api/contacts_test.go | 85 ++ internal/api/devices.go | 120 +++ internal/api/devices_test.go | 180 ++++ internal/api/events.go | 118 +++ internal/api/events_test.go | 284 ++++++ internal/api/limit.go | 91 ++ internal/api/limit_test.go | 121 +++ internal/api/messages.go | 201 ++++ internal/api/messages_test.go | 377 ++++++++ internal/api/ulid.go | 37 + internal/api/valid.go | 8 + internal/hub/hub.go | 123 +++ internal/hub/hub_test.go | 170 ++++ internal/store/contacts.go | 65 ++ internal/store/devices.go | 129 +++ internal/store/queue.go | 133 +++ web/app.css | 373 +++++++- web/js/api.js | 102 ++- web/js/crypto.js | 94 ++ web/js/db.js | 253 +++++ web/js/main.js | 125 ++- web/js/sync.js | 911 +++++++++++++++++++ web/js/ui/chat.js | 425 +++++++++ web/js/ui/chats.js | 73 ++ web/js/ui/contact.js | 61 ++ web/js/ui/dom.js | 9 + web/js/ui/new.js | 68 ++ web/js/ui/shell.js | 27 +- web/js/ulid.js | 104 +++ 39 files changed, 5126 insertions(+), 46 deletions(-) create mode 100644 docs/decisions/033-failed-message-reason.md create mode 100644 docs/decisions/034-incoming-message-id.md create mode 100644 docs/decisions/035-one-stream-per-profile.md create mode 100644 docs/decisions/036-resend-keeps-ulid.md create mode 100644 internal/api/contacts.go create mode 100644 internal/api/contacts_test.go create mode 100644 internal/api/devices.go create mode 100644 internal/api/devices_test.go create mode 100644 internal/api/events.go create mode 100644 internal/api/events_test.go create mode 100644 internal/api/limit.go create mode 100644 internal/api/limit_test.go create mode 100644 internal/api/messages.go create mode 100644 internal/api/messages_test.go create mode 100644 internal/api/ulid.go create mode 100644 internal/hub/hub.go create mode 100644 internal/hub/hub_test.go create mode 100644 internal/store/contacts.go create mode 100644 internal/store/devices.go create mode 100644 internal/store/queue.go create mode 100644 web/js/sync.js create mode 100644 web/js/ui/chat.js create mode 100644 web/js/ui/chats.js create mode 100644 web/js/ui/contact.js create mode 100644 web/js/ui/new.js create mode 100644 web/js/ulid.js diff --git a/cmd/bare/main.go b/cmd/bare/main.go index 076e3ce..17f5e7c 100644 --- a/cmd/bare/main.go +++ b/cmd/bare/main.go @@ -80,8 +80,9 @@ func serve() error { fmt.Printf("bare применил миграцию %s\n", name) } + h := api.New(cfg, st, static, os.Stdout) srv := &http.Server{ - Handler: api.New(cfg, st, static, os.Stdout), + Handler: h, ReadHeaderTimeout: 10 * time.Second, IdleTimeout: 120 * time.Second, // OPTIONS * иначе обслуживает net/http сам, в обход middleware: @@ -90,6 +91,10 @@ func serve() error { // WriteTimeout не задаётся: впереди SSE с долгими ответами (ADR-004). } + // Потоки событий не заканчиваются сами: без этого Shutdown ждал бы, + // пока подключённые клиенты уйдут, до самого таймаута (ADR-004). + srv.RegisterOnShutdown(h.Close) + // Сначала bind, потом сообщение: строка в журнале означает, что порт занят // нами, а не то, что мы собирались его занять. ln, err := net.Listen("tcp", cfg.Addr) diff --git a/docs/decisions/033-failed-message-reason.md b/docs/decisions/033-failed-message-reason.md new file mode 100644 index 0000000..db98880 --- /dev/null +++ b/docs/decisions/033-failed-message-reason.md @@ -0,0 +1,22 @@ +# ADR-033: Текст отказа у неотправленного сообщения + +## Контекст + +`docs/storage.md` задаёт судьбу исходящего: `202` → `sent`, сетевая ошибка → остаётся `pending`, `4xx` → `failed` «с текстом ошибки». Поля для этого текста в записи `messages` нет — есть только `status`. + +`docs/ui.md` описывает вторую половину так же наполовину. У сообщения в ленте есть пометка «не отправлено · повторить», одна на все причины. `clock_skew` записан в «Сеть и состояния» с текстом «проверьте часы на устройстве: расхождение больше 5 минут», но где он показывается — не сказано, а показать его негде: пометка у сообщения фиксирована, строка состояния формы (ADR-028) в чате не живёт. + +Этап 2 упёрся в это на первой же отправке. Причина отказа известна ровно в момент ответа сервера, а сообщение живёт дальше и переживает перезагрузку страницы. + +## Решение + +- Запись `messages` получает необязательное поле `error: string` — текст отказа, из-за которого сообщение стало `failed`. Тексты берутся из тех же перечней, что и у форм: коды `docs/protocol.md` и строки ADR-028. Новых строк интерфейса это решение не заводит. +- Поле живёт только у `failed`. Новая попытка отправки заводит запись с новым ULID и без него. +- Текст показывается полосой над вводом цветом `mark` — там же, где «нет соединения» и предупреждение о ключе. Место одно, как требует ADR-028; пометка «не отправлено · повторить» у самого сообщения не меняется. +- Поле служебное: на сервер не уходит и в архив `.bare` не пишется, как и `raw`. + +## Следствия + +- `clock_skew` наконец видно: расхождение часов объясняется словами, а не молчаливым «не отправлено». +- Текст переживает перезагрузку вместе с сообщением: он часть записи, а не состояние экрана. +- Причин у полосы над вводом становится три — нет соединения, ключ изменился, отказ отправки. Больше одной сразу не показывается: полоса одна. diff --git a/docs/decisions/034-incoming-message-id.md b/docs/decisions/034-incoming-message-id.md new file mode 100644 index 0000000..c3bb942 --- /dev/null +++ b/docs/decisions/034-incoming-message-id.md @@ -0,0 +1,22 @@ +# ADR-034: Входящее сообщение с известным id не перезаписывает запись + +## Контекст + +`docs/storage.md` описывал повтор доставки одной строкой: «`put` с тем же `id`, без дублей». Подразумевался тот же самый конверт — сервер выдаёт очередь заново при каждом подключении и вправе прислать конверт дважды (ADR-017). Клиент так и делал: писал `put` по `id` безусловно. + +`id` — открытое поле конверта, собеседник видит его сразу, как получает сообщение. Истории идентификаторов сервер не хранит: это противоречило бы ADR-008, — поэтому `POST /api/messages` с чужим `id` он принимает и раскладывает по очередям как любой другой. AAD сходится: в него входят `id`, `chat`, `from` и `keyId`, а `from` сервер ставит из сессии — конверт собеседника валиден и расшифровывается. Дальше `put` перезаписывал мою строку: в ленте вместо моего сообщения оказывался чужой текст, исходное исчезало, и фан-аут разносил подмену на остальные мои устройства. Тем же приёмом собеседник стирал и то, что прислал сам, — это прямо противоречит модели угроз: «„Удалить у всех“ после доставки не существует». + +Вторая половина того же места — счётчик. Все чтения `messages` выпускались до первого `put`, поэтому две копии одного конверта в одной пачке обе считались новыми и `unread` рос дважды. Такая пачка — не гипотеза: конверт, попавший в очередь в момент подключения, приходит и выдачей очереди, и живым событием. + +## Решение + +- Входящее сообщение с уже известным `id` игнорируется целиком: ни записи, ни счётчика непрочитанных. Строку, которая уже лежит на устройстве, входящий конверт не трогает. +- Повторный `id` внутри одной пачки учитывается один раз. +- Перезапись по `id` остаётся у исходящего: переход `pending → sent/failed`, где `id` свой и запись своя. +- Правило записано строкой в `docs/storage.md`. + +## Следствия + +- Знание `id` не даёт собеседнику ничего: подменяющий конверт у получателя молча пропадает. +- Нерасшифрованное чинится не повторной доставкой, а полем `raw` — оно для этого и хранится (`docs/storage.md`). +- Дубль доставки, разрешённый ADR-017, больше не двигает счётчик непрочитанных. diff --git a/docs/decisions/035-one-stream-per-profile.md b/docs/decisions/035-one-stream-per-profile.md new file mode 100644 index 0000000..d91cb18 --- /dev/null +++ b/docs/decisions/035-one-stream-per-profile.md @@ -0,0 +1,22 @@ +# ADR-035: Один поток событий на браузерный профиль + +## Контекст + +Устройство определяется браузерным профилем (ADR-017), а `docs/protocol.md` держит одно соединение на устройство: новое закрывает предыдущее. Про несколько вкладок одного профиля не сказано нигде, и клиент открывал `EventSource` в каждой. + +Две вкладки одного аккаунта отбирали поток друг у друга бесконечно: сервер закрывал предыдущее соединение, браузер переподключался через три секунды и закрывал соседнее. Замер — семь соединений за двадцать секунд, каждое ровно по три секунды, и после каждого `ready` ещё `GET /api/contacts` и повтор `pending`. Живой доставки при этом нет ни у одной вкладки: сообщения приходят только выдачей очереди, полоса состояния мигает, сервер получает два десятка запросов в минуту на пользователя — и так, пока открыты обе вкладки. + +## Решение + +- Поток открывает одна вкладка профиля — та, что держит замок `navigator.locks` с именем `bare-stream`. Замок берётся на всё время работы синхронизации и отпускается при выходе и при закрытии вкладки; следующая вкладка получает его сразу и открывает поток. +- Остальные вкладки потока не открывают. Экраны, чтение базы и отправка у них работают как прежде: `POST` потока не требует. +- Вкладки рассказывают друг другу об изменениях через `BroadcastChannel`: то же, что вкладка раздаёт своим экранам, — изменения лент, список чатов, состояние сети. Пишет в базу каждая сама, поэтому рассылают все, а не только владелец. +- Неотправленное повторяет только владелец потока: иначе одно сообщение ушло бы дважды, с разными ULID. +- Оба API нужны вместе: замок выбирает владельца, канал раздаёт его находки. Нет хотя бы одного — вкладка работает как единственная, то есть как до этого решения. + +## Следствия + +- Соединений к серверу столько, сколько браузерных профилей, а не открытых вкладок. +- Вкладка-наблюдатель показывает то же, что владелец, с задержкой в один `postMessage`. +- Новых текстов интерфейса решение не заводит: наблюдатель видит те же полосы, что владелец. +- Зависимостей не прибавляется: `navigator.locks` и `BroadcastChannel` — нативные браузерные API (ADR-001). diff --git a/docs/decisions/036-resend-keeps-ulid.md b/docs/decisions/036-resend-keeps-ulid.md new file mode 100644 index 0000000..51f9bd5 --- /dev/null +++ b/docs/decisions/036-resend-keeps-ulid.md @@ -0,0 +1,33 @@ +# ADR-036: Повтор отправки сохраняет ULID + +## Контекст + +ADR-017 присваивает ULID в момент попытки отправки, а не набора: сервер принимает сообщение, только если время в идентификаторе расходится с его часами не больше чем на пять минут. `docs/storage.md` довёл это до правила «при каждой попытке отправки `pending` получает новый ULID»: старая запись удалялась, новая писалась. + +У правила есть цена. `POST /api/messages` кладёт конверт в очередь и только потом отвечает `202`. Ответ теряется: обрыв на мобильной сети, закрытая вкладка, `502` от прокси. Сервер сообщение принял и разослал, клиент считает его неотправленным, оставляет `pending` и после следующего `ready` шлёт заново — уже с другим идентификатором. Собеседник видит один и тот же текст дважды, двумя разными записями, и склеить их нечем: `id` у них разные. Обрыв сразу после отправки — обычное дело на телефоне, а дубль остаётся в истории навсегда. + +Второй половины проблемы больше нет. ADR-034 заставил получателя игнорировать входящее с уже известным `id` целиком: ни записи, ни счётчика. Значит повтор с тем же идентификатором безвреден — сервер положит конверт в очередь (`ON CONFLICT DO NOTHING` либо новая строка, если прежнюю уже подтвердили), получатель его молча пропустит и подтвердит. Менять `id` нужно ровно тогда, когда прежний перестал годиться серверу. + +У переиспользования есть своя цена. Возраст идентификатора клиент считает по своим часам, сервер — по своим. Клиент отстаёт на две минуты, сообщение пролежало `pending` три с половиной: клиент видит запас нетронутым, сервер видит пять с половиной и отвечает `400 clock_skew` — тогда как прежнее правило дало бы свежий `id` с расхождением в две минуты и `202`. Кнопка «повторить» при этом бесполезна первые минуты: она берёт тот же `id` и получает тот же отказ, пока возраст не перевалит за запас. Оставить это пользователю нельзя: часы отстают на пару минут у любого устройства, которое давно не сверялось со временем, а сообщение при этом не уходит вовсе. + +## Решение + +- Повтор отправки идёт с прежним ULID. Запись не удаляется и не заводится заново: меняется только её состояние. +- Новый ULID берётся, когда время прежнего разошлось с текущим больше чем на четыре минуты. Тогда работает прежний порядок: старая запись удаляется, новая пишется. +- Запас — минута под серверным окном ±5 минут: за неё успевают шифрование, очередь работ клиента и сама сеть, так что дошедший запрос застаёт окно ещё открытым. +- Первая отправка ULID генерирует, как и раньше. +- Клиент сравнивает время идентификатора со своими часами: других у него нет, и первый ULID берётся из них же. +- `clock_skew` на переиспользованном идентификаторе отменяет переиспользование: прежний `id` снимается, попытка идёт второй раз со свежим. Ровно один раз — это та самая ситуация, ради которой `id` и меняется. Отказ на свежем `id` означает, что часы врут по-настоящему: сообщение становится `failed` с текстом про часы (ADR-033), второго круга нет. +- Сохранённый `id` оставляет и прежнее `ts`: время показа идёт за идентификатором, пока `202` не принесёт серверное. +- Правило записано строкой в `docs/storage.md` вместо прежнего. + +## Следствия + +- Потерянный ответ на `POST` больше не оборачивается дублем: повтор приходит собеседнику с тем же `id` и молча пропадает у него по ADR-034. +- Остаточный случай остаётся. Если ответ потерялся, а повтор случился позже окна — вкладку закрыли на час, устройство ушло в офлайн, — идентификатор сменится, и дубль появится. Иначе нельзя: сервер такое сообщение не примет вовсе. Вероятность теперь ничтожна, а раньше дубль давал любой обрыв. +- Экран не мигает. `removed` в уведомлении пуст, лента находит сообщение по прежнему `id` и перерисовывает одну строку вместо всей ленты. +- Умеренно врущие часы пользователь не разбирает. Расхождение, которое сервер видит только из-за переиспользования, снимает вторая попытка со свежим `id`; полоса про часы остаётся за настоящим расхождением — тем, что больше пяти минут и от идентификатора не зависит. +- Уточняется ADR-017: «ULID присваивается в момент попытки отправки» верно для идентификатора старше запаса. Более свежий переживает попытку, и время в нём — время первой из них. +- Уточняется ADR-033: новая попытка заводит запись без поля `error`, но не обязательно с новым ULID. +- Уточняется ADR-035. Правило «неотправленное повторяет только владелец потока» остаётся, но причина мельчает: две вкладки послали бы одно и то же сообщение дважды с одним `id`, а не два разных. +- Сортировка исходящего перестаёт зависеть от числа попыток: сообщение остаётся на своём месте в ленте, а не переезжает в конец при каждом повторе. diff --git a/docs/storage.md b/docs/storage.md index ba9204f..51868ba 100644 --- a/docs/storage.md +++ b/docs/storage.md @@ -114,6 +114,7 @@ chats key: id // "dm:" | "room:" messages key: id (ULID) index "chat": [chatId, id] {id, chatId, from, text: string|null, ts, status: "pending"|"sent"|"failed", + error?: string, // текст отказа у failed undecryptable?: "unknown_key"|"bad_aead"|"key_changed", raw?: Envelope} roomKeys key: [roomId, keyId] @@ -126,8 +127,9 @@ peers key: nick Правила: -- Сообщение пишется в `messages` до ACK серверу: сначала `put`, потом `POST /api/ack`. Повтор доставки — `put` с тем же `id`, без дублей. -- Исходящее пишется со `status: "pending"` и локальным `id`, затем `POST /api/messages`; `202` → `sent`, сетевая ошибка → остаётся `pending` и повторяется при следующем подключении; `4xx` → `failed` с текстом ошибки. При каждой попытке отправки `pending` получает новый ULID (старая запись удаляется, новая пишется): сообщение ещё не покидало устройство, а его время должно совпадать с временем фактической отправки — иначе после долгого офлайна сервер ответит `clock_skew`. +- Сообщение пишется в `messages` до ACK серверу: сначала `put`, потом `POST /api/ack`. +- Входящее сообщение с уже известным `id` игнорируется целиком: ни записи, ни счётчика непрочитанных (ADR-034). Повтор доставки не даёт ни дубля в ленте, ни второго непрочитанного; `id` открыт в конверте, и перезапись отдала бы собеседнику чужую строку истории. Перезапись по `id` остаётся у исходящего: переход `pending → sent/failed`. +- Исходящее пишется со `status: "pending"` и локальным `id`, затем `POST /api/messages`; `202` → `sent`, сетевая ошибка и `500` → остаётся `pending` и повторяется при следующем подключении; прочие `4xx` → `failed` с текстом отказа в поле `error` (ADR-033). Повтор отправки идёт с прежним ULID, пока время в нём разошлось с текущим меньше чем на четыре минуты: ответ на `POST` мог потеряться уже после того, как сервер сообщение принял, а повтор с тем же `id` получатель игнорирует (ADR-034). Идентификатор старше запаса заменяется свежим — старая запись удаляется, новая пишется: время в `id` должно совпадать с временем фактической отправки, иначе после долгого офлайна сервер ответит `clock_skew`. `clock_skew` на переиспользованном `id` отменяет переиспользование: попытка идёт второй раз со свежим `id`, ровно один раз; такой же отказ на свежем `id` — `failed` с текстом про часы (ADR-036). - `unread` и `lastReadId` — локальные, на сервер не уходят. - Нерасшифрованное сообщение хранит `raw` для повторной попытки после подтверждения нового ключа или получения недостающего `keyId`. - Пагинация — курсор по индексу `chat` назад от последнего, по 50. @@ -135,4 +137,4 @@ peers key: nick ## Экспорт `.bare` -Полезная нагрузка — `chats` (без `unread`, `lastReadId`), `messages` (без `raw`, только с `text`), `peers` (без `pending`). Формат файла и шифрование — `docs/crypto.md`. Имя файла — `bare--.bare`. +Полезная нагрузка — `chats` (без `unread`, `lastReadId`), `messages` (без `raw` и `error`, только с `text`), `peers` (без `pending`). Формат файла и шифрование — `docs/crypto.md`. Имя файла — `bare--.bare`. diff --git a/docs/ui.md b/docs/ui.md index f14d895..ad289ff 100644 --- a/docs/ui.md +++ b/docs/ui.md @@ -38,6 +38,8 @@ Предупреждение о ключе — полоса над вводом цветом `mark`: «ключ @marta изменился. сверьте отпечаток лично. [доверять новому ключу]». Ввод заблокирован до подтверждения. +Отказ отправки — та же полоса над вводом цветом `mark` с текстом из поля `error` последнего неотправленного сообщения (ADR-033): «проверьте часы на устройстве: расхождение больше 5 минут», «слишком часто, попробуйте позже», «сервер не справился, попробуйте позже». Полоса исчезает при следующей попытке. Ввод не блокируется. + Первое отправленное сообщение за всю историю устройства → запрос разрешения на уведомления (см. «Уведомления»). ## Карточка контакта (`#/contact/`) @@ -70,6 +72,7 @@ ## Сеть и состояния - SSE переподключается браузером; после `ready` клиент перечитывает комнаты и контакты и повторяет `pending`. +- Вкладок одного профиля бывает несколько; поток событий держит одна из них, остальные получают изменения от неё и выглядят так же (ADR-035). - Без сети: полоса «нет соединения» цветом `stone` над вводом; ввод не блокируется — сообщения уходят в `pending`. - `clock_skew` — «проверьте часы на устройстве: расхождение больше 5 минут». - `401 unauthenticated` на любом запросе — выход на экран входа с сохранением IndexedDB (сессия истекла, история остаётся). Исключение одно: служебный выход перед повторным входом при смене пароля и удалении аккаунта (ADR-031) — там этот ответ означает, что сессии и так нет. diff --git a/internal/api/api.go b/internal/api/api.go index cd6f305..cdc9cba 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -15,6 +15,7 @@ import ( "github.com/xmatic-squad/bare/internal/auth" "github.com/xmatic-squad/bare/internal/config" + "github.com/xmatic-squad/bare/internal/hub" "github.com/xmatic-squad/bare/internal/store" ) @@ -27,17 +28,36 @@ const maxLogPath = 256 // csp — политика из ADR-021. HSTS ставит nginx, здесь его нет. const csp = "default-src 'self'; img-src 'self' data:; frame-ancestors 'none'; base-uri 'none'; form-action 'self'" -// server — общее для обработчиков: настройки, база, куда писать журнал. +// server — общее для обработчиков: настройки, база, открытые потоки +// событий, лимиты, куда писать журнал. type server struct { cfg *config.Config st *store.Store + hub *hub.Hub + msgs *buckets logw io.Writer } +// Handler — обработчик всех маршрутов и живые SSE-потоки за ним. +type Handler struct { + http.Handler + hub *hub.Hub +} + +// Close закрывает открытые потоки событий. Без него остановка сервера +// ждала бы, пока клиенты уйдут сами: у потока нет конца (ADR-004). +func (h *Handler) Close() { h.hub.CloseAll() } + // New собирает обработчик: /api/, /healthz, всё остальное — статика. // logw — куда писать строки запросов и причины отказов; nil отключает лог. -func New(cfg *config.Config, st *store.Store, static http.Handler, logw io.Writer) http.Handler { - s := &server{cfg: cfg, st: st, logw: logw} +func New(cfg *config.Config, st *store.Store, static http.Handler, logw io.Writer) *Handler { + s := &server{ + cfg: cfg, + st: st, + hub: hub.New(), + msgs: newBuckets(messagesPerMinute, messagesBurst), + logw: logw, + } fail := auth.Fail{Error: Error, Internal: s.internal} // Сессия проверяется на всех непубличных маршрутах (docs/protocol.md). private := auth.Require(st, fail) @@ -56,13 +76,28 @@ func New(cfg *config.Config, st *store.Store, static http.Handler, logw io.Write mux.Handle("POST /api/password", private(http.HandlerFunc(s.password))) mux.Handle("GET /api/users/{nick}", private(http.HandlerFunc(s.user))) + mux.Handle("POST /api/devices", private(http.HandlerFunc(s.createDevice))) + mux.Handle("GET /api/devices", private(http.HandlerFunc(s.devices))) + mux.Handle("DELETE /api/devices/{id}", private(http.HandlerFunc(s.deleteDevice))) + + mux.Handle("GET /api/contacts", private(http.HandlerFunc(s.contacts))) + 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/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))) + // Всё прочее под /api/ — 404, включая неподдерживаемый метод известного // пути: кода 405 в протоколе нет (ADR-026). Этот маршрут заодно не даёт // запросам к /api/ уходить в обработчик статики. mux.HandleFunc("/api/", func(w http.ResponseWriter, r *http.Request) { NotFound(w) }) mux.Handle("/", static) - return logging(logw, headers(auth.Origin(cfg.Origin, fail)(limitBody(mux)))) + return &Handler{ + Handler: logging(logw, headers(auth.Origin(cfg.Origin, fail)(limitBody(mux)))), + hub: s.hub, + } } func healthz(w http.ResponseWriter, r *http.Request) { diff --git a/internal/api/api_test.go b/internal/api/api_test.go index fdc07b0..f3df3e3 100644 --- a/internal/api/api_test.go +++ b/internal/api/api_test.go @@ -8,6 +8,7 @@ import ( "net/http/httptest" "path/filepath" "strings" + "sync" "testing" "github.com/xmatic-squad/bare/internal/api" @@ -23,7 +24,34 @@ type env struct { t *testing.T h http.Handler st *store.Store - log *bytes.Buffer + log *syncLog + srv *httptest.Server +} + +// syncLog — журнал сервера в памяти. Под замком, потому что пишут в него +// и обработчики, вызванные напрямую, и обработчики настоящего сервера +// из live: у них разные горутины. +type syncLog struct { + mu sync.Mutex + buf bytes.Buffer +} + +func (l *syncLog) Write(p []byte) (int, error) { + l.mu.Lock() + defer l.mu.Unlock() + return l.buf.Write(p) +} + +func (l *syncLog) String() string { + l.mu.Lock() + defer l.mu.Unlock() + return l.buf.String() +} + +func (l *syncLog) Reset() { + l.mu.Lock() + defer l.mu.Unlock() + l.buf.Reset() } func newEnv(t *testing.T) *env { return invited(t, "") } @@ -48,7 +76,7 @@ func invited(t *testing.T, code string) *env { VAPIDPublic: "vapid", InviteCode: code, } - e := &env{t: t, st: st, log: &bytes.Buffer{}} + e := &env{t: t, st: st, log: &syncLog{}} e.h = api.New(cfg, st, static, e.log) return e } @@ -81,6 +109,18 @@ func (e *env) do(method, target string, body any, opts ...func(*http.Request)) * return rec } +// live поднимает настоящий сервер на том же обработчике. Нужен потоку +// событий: httptest.ResponseRecorder не отдаёт тело, пока обработчик +// не вернулся, а поток не возвращается никогда. +func (e *env) live() *httptest.Server { + e.t.Helper() + if e.srv == nil { + e.srv = httptest.NewServer(e.h) + e.t.Cleanup(e.srv.Close) + } + return e.srv +} + func with(c *http.Cookie) func(*http.Request) { return func(r *http.Request) { if c != nil { @@ -89,6 +129,10 @@ func with(c *http.Cookie) func(*http.Request) { } } +func withDevice(id string) func(*http.Request) { + return func(r *http.Request) { r.Header.Set("X-Device", id) } +} + func withOrigin(value string) func(*http.Request) { return func(r *http.Request) { if value == "" { diff --git a/internal/api/contacts.go b/internal/api/contacts.go new file mode 100644 index 0000000..c37942f --- /dev/null +++ b/internal/api/contacts.go @@ -0,0 +1,97 @@ +package api + +import ( + "encoding/json" + "errors" + "net/http" + "time" + + "github.com/xmatic-squad/bare/internal/auth" + "github.com/xmatic-squad/bare/internal/store" +) + +// Контакт — строка в списке чатов, не разрешение на переписку: писать +// можно любому нику, согласия не требуется (ADR-019). + +// GET /api/contacts — список чатов 1:1 с публичными ключами собеседников. +func (s *server) contacts(w http.ResponseWriter, r *http.Request) { + sess, _ := auth.From(r) + list, err := s.st.Contacts(r.Context(), sess.Nick) + if err != nil { + s.internal(w, r, err) + return + } + type contactOut struct { + Nick string `json:"nick"` + PublicKey json.RawMessage `json:"publicKey"` + CreatedAt int64 `json:"createdAt"` + } + out := make([]contactOut, 0, len(list)) + for _, c := range list { + out = append(out, contactOut{c.Nick, json.RawMessage(c.PublicKey), c.CreatedAt}) + } + writeJSON(w, http.StatusOK, out) +} + +// POST /api/contacts — завести чат с ником вручную, до первого сообщения. +func (s *server) addContact(w http.ResponseWriter, r *http.Request) { + var in struct { + Nick string `json:"nick"` + } + if !decode(w, r, &in) { + return + } + sess, _ := auth.From(r) + peer, ok := s.peer(w, r, in.Nick, sess.Nick) + if !ok { + return + } + created, err := s.st.AddContact(r.Context(), sess.Nick, peer.Nick, time.Now().UnixMilli()) + if err != nil { + s.internal(w, r, err) + return + } + status := http.StatusOK + if created { + status = http.StatusCreated + } + writeJSON(w, status, struct { + Nick string `json:"nick"` + PublicKey json.RawMessage `json:"publicKey"` + }{peer.Nick, json.RawMessage(peer.PublicKey)}) +} + +// DELETE /api/contacts/{nick} — убрать чат из списка. Зеркальная строка +// у собеседника остаётся: это не блокировка (ADR-019). Строки не было — +// тот же 204, удалять нечего. +func (s *server) deleteContact(w http.ResponseWriter, r *http.Request) { + sess, _ := auth.From(r) + if err := s.st.DeleteContact(r.Context(), sess.Nick, r.PathValue("nick")); err != nil { + s.internal(w, r, err) + return + } + noContent(w) +} + +// peer читает собеседника по нику. Порядок отказов — docs/protocol.md: +// сначала существование ника, потом запрет писать себе. +func (s *server) peer(w http.ResponseWriter, r *http.Request, nick, me string) (store.User, bool) { + if !validNick(nick) { + unknownUser(w) + return store.User{}, false + } + u, err := s.st.User(r.Context(), nick) + if errors.Is(err, store.ErrNotFound) { + unknownUser(w) + return store.User{}, false + } + if err != nil { + s.internal(w, r, err) + return store.User{}, false + } + if u.Nick == me { + Error(w, http.StatusBadRequest, "self", "нельзя писать себе") + return store.User{}, false + } + return u, true +} diff --git a/internal/api/contacts_test.go b/internal/api/contacts_test.go new file mode 100644 index 0000000..2fff340 --- /dev/null +++ b/internal/api/contacts_test.go @@ -0,0 +1,85 @@ +package api_test + +import ( + "encoding/json" + "net/http" + "testing" +) + +// contactOut — строка ответа GET /api/contacts. +type contactOut struct { + Nick string `json:"nick"` + PublicKey json.RawMessage `json:"publicKey"` + CreatedAt int64 `json:"createdAt"` +} + +func (e *env) contacts(c *http.Cookie) []contactOut { + e.t.Helper() + rec := e.do(http.MethodGet, "/api/contacts", nil, with(c)) + expect(e.t, rec, http.StatusOK, "") + var out []contactOut + decodeBody(e.t, rec, &out) + return out +} + +func TestContacts(t *testing.T) { + e := newEnv(t) + marta := e.signUp("marta") + petya := e.signUp("petya") + + if got := e.contacts(marta); len(got) != 0 { + t.Fatalf("контакты нового аккаунта: %+v", got) + } + + rec := e.do(http.MethodPost, "/api/contacts", map[string]any{"nick": "petya"}, with(marta)) + expect(t, rec, http.StatusCreated, "") + var added struct { + Nick string `json:"nick"` + PublicKey json.RawMessage `json:"publicKey"` + } + decodeBody(t, rec, &added) + if added.Nick != "petya" || len(added.PublicKey) == 0 { + t.Errorf("ответ: %s", rec.Body.String()) + } + + // Повтор — 200 и та же строка. + expect(t, e.do(http.MethodPost, "/api/contacts", map[string]any{"nick": "petya"}, with(marta)), http.StatusOK, "") + if got := e.contacts(marta); len(got) != 1 || got[0].Nick != "petya" { + t.Errorf("контакты marta: %+v", got) + } + // Зеркальной строки POST не заводит: она появляется при первом + // сообщении (ADR-019). + if got := e.contacts(petya); len(got) != 0 { + t.Errorf("контакты petya: %+v", got) + } + + expect(t, e.do(http.MethodPost, "/api/contacts", map[string]any{"nick": "marta"}, with(marta)), + http.StatusBadRequest, "self") + expect(t, e.do(http.MethodPost, "/api/contacts", map[string]any{"nick": "kolya"}, with(marta)), + http.StatusNotFound, "unknown_user") + expect(t, e.do(http.MethodPost, "/api/contacts", map[string]any{"nick": "МАРТА"}, with(marta)), + http.StatusNotFound, "unknown_user") +} + +// Удаляется только своя строка: зеркальная у собеседника остаётся, +// это не блокировка (ADR-019). +func TestDeleteContact(t *testing.T) { + e := newEnv(t) + marta, m1 := e.join("marta", 1) + petya, _ := e.join("petya", 2) + + expect(t, e.do(http.MethodPost, "/api/messages", message(ulid(nowMillis(), 3), "petya"), + with(marta), withDevice(m1)), http.StatusAccepted, "") + + expect(t, e.do(http.MethodDelete, "/api/contacts/petya", nil, with(marta)), http.StatusNoContent, "") + if got := e.contacts(marta); len(got) != 0 { + t.Errorf("контакты marta: %+v", got) + } + if got := e.contacts(petya); len(got) != 1 || got[0].Nick != "marta" { + t.Errorf("контакты petya: %+v", got) + } + + // Удалять нечего — тот же ответ. + expect(t, e.do(http.MethodDelete, "/api/contacts/petya", nil, with(marta)), http.StatusNoContent, "") + expect(t, e.do(http.MethodDelete, "/api/contacts/kolya", nil, with(marta)), http.StatusNoContent, "") +} diff --git a/internal/api/devices.go b/internal/api/devices.go new file mode 100644 index 0000000..7287e75 --- /dev/null +++ b/internal/api/devices.go @@ -0,0 +1,120 @@ +package api + +import ( + "errors" + "net/http" + "time" + + "github.com/xmatic-squad/bare/internal/auth" + "github.com/xmatic-squad/bare/internal/store" +) + +// deviceID — тело и ответ POST /api/devices: идентификатор выдаёт клиент +// (ADR-017), сервер только проверяет форму и принадлежность. +type deviceID struct { + ID string `json:"id"` +} + +// POST /api/devices — регистрация устройства. Уже заведённое своё — +// 200 и обновлённый last_seen; занятое чужим — 409, клиент берёт новый id. +func (s *server) createDevice(w http.ResponseWriter, r *http.Request) { + var in deviceID + if !decode(w, r, &in) { + return + } + if !validID(in.ID) { + Invalid(w, "id", "id — не 16 байт base64url") + return + } + sess, _ := auth.From(r) + created, err := s.st.RegisterDevice(r.Context(), in.ID, sess.Nick, sess.TokenHash, time.Now().UnixMilli()) + if errors.Is(err, store.ErrDeviceTaken) { + Error(w, http.StatusConflict, "device_conflict", "такое устройство уже есть") + return + } + if err != nil { + s.internal(w, r, err) + return + } + status := http.StatusOK + if created { + status = http.StatusCreated + } + writeJSON(w, status, deviceID{in.ID}) +} + +// deviceOut — строка ответа GET /api/devices. +type deviceOut struct { + ID string `json:"id"` + CreatedAt int64 `json:"createdAt"` + LastSeen int64 `json:"lastSeen"` + HasPush bool `json:"hasPush"` + Current bool `json:"current"` +} + +// GET /api/devices — устройства аккаунта. Самой push-подписки в ответе +// нет, только факт её наличия. +func (s *server) devices(w http.ResponseWriter, r *http.Request) { + sess, _ := auth.From(r) + list, err := s.st.Devices(r.Context(), sess.Nick) + if err != nil { + s.internal(w, r, err) + return + } + out := make([]deviceOut, 0, len(list)) + for _, d := range list { + out = append(out, deviceOut{ + ID: d.ID, + CreatedAt: d.CreatedAt, + LastSeen: d.LastSeen, + HasPush: d.HasPush, + Current: d.ID == sess.DeviceID, + }) + } + writeJSON(w, http.StatusOK, out) +} + +// DELETE /api/devices/{id} — удаление устройства: очередь, подписка +// и сессии уходят каскадом, открытый поток событий закрывается. +// +// Чужое и несуществующее устройство отвечают тем же 204: удалять нечего, +// а отдельного кода на этот случай в протоколе нет (docs/protocol.md). +func (s *server) deleteDevice(w http.ResponseWriter, r *http.Request) { + sess, _ := auth.From(r) + id := r.PathValue("id") + deleted, err := s.st.DeleteDevice(r.Context(), id, sess.Nick) + if err != nil { + s.internal(w, r, err) + return + } + if deleted { + s.hub.Close(id) + } + noContent(w) +} + +// device читает X-Device и проверяет, что устройство принадлежит +// пользователю сессии. Заголовка нет, форма кривая, устройство чужое — +// всё это 403 unknown_device (docs/protocol.md, «Общие правила»). +func (s *server) device(w http.ResponseWriter, r *http.Request) (string, bool) { + sess, _ := auth.From(r) + id := r.Header.Get("X-Device") + if !validID(id) { + unknownDevice(w) + return "", false + } + owned, err := s.st.DeviceOwned(r.Context(), id, sess.Nick) + if err != nil { + s.internal(w, r, err) + return "", false + } + if !owned { + unknownDevice(w) + return "", false + } + return id, true +} + +func unknownDevice(w http.ResponseWriter) { + Error(w, http.StatusForbidden, "unknown_device", "это устройство не ваше") +} diff --git a/internal/api/devices_test.go b/internal/api/devices_test.go new file mode 100644 index 0000000..9cc7911 --- /dev/null +++ b/internal/api/devices_test.go @@ -0,0 +1,180 @@ +package api_test + +import ( + "net/http" + "testing" +) + +// deviceOf — идентификатор устройства: 16 байт base64url (docs/crypto.md). +func deviceOf(seed byte) string { return bytesOf(16, seed) } + +// join регистрирует аккаунт и его устройство, отдаёт cookie и id. +func (e *env) join(nick string, seed byte) (*http.Cookie, string) { + e.t.Helper() + c := e.signUp(nick) + return c, e.addDevice(c, deviceOf(seed)) +} + +// addDevice регистрирует устройство под уже открытой сессией. +func (e *env) addDevice(c *http.Cookie, id string) string { + e.t.Helper() + rec := e.do(http.MethodPost, "/api/devices", map[string]any{"id": id}, with(c)) + if rec.Code != http.StatusCreated && rec.Code != http.StatusOK { + e.t.Fatalf("регистрация устройства: %d (%s)", rec.Code, rec.Body.String()) + } + return id +} + +func TestDevices(t *testing.T) { + e := newEnv(t) + c := e.signUp("marta") + id := deviceOf(1) + + first := e.do(http.MethodPost, "/api/devices", map[string]any{"id": id}, with(c)) + expect(t, first, http.StatusCreated, "") + var created struct { + ID string `json:"id"` + } + decodeBody(t, first, &created) + if created.ID != id { + t.Errorf("id в ответе: получено %q, ожидалось %q", created.ID, id) + } + + // Повтор — то же устройство того же пользователя. + expect(t, e.do(http.MethodPost, "/api/devices", map[string]any{"id": id}, with(c)), http.StatusOK, "") + + rec := e.do(http.MethodGet, "/api/devices", nil, with(c)) + expect(t, rec, http.StatusOK, "") + var list []struct { + ID string `json:"id"` + CreatedAt int64 `json:"createdAt"` + LastSeen int64 `json:"lastSeen"` + HasPush bool `json:"hasPush"` + Current bool `json:"current"` + } + decodeBody(t, rec, &list) + if len(list) != 1 { + t.Fatalf("устройств: получено %d, ожидалось 1", len(list)) + } + if list[0].ID != id || list[0].CreatedAt == 0 || list[0].LastSeen == 0 || list[0].HasPush || !list[0].Current { + t.Errorf("устройство: %+v", list[0]) + } + + // Второе устройство — своя сессия, свой вход. current у каждой сессии + // своё: сессия привязана к устройству (ADR-021). + login := e.do(http.MethodPost, "/api/login", map[string]any{"nick": "marta", "authKey": bytesOf(32, 1)}) + expect(t, login, http.StatusOK, "") + second := e.cookie(login) + e.addDevice(second, deviceOf(2)) + + rec = e.do(http.MethodGet, "/api/devices", nil, with(c)) + decodeBody(t, rec, &list) + if len(list) != 2 { + t.Fatalf("устройств: получено %d, ожидалось 2", len(list)) + } + if !list[0].Current || list[1].Current { + t.Errorf("текущее устройство первой сессии: %+v", list) + } + rec = e.do(http.MethodGet, "/api/devices", nil, with(second)) + decodeBody(t, rec, &list) + if list[0].Current || !list[1].Current { + t.Errorf("текущее устройство второй сессии: %+v", list) + } + + // Push-подписка — этап 4: пути ещё нет, а неизвестный путь отвечает + // 404 not_found (ADR-026). + expect(t, e.do(http.MethodPut, "/api/devices/"+id+"/push", map[string]any{}, with(c)), + http.StatusNotFound, "not_found") +} + +// Занятый чужим идентификатор — 409: клиент берёт новый (ADR-017). +func TestDeviceConflict(t *testing.T) { + e := newEnv(t) + marta, id := e.join("marta", 1) + petya := e.signUp("petya") + + expect(t, e.do(http.MethodPost, "/api/devices", map[string]any{"id": id}, with(petya)), + http.StatusConflict, "device_conflict") + + // Устройство осталось за прежним владельцем. + rec := e.do(http.MethodGet, "/api/devices", nil, with(marta)) + var mine []struct { + ID string `json:"id"` + } + decodeBody(t, rec, &mine) + if len(mine) != 1 || mine[0].ID != id { + t.Errorf("устройства marta: %+v", mine) + } + rec = e.do(http.MethodGet, "/api/devices", nil, with(petya)) + var theirs []struct { + ID string `json:"id"` + } + decodeBody(t, rec, &theirs) + if len(theirs) != 0 { + t.Errorf("устройства petya: %+v", theirs) + } +} + +func TestDeviceIDForm(t *testing.T) { + e := newEnv(t) + c := e.signUp("marta") + + for _, id := range []any{"", "короткий", bytesOf(8, 1), bytesOf(32, 1), 42} { + rec := e.do(http.MethodPost, "/api/devices", map[string]any{"id": id}, with(c)) + if rec.Code == http.StatusCreated || rec.Code == http.StatusOK { + t.Errorf("id %v принят: %d", id, rec.Code) + } + } +} + +// X-Device чужого пользователя — 403 unknown_device на всех маршрутах, +// где устройство важно (docs/protocol.md, «Общие правила»). +func TestForeignDevice(t *testing.T) { + e := newEnv(t) + _, martaDevice := e.join("marta", 1) + petya, petyaDevice := e.join("petya", 2) + + body := message(ulid(nowMillis(), 3), "marta") + expect(t, e.do(http.MethodPost, "/api/messages", body, with(petya), withDevice(martaDevice)), + http.StatusForbidden, "unknown_device") + expect(t, e.do(http.MethodPost, "/api/ack", map[string]any{"ids": []string{}}, with(petya), withDevice(martaDevice)), + http.StatusForbidden, "unknown_device") + + // Заголовка нет вовсе или в нём мусор — тот же ответ. + expect(t, e.do(http.MethodPost, "/api/messages", body, with(petya)), + http.StatusForbidden, "unknown_device") + expect(t, e.do(http.MethodPost, "/api/messages", body, with(petya), withDevice("мусор")), + http.StatusForbidden, "unknown_device") + expect(t, e.do(http.MethodPost, "/api/messages", body, with(petya), withDevice(deviceOf(9))), + http.StatusForbidden, "unknown_device") + + // Со своим устройством — обычная отправка. + expect(t, e.do(http.MethodPost, "/api/messages", message(ulid(nowMillis(), 4), "marta"), + with(petya), withDevice(petyaDevice)), http.StatusAccepted, "") +} + +// Удаление устройства уносит очередь и сессии устройства. +func TestDeleteDevice(t *testing.T) { + e := newEnv(t) + marta, martaDevice := e.join("marta", 1) + petya, petyaDevice := e.join("petya", 2) + + expect(t, e.do(http.MethodPost, "/api/messages", message(ulid(nowMillis(), 3), "petya"), + with(marta), withDevice(martaDevice)), http.StatusAccepted, "") + if got := e.queue(petyaDevice); len(got) != 1 { + t.Fatalf("очередь petya: получено %d конвертов, ожидался 1", len(got)) + } + + // Чужое устройство удалить нельзя — и это не ошибка. + expect(t, e.do(http.MethodDelete, "/api/devices/"+petyaDevice, nil, with(marta)), http.StatusNoContent, "") + if got := e.queue(petyaDevice); len(got) != 1 { + t.Errorf("очередь petya после чужого удаления: получено %d конвертов", len(got)) + } + + expect(t, e.do(http.MethodDelete, "/api/devices/"+petyaDevice, nil, with(petya)), http.StatusNoContent, "") + if got := e.queue(petyaDevice); len(got) != 0 { + t.Errorf("очередь после удаления устройства: получено %d конвертов, ожидалось 0", len(got)) + } + // Сессия, привязанная к устройству, ушла каскадом. + expect(t, e.do(http.MethodGet, "/api/me", nil, with(petya)), http.StatusUnauthorized, "unauthenticated") +} diff --git a/internal/api/events.go b/internal/api/events.go new file mode 100644 index 0000000..42acb4f --- /dev/null +++ b/internal/api/events.go @@ -0,0 +1,118 @@ +package api + +import ( + "fmt" + "io" + "net/http" + "time" + + "github.com/xmatic-squad/bare/internal/auth" +) + +// pingEvery — период комментария-пинга: он держит соединение живым +// через прокси и показывает клиенту, что поток цел (docs/protocol.md). +const pingEvery = 20 * time.Second + +// GET /api/events?device= — поток событий устройства (ADR-004). +// Устройство передаётся в query: EventSource не умеет заголовки. +// +// Last-Event-ID игнорируется: механизм восстановления — не докрутка +// по идентификатору, а повторная выдача очереди при каждом подключении. +func (s *server) events(w http.ResponseWriter, r *http.Request) { + sess, _ := auth.From(r) + device := r.URL.Query().Get("device") + if !validID(device) { + unknownDevice(w) + return + } + owned, err := s.st.DeviceOwned(r.Context(), device, sess.Nick) + if err != nil { + s.internal(w, r, err) + return + } + if !owned { + unknownDevice(w) + return + } + + // Порядок — docs/protocol.md, «События»: сначала push_pending и + // last_seen, потом поток, потом очередь. Сорвавшаяся запись last_seen + // не должна рвать исправный поток устройства, а он был бы уже закрыт + // открытием нового. + now := time.Now().UnixMilli() + if err := s.st.TouchDevice(r.Context(), device, now); err != nil { + s.internal(w, r, err) + return + } + + // Поток открывается до чтения очереди: конверт, попавший в очередь + // между выборкой и подпиской, иначе пролежал бы там до следующего + // подключения. Обратная крайность — дубль, а его клиент сливает по id + // (ADR-017). Открытие закрывает прежний поток этого устройства. + stream := s.hub.Open(device) + defer stream.Close() + + queued, err := s.st.Queue(r.Context(), device) + if err != nil { + s.internal(w, r, err) + return + } + + head := w.Header() + head.Set("Content-Type", "text/event-stream") + head.Set("Cache-Control", "no-cache") + // nginx буферизует ответы проксируемых приложений; для потока это + // означало бы, что события копятся и не уходят (docs/deploy.md). + head.Set("X-Accel-Buffering", "no") + w.WriteHeader(http.StatusOK) + + send := sender(w) + for _, envelope := range queued { + if !send("msg", envelope) { + return + } + } + if !send("ready", "{}") { + return + } + + ping := time.NewTicker(pingEvery) + defer ping.Stop() + for { + select { + case <-r.Context().Done(): + // Клиент ушёл. + return + case <-stream.Done(): + // Поток закрыли: новое соединение того же устройства, + // удаление устройства или остановка сервера. + return + case ev := <-stream.Events(): + if !send(ev.Name, ev.Data) { + return + } + case <-ping.C: + if !write(w, ": ping\n\n") { + return + } + } + } +} + +// sender собирает функцию записи события. Данные — компактный JSON +// без переводов строки, поэтому кадр SSE собирается одной строкой data. +// Ответ false означает, что писать больше некуда: соединение оборвалось. +func sender(w http.ResponseWriter) func(name, data string) bool { + return func(name, data string) bool { + return write(w, fmt.Sprintf("event: %s\ndata: %s\n\n", name, data)) + } +} + +func write(w http.ResponseWriter, frame string) bool { + if _, err := io.WriteString(w, frame); err != nil { + return false + } + // Без Flush кадр остался бы в буфере net/http до конца ответа, + // а конца у потока нет. + return http.NewResponseController(w).Flush() == nil +} diff --git a/internal/api/events_test.go b/internal/api/events_test.go new file mode 100644 index 0000000..b08b37d --- /dev/null +++ b/internal/api/events_test.go @@ -0,0 +1,284 @@ +package api_test + +import ( + "bufio" + "context" + "io" + "net/http" + "net/url" + "strings" + "testing" + "time" +) + +// wait — сколько тест ждёт события. Всё локально, задержек быть не должно. +const wait = 2 * time.Second + +// sseEvent — одно событие потока. +type sseEvent struct { + name string + data string +} + +// stream — открытый GET /api/events. Идёт через настоящий сервер: +// httptest.ResponseRecorder не отдаёт тело, пока обработчик не вернулся. +type stream struct { + t *testing.T + ctx context.Context + cancel context.CancelFunc + events chan sseEvent + head http.Header +} + +// open подключается к потоку событий устройства. +func (e *env) open(device string, c *http.Cookie) *stream { + e.t.Helper() + srv := e.live() + ctx, cancel := context.WithCancel(context.Background()) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, + srv.URL+"/api/events?device="+url.QueryEscape(device), nil) + if err != nil { + cancel() + e.t.Fatalf("запрос: %v", err) + } + req.AddCookie(c) + resp, err := srv.Client().Do(req) + if err != nil { + cancel() + e.t.Fatalf("подключение: %v", err) + } + if resp.StatusCode != http.StatusOK { + resp.Body.Close() + cancel() + e.t.Fatalf("статус потока: получено %d, ожидалось 200", resp.StatusCode) + } + s := &stream{t: e.t, ctx: ctx, cancel: cancel, events: make(chan sseEvent, 64), head: resp.Header} + go s.read(resp.Body) + e.t.Cleanup(s.close) + return s +} + +// read разбирает кадры SSE: строки event и data, пустая строка — конец +// события, строка с двоеточия — комментарий-пинг. +func (s *stream) read(body io.ReadCloser) { + defer body.Close() + defer close(s.events) + + sc := bufio.NewScanner(body) + var ev sseEvent + for sc.Scan() { + line := sc.Text() + switch { + case line == "": + if ev.name == "" { + continue + } + select { + case s.events <- ev: + case <-s.ctx.Done(): + return + } + ev = sseEvent{} + case strings.HasPrefix(line, ":"): + case strings.HasPrefix(line, "event: "): + ev.name = strings.TrimPrefix(line, "event: ") + case strings.HasPrefix(line, "data: "): + ev.data = strings.TrimPrefix(line, "data: ") + } + } +} + +// next ждёт следующее событие. +func (s *stream) next() sseEvent { + s.t.Helper() + select { + case ev, ok := <-s.events: + if !ok { + s.t.Fatal("поток закрылся, события нет") + } + return ev + case <-time.After(wait): + s.t.Fatal("событие не пришло") + } + return sseEvent{} +} + +// untilReady собирает события до ready — то, что лежало в очереди. +func (s *stream) untilReady() []sseEvent { + s.t.Helper() + var out []sseEvent + for { + ev := s.next() + if ev.name == "ready" { + if ev.data != "{}" { + s.t.Errorf("данные ready: получено %q, ожидалось \"{}\"", ev.data) + } + return out + } + out = append(out, ev) + } +} + +// ended ждёт, что поток закроет сервер. +func (s *stream) ended() { + s.t.Helper() + select { + case ev, ok := <-s.events: + if ok { + s.t.Fatalf("вместо закрытия пришло событие %q", ev.name) + } + case <-time.After(wait): + s.t.Fatal("поток не закрылся") + } +} + +func (s *stream) close() { s.cancel() } + +// Порядок после подключения: очередь, ready, живые события +// (docs/protocol.md, «События»). +func TestEventsQueueThenReady(t *testing.T) { + e := newEnv(t) + marta, m1 := e.join("marta", 1) + petya, p1 := e.join("petya", 2) + + first := ulid(nowMillis(), 3) + second := ulid(nowMillis()+1, 4) + for _, id := range []string{first, second} { + expect(t, e.do(http.MethodPost, "/api/messages", message(id, "petya"), with(marta), withDevice(m1)), + http.StatusAccepted, "") + } + + s := e.open(p1, petya) + for header, value := range map[string]string{ + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache", + "X-Accel-Buffering": "no", + } { + if got := s.head.Get(header); got != value { + t.Errorf("%s: получено %q, ожидалось %q", header, got, value) + } + } + + queued := s.untilReady() + if len(queued) != 2 { + t.Fatalf("событий из очереди: получено %d, ожидалось 2", len(queued)) + } + for i, ev := range queued { + if ev.name != "msg" { + t.Errorf("событие %d: получено %q, ожидалось \"msg\"", i, ev.name) + } + if !strings.Contains(ev.data, `"from":"marta"`) { + t.Errorf("конверт %d: %s", i, ev.data) + } + } + if !strings.Contains(queued[0].data, first) || !strings.Contains(queued[1].data, second) { + t.Errorf("порядок очереди: %q, %q", queued[0].data, queued[1].data) + } + + // Реконнект без ACK повторяет очередь целиком: Last-Event-ID сервер + // не смотрит (docs/protocol.md, «События»). + s.close() + again := e.open(p1, petya) + if got := again.untilReady(); len(got) != 2 { + t.Fatalf("после реконнекта: получено %d событий, ожидалось 2", len(got)) + } + + // После ACK очередь пуста, остаётся только ready. + expect(t, e.do(http.MethodPost, "/api/ack", map[string]any{"ids": []string{first, second}}, + with(petya), withDevice(p1)), http.StatusNoContent, "") + again.close() + third := e.open(p1, petya) + if got := third.untilReady(); len(got) != 0 { + t.Errorf("после ack: получено %d событий, ожидалось 0", len(got)) + } +} + +// Подключённое устройство получает конверт сразу после коммита. +func TestEventsLive(t *testing.T) { + e := newEnv(t) + marta, m1 := e.join("marta", 1) + petya, p1 := e.join("petya", 2) + + s := e.open(p1, petya) + if got := s.untilReady(); len(got) != 0 { + t.Fatalf("очередь нового устройства: %+v", got) + } + + id := ulid(nowMillis(), 3) + expect(t, e.do(http.MethodPost, "/api/messages", message(id, "petya"), with(marta), withDevice(m1)), + http.StatusAccepted, "") + + ev := s.next() + if ev.name != "msg" || !strings.Contains(ev.data, id) { + t.Errorf("живое событие: %+v", ev) + } + // Живая доставка не отменяет ACK: конверт лежит в очереди до него. + if got := e.queue(p1); len(got) != 1 { + t.Errorf("очередь: получено %d конвертов, ожидался 1", len(got)) + } +} + +// Отправитель эха не получает даже живьём, другие его устройства — да. +func TestEventsNoEchoToSender(t *testing.T) { + e := newEnv(t) + marta, m1 := e.join("marta", 1) + m2 := e.addDevice(marta, deviceOf(2)) + e.join("petya", 3) + + sender := e.open(m1, marta) + sender.untilReady() + other := e.open(m2, marta) + other.untilReady() + + id := ulid(nowMillis(), 4) + expect(t, e.do(http.MethodPost, "/api/messages", message(id, "petya"), with(marta), withDevice(m1)), + http.StatusAccepted, "") + + if ev := other.next(); ev.name != "msg" || !strings.Contains(ev.data, id) { + t.Errorf("второе устройство отправителя: %+v", ev) + } + select { + case ev := <-sender.events: + t.Errorf("эхо отправившему устройству: %+v", ev) + case <-time.After(200 * time.Millisecond): + } +} + +// Одно соединение на устройство: новое закрывает предыдущее. +func TestEventsSingleConnection(t *testing.T) { + e := newEnv(t) + petya, p1 := e.join("petya", 1) + + first := e.open(p1, petya) + first.untilReady() + second := e.open(p1, petya) + second.untilReady() + + first.ended() +} + +// Удаление устройства закрывает его поток (docs/protocol.md, «Устройства»). +func TestEventsClosedOnDeviceDelete(t *testing.T) { + e := newEnv(t) + petya, p1 := e.join("petya", 1) + + s := e.open(p1, petya) + s.untilReady() + + expect(t, e.do(http.MethodDelete, "/api/devices/"+p1, nil, with(petya)), http.StatusNoContent, "") + s.ended() +} + +// Чужое устройство в query — 403 unknown_device, поток не открывается. +func TestEventsUnknownDevice(t *testing.T) { + e := newEnv(t) + _, martaDevice := e.join("marta", 1) + petya, _ := e.join("petya", 2) + + for _, device := range []string{martaDevice, deviceOf(9), "мусор", ""} { + rec := e.do(http.MethodGet, "/api/events?device="+url.QueryEscape(device), nil, with(petya)) + expect(t, rec, http.StatusForbidden, "unknown_device") + } + // Без сессии — обычный 401. + expect(t, e.do(http.MethodGet, "/api/events?device="+martaDevice, nil), http.StatusUnauthorized, "unauthenticated") +} diff --git a/internal/api/limit.go b/internal/api/limit.go new file mode 100644 index 0000000..b9eeacf --- /dev/null +++ b/internal/api/limit.go @@ -0,0 +1,91 @@ +package api + +import ( + "math" + "sync" + "time" +) + +// Лимит сообщений (ADR-021): 30 в минуту на пользователя, пакет 10. +// Остальные лимиты — этап 6. +const ( + messagesPerMinute = 30 + messagesBurst = 10 +) + +// sweepAt — с какого размера карты имеет смысл выкидывать полные вёдра. +const sweepAt = 1024 + +// buckets — token bucket в памяти сервера, по ведру на ключ (ник). +// Рестарт обнуляет лимиты: для маленького сервера это принято (ADR-021). +type buckets struct { + mu sync.Mutex + rate float64 // токенов в секунду + burst float64 + seen map[string]*bucket +} + +type bucket struct { + tokens float64 + at time.Time +} + +func newBuckets(perMinute, burst int) *buckets { + return &buckets{ + rate: float64(perMinute) / 60, + burst: float64(burst), + seen: make(map[string]*bucket), + } +} + +// take забирает токен. Второе значение — можно ли; если нет, первое — +// сколько ждать до следующего токена. +func (b *buckets) take(key string, now time.Time) (time.Duration, bool) { + b.mu.Lock() + defer b.mu.Unlock() + + e, ok := b.seen[key] + if !ok { + if len(b.seen) >= sweepAt { + b.sweep(now) + } + e = &bucket{tokens: b.burst, at: now} + b.seen[key] = e + } + e.tokens = math.Min(b.burst, e.tokens+b.refill(e.at, now)) + e.at = now + if e.tokens < 1 { + return time.Duration((1 - e.tokens) / b.rate * float64(time.Second)), false + } + e.tokens-- + return 0, true +} + +// refill — сколько токенов набежало. Время назад не идёт: часы могли +// прыгнуть, но долг за это выставлять некому. +func (b *buckets) refill(since, now time.Time) float64 { + d := now.Sub(since) + if d <= 0 { + return 0 + } + return d.Seconds() * b.rate +} + +// sweep выкидывает полные вёдра: они уже ничего не помнят. Иначе карта +// росла бы на каждый новый ник и не уменьшалась никогда. +func (b *buckets) sweep(now time.Time) { + for key, e := range b.seen { + if e.tokens+b.refill(e.at, now) >= b.burst { + delete(b.seen, key) + } + } +} + +// retryAfter — значение заголовка в секундах, не меньше одной: нулевое +// ожидание после отказа сбивало бы клиента с толку. +func retryAfter(wait time.Duration) int { + if wait < time.Second { + return 1 + } + return int(math.Ceil(wait.Seconds())) +} diff --git a/internal/api/limit_test.go b/internal/api/limit_test.go new file mode 100644 index 0000000..90366e0 --- /dev/null +++ b/internal/api/limit_test.go @@ -0,0 +1,121 @@ +package api + +import ( + "testing" + "time" +) + +// Token bucket из ADR-021: 30 в минуту, пакет 10. +func TestBuckets(t *testing.T) { + b := newBuckets(messagesPerMinute, messagesBurst) + now := time.Now() + + for i := 0; i < messagesBurst; i++ { + if _, ok := b.take("marta", now); !ok { + t.Fatalf("запрос %d из пакета отклонён", i+1) + } + } + wait, ok := b.take("marta", now) + if ok { + t.Fatal("пакет не кончился") + } + // Тридцать в минуту — токен раз в две секунды. + if wait != 2*time.Second { + t.Errorf("ожидание: получено %v, ожидалось 2s", wait) + } + if got := retryAfter(wait); got != 2 { + t.Errorf("Retry-After: получено %d, ожидалось 2", got) + } + + // Через две секунды набегает ровно один токен. + if _, ok := b.take("marta", now.Add(2*time.Second)); !ok { + t.Error("токен не набежал") + } + if _, ok := b.take("marta", now.Add(2*time.Second)); ok { + t.Error("набежало больше одного токена") + } + + // Ведро не переполняется: за час копится пакет, не тридцать в минуту. + for i := 0; i < messagesBurst; i++ { + if _, ok := b.take("marta", now.Add(time.Hour)); !ok { + t.Fatalf("запрос %d после долгой паузы отклонён", i+1) + } + } + if _, ok := b.take("marta", now.Add(time.Hour)); ok { + t.Error("ведро больше пакета") + } + + // Лимит на ключ: чужое ведро полное. + if _, ok := b.take("petya", now); !ok { + t.Error("лимит одного пользователя задел другого") + } +} + +// Часы могут прыгнуть назад; долг за это никому не выставляется. +func TestBucketsClockBack(t *testing.T) { + b := newBuckets(messagesPerMinute, messagesBurst) + now := time.Now() + + for i := 0; i < messagesBurst; i++ { + b.take("marta", now) + } + if _, ok := b.take("marta", now.Add(-time.Hour)); ok { + t.Error("время назад добавило токенов") + } +} + +// Полные вёдра выкидываются: карта не растёт на каждый ник навсегда. +func TestBucketsSweep(t *testing.T) { + b := newBuckets(messagesPerMinute, messagesBurst) + now := time.Now() + + for i := 0; i < sweepAt; i++ { + b.take(string(rune(i)), now) + } + if len(b.seen) != sweepAt { + t.Fatalf("вёдер: получено %d, ожидалось %d", len(b.seen), sweepAt) + } + // Все вёдра успели наполниться заново — чистка их и уносит. + b.take("marta", now.Add(time.Hour)) + if len(b.seen) != 1 { + t.Errorf("вёдер после чистки: получено %d, ожидалось 1", len(b.seen)) + } +} + +// Ждать меньше секунды бессмысленно: Retry-After в секундах. +func TestRetryAfter(t *testing.T) { + cases := map[time.Duration]int{ + 0: 1, + 100 * time.Millisecond: 1, + time.Second: 1, + 1500 * time.Millisecond: 2, + 2 * time.Second: 2, + } + for wait, want := range cases { + if got := retryAfter(wait); got != want { + t.Errorf("retryAfter(%v): получено %d, ожидалось %d", wait, got, want) + } + } +} + +// ULID: 26 символов Crockford base32, время в первых десяти. +func TestULIDTime(t *testing.T) { + // 01ARZ3NDEK — 2016-07-30T23:54:10.259Z. + ms, ok := ulidTime("01ARZ3NDEKTSV4RRFFQ69G5FAV") + if !ok || ms != 1469922850259 { + t.Errorf("ulidTime: получено %d, %v; ожидалось 1469922850259", ms, ok) + } + for _, id := range []string{ + "", + "01ARZ3NDEKTSV4RRFFQ69G5FA", // 25 символов + "01ARZ3NDEKTSV4RRFFQ69G5FAVX", // 27 символов + "01arz3ndektsv4rrffq69g5fav", // строчные + "01ARZ3NDEKTSV4RRFFQ69G5FAU", // U вне алфавита Crockford + "81ARZ3NDEKTSV4RRFFQ69G5FAV", // время больше 48 бит + "01ARZ3NDEKTSV4RRFFQ69G5F☺", + } { + if _, ok := ulidTime(id); ok { + t.Errorf("принят кривой ulid %q", id) + } + } +} diff --git a/internal/api/messages.go b/internal/api/messages.go new file mode 100644 index 0000000..ab56c50 --- /dev/null +++ b/internal/api/messages.go @@ -0,0 +1,201 @@ +package api + +import ( + "encoding/json" + "net/http" + "strconv" + "time" + + "github.com/xmatic-squad/bare/internal/auth" + "github.com/xmatic-squad/bare/internal/hub" + "github.com/xmatic-squad/bare/internal/store" +) + +// clockSkew — на сколько метка времени ULID вправе разойтись с часами +// сервера (ADR-017). +const clockSkew = 5 * time.Minute + +// dmKeyID — keyId личного чата: ключ выводится из ECDH, идентификатора +// у него нет (docs/crypto.md, «Сообщение»). +const dmKeyID = "dm" + +// maxAck — сколько идентификаторов принимает один ACK. +const maxAck = 500 + +// target — адресат конверта: ровно одно из двух. +type target struct { + DM string `json:"dm,omitempty"` + Room string `json:"room,omitempty"` +} + +// envelope — конверт из docs/protocol.md. Порядок полей — как в нём. +// from и ts ставит сервер: клиентские значения не читаются вовсе (ADR-017). +type envelope struct { + ID string `json:"id"` + To target `json:"to"` + From string `json:"from"` + KeyID string `json:"keyId"` + IV string `json:"iv"` + CT string `json:"ct"` + TS int64 `json:"ts"` +} + +// messageIn — тело POST /api/messages. Полей from и ts здесь нет +// намеренно: что бы клиент ни прислал, сервер ставит своё (ADR-017). +type messageIn struct { + ID string `json:"id"` + To target `json:"to"` + KeyID string `json:"keyId"` + IV string `json:"iv"` + CT string `json:"ct"` +} + +// POST /api/messages — отправка. Сервер не умеет проверять шифротекст, +// он проверяет форму и раскладывает конверт по очередям (ADR-008). +// Порядок проверок — docs/protocol.md, «Сообщения». +func (s *server) sendMessage(w http.ResponseWriter, r *http.Request) { + device, ok := s.device(w, r) + if !ok { + return + } + var in messageIn + if !decode(w, r, &in) { + return + } + ms, ok := checkForm(w, in) + if !ok { + return + } + + now := time.Now() + if d := now.Sub(time.UnixMilli(ms)); d > clockSkew || d < -clockSkew { + Error(w, http.StatusBadRequest, "clock_skew", + "проверьте часы на устройстве: расхождение больше 5 минут") + 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 { + return + } + + if wait, ok := s.msgs.take(sess.Nick, now); !ok { + s.rateLimited(w, wait) + return + } + + env := envelope{ + ID: in.ID, + To: target{DM: in.To.DM}, + From: sess.Nick, + KeyID: in.KeyID, + IV: in.IV, + CT: in.CT, + TS: now.UnixMilli(), + } + raw, err := json.Marshal(env) + if err != nil { + s.internal(w, r, err) + return + } + devices, err := s.st.DeliverDM(r.Context(), store.Delivery{ + From: env.From, + To: env.To.DM, + Exclude: device, + MsgID: env.ID, + Envelope: string(raw), + Now: env.TS, + }) + if err != nil { + s.internal(w, r, err) + return + } + // Очередь уже записана: подключённое устройство получает конверт + // сразу, остальные — при подключении. Пуши — этап 4. + for _, id := range devices { + s.hub.Send(id, hub.Event{Name: "msg", Data: string(raw)}) + } + writeJSON(w, http.StatusAccepted, struct { + ID string `json:"id"` + TS int64 `json:"ts"` + }{env.ID, env.TS}) +} + +// checkForm проверяет форму полей конверта (docs/crypto.md, «Что сервер +// проверяет») и отдаёт метку времени из ULID. Ответ об ошибке уже написан, +// если вернулось false. +func checkForm(w http.ResponseWriter, in messageIn) (int64, bool) { + ms, ok := ulidTime(in.ID) + if !ok { + Invalid(w, "id", "id — не ulid из 26 символов") + return 0, false + } + if (in.To.DM == "") == (in.To.Room == "") { + Invalid(w, "to", "to — ровно одно из dm и room") + return 0, false + } + if in.To.DM != "" { + if !validNick(in.To.DM) { + Invalid(w, "to", "ник: 2–32 символа, a–z, 0–9, _") + return 0, false + } + if in.KeyID != dmKeyID { + Invalid(w, "keyId", `keyId личного чата — "dm"`) + return 0, false + } + } else { + if !validID(in.To.Room) { + Invalid(w, "to", "room — не 16 байт base64url") + return 0, false + } + if !validID(in.KeyID) { + Invalid(w, "keyId", "keyId — не 16 байт base64url") + return 0, false + } + } + if _, ok := decodeExactly(in.IV, ivLen); !ok { + Invalid(w, "iv", "iv — не 12 байт base64url") + return 0, false + } + if ct, err := b64.DecodeString(in.CT); err != nil || len(ct) < minCTLen { + Invalid(w, "ct", "ct — не base64url или слишком короткий") + return 0, false + } + return ms, true +} + +// POST /api/ack — клиент записал сообщения в IndexedDB: из очереди +// устройства их можно убрать (ADR-008). +func (s *server) ack(w http.ResponseWriter, r *http.Request) { + device, ok := s.device(w, r) + if !ok { + return + } + var in struct { + IDs []string `json:"ids"` + } + if !decode(w, r, &in) { + return + } + if len(in.IDs) > maxAck { + Invalid(w, "ids", "не больше 500 идентификаторов") + return + } + if err := s.st.Ack(r.Context(), device, in.IDs); err != nil { + s.internal(w, r, err) + return + } + noContent(w) +} + +// rateLimited — 429 с Retry-After в секундах (ADR-021). +func (s *server) rateLimited(w http.ResponseWriter, wait time.Duration) { + w.Header().Set("Retry-After", strconv.Itoa(retryAfter(wait))) + Error(w, http.StatusTooManyRequests, "rate_limited", "слишком часто, попробуйте позже") +} diff --git a/internal/api/messages_test.go b/internal/api/messages_test.go new file mode 100644 index 0000000..1d1e721 --- /dev/null +++ b/internal/api/messages_test.go @@ -0,0 +1,377 @@ +package api_test + +import ( + "context" + "encoding/json" + "net/http" + "strconv" + "testing" + "time" +) + +// crockford — алфавит ULID (docs/crypto.md, «Идентификаторы»). +const crockford = "0123456789ABCDEFGHJKMNPQRSTVWXYZ" + +func nowMillis() int64 { return time.Now().UnixMilli() } + +// ulid собирает ULID с заданным временем: первые десять символов — +// 48 бит миллисекунд, остальные шестнадцать — 80 бит «случайности». +func ulid(ms int64, seed byte) string { + out := make([]byte, 26) + for i := 9; i >= 0; i-- { + out[i] = crockford[ms&31] + ms >>= 5 + } + for i := 10; i < 26; i++ { + out[i] = crockford[(int(seed)+i)%32] + } + return string(out) +} + +// message — тело POST /api/messages в личный чат. Шифротекст сервер +// не проверяет: ему важна только форма. +func message(id, to string) map[string]any { + return map[string]any{ + "id": id, + "to": map[string]string{"dm": to}, + "keyId": "dm", + "iv": bytesOf(12, 21), + "ct": bytesOf(48, 23), + } +} + +// queue — очередь устройства как её видит сервер. +func (e *env) queue(device string) []string { + e.t.Helper() + got, err := e.st.Queue(context.Background(), device) + if err != nil { + e.t.Fatalf("очередь %s: %v", device, err) + } + return got +} + +// envelopes разбирает конверты очереди. +func (e *env) envelopes(device string) []envelope { + e.t.Helper() + raw := e.queue(device) + out := make([]envelope, 0, len(raw)) + for _, s := range raw { + var env envelope + if err := json.Unmarshal([]byte(s), &env); err != nil { + e.t.Fatalf("разбор конверта %q: %v", s, err) + } + out = append(out, env) + } + return out +} + +// envelope — конверт в том виде, в каком его видит клиент. +type envelope struct { + ID string `json:"id"` + To struct { + DM string `json:"dm"` + Room string `json:"room"` + } `json:"to"` + From string `json:"from"` + KeyID string `json:"keyId"` + IV string `json:"iv"` + CT string `json:"ct"` + TS int64 `json:"ts"` +} + +// Конверт уходит на все устройства обоих собеседников, кроме отправившего +// (ADR-017): мультидевайс без отдельной логики. +func TestFanout(t *testing.T) { + e := newEnv(t) + marta, m1 := e.join("marta", 1) + m2 := e.addDevice(marta, deviceOf(2)) + petya, p1 := e.join("petya", 3) + p2 := e.addDevice(petya, deviceOf(4)) + + id := ulid(nowMillis(), 5) + rec := e.do(http.MethodPost, "/api/messages", message(id, "petya"), with(marta), withDevice(m1)) + expect(t, rec, http.StatusAccepted, "") + var accepted struct { + ID string `json:"id"` + TS int64 `json:"ts"` + } + decodeBody(t, rec, &accepted) + if accepted.ID != id || accepted.TS == 0 { + t.Errorf("ответ: %+v", accepted) + } + + if got := e.queue(m1); len(got) != 0 { + t.Errorf("эхо отправившему устройству: %v", got) + } + for _, device := range []string{m2, p1, p2} { + got := e.envelopes(device) + if len(got) != 1 { + t.Fatalf("очередь %s: получено %d конвертов, ожидался 1", device, len(got)) + } + env := got[0] + if env.ID != id || env.From != "marta" || env.To.DM != "petya" || env.KeyID != "dm" { + t.Errorf("конверт для %s: %+v", device, env) + } + if env.IV != bytesOf(12, 21) || env.CT != bytesOf(48, 23) { + t.Errorf("шифротекст изменился: %+v", env) + } + if env.TS != accepted.TS { + t.Errorf("ts: получено %d, ожидалось %d", env.TS, accepted.TS) + } + } +} + +// from ставит сервер из сессии; поле from в теле запроса не читается +// вовсе (ADR-017, модель угроз). +func TestFromComesFromSession(t *testing.T) { + e := newEnv(t) + marta, m1 := e.join("marta", 1) + _, p1 := e.join("petya", 2) + + body := message(ulid(nowMillis(), 3), "petya") + body["from"] = "petya" + body["ts"] = 1 + expect(t, e.do(http.MethodPost, "/api/messages", body, with(marta), withDevice(m1)), http.StatusAccepted, "") + + got := e.envelopes(p1) + if len(got) != 1 { + t.Fatalf("очередь: получено %d конвертов, ожидался 1", len(got)) + } + if got[0].From != "marta" { + t.Errorf("from: получено %q, ожидалось \"marta\"", got[0].From) + } + if got[0].TS == 1 { + t.Errorf("ts взят из тела запроса: %d", got[0].TS) + } +} + +// ACK удаляет строки очереди только своего устройства. +func TestAck(t *testing.T) { + e := newEnv(t) + marta, m1 := e.join("marta", 1) + petya, p1 := e.join("petya", 2) + p2 := e.addDevice(petya, deviceOf(3)) + + first := ulid(nowMillis(), 4) + second := ulid(nowMillis()+1, 5) + for _, id := range []string{first, second} { + expect(t, e.do(http.MethodPost, "/api/messages", message(id, "petya"), with(marta), withDevice(m1)), + http.StatusAccepted, "") + } + + ack := map[string]any{"ids": []string{first}} + expect(t, e.do(http.MethodPost, "/api/ack", ack, with(petya), withDevice(p1)), http.StatusNoContent, "") + + left := e.envelopes(p1) + if len(left) != 1 || left[0].ID != second { + t.Errorf("очередь p1 после ack: %+v", left) + } + if got := e.queue(p2); len(got) != 2 { + t.Errorf("очередь p2: получено %d конвертов, ожидалось 2", len(got)) + } + // Чужие идентификаторы и повторный ack ничего не ломают. + expect(t, e.do(http.MethodPost, "/api/ack", map[string]any{"ids": []string{first, second}}, + with(petya), withDevice(p1)), http.StatusNoContent, "") + if got := e.queue(p1); len(got) != 0 { + t.Errorf("очередь p1: получено %d конвертов, ожидалось 0", len(got)) + } + if got := e.queue(p2); len(got) != 2 { + t.Errorf("очередь p2 после ack чужого устройства: получено %d", len(got)) + } +} + +func TestAckLimit(t *testing.T) { + e := newEnv(t) + c, device := e.join("marta", 1) + + ids := make([]string, 500) + for i := range ids { + ids[i] = ulid(nowMillis(), byte(i)) + } + expect(t, e.do(http.MethodPost, "/api/ack", map[string]any{"ids": ids}, with(c), withDevice(device)), + http.StatusNoContent, "") + + rec := e.do(http.MethodPost, "/api/ack", map[string]any{"ids": append(ids, ulid(nowMillis(), 9))}, + with(c), withDevice(device)) + expect(t, rec, http.StatusBadRequest, "invalid") + var field struct { + Field string `json:"field"` + } + decodeBody(t, rec, &field) + if field.Field != "ids" { + t.Errorf("field: получено %q, ожидалось \"ids\"", field.Field) + } +} + +// Часы клиента врут в обе стороны одинаково плохо (ADR-017). +func TestClockSkew(t *testing.T) { + e := newEnv(t) + marta, m1 := e.join("marta", 1) + e.join("petya", 2) + + minute := int64(60 * 1000) + for _, shift := range []int64{-6 * minute, 6 * minute, -24 * 60 * minute, 24 * 60 * minute} { + body := message(ulid(nowMillis()+shift, 3), "petya") + expect(t, e.do(http.MethodPost, "/api/messages", body, with(marta), withDevice(m1)), + http.StatusBadRequest, "clock_skew") + } + // В пределах пяти минут — принимается. + for _, shift := range []int64{-4 * minute, 4 * minute} { + body := message(ulid(nowMillis()+shift, 4), "petya") + expect(t, e.do(http.MethodPost, "/api/messages", body, with(marta), withDevice(m1)), + http.StatusAccepted, "") + } +} + +// Строки contacts заводятся в обе стороны при первом сообщении (ADR-019): +// новое устройство видит список чатов без истории. +func TestContactsFromFirstMessage(t *testing.T) { + e := newEnv(t) + marta, m1 := e.join("marta", 1) + petya, _ := e.join("petya", 2) + + if got := e.contacts(marta); len(got) != 0 { + t.Fatalf("контакты до первого сообщения: %+v", got) + } + expect(t, e.do(http.MethodPost, "/api/messages", message(ulid(nowMillis(), 3), "petya"), + with(marta), withDevice(m1)), http.StatusAccepted, "") + + mine := e.contacts(marta) + if len(mine) != 1 || mine[0].Nick != "petya" || mine[0].CreatedAt == 0 { + t.Errorf("контакты marta: %+v", mine) + } + theirs := e.contacts(petya) + if len(theirs) != 1 || theirs[0].Nick != "marta" { + t.Errorf("контакты petya: %+v", theirs) + } + if len(theirs[0].PublicKey) == 0 { + t.Errorf("в контакте нет публичного ключа: %+v", theirs[0]) + } + + // Второе сообщение ничего не удваивает. + expect(t, e.do(http.MethodPost, "/api/messages", message(ulid(nowMillis(), 4), "petya"), + with(marta), withDevice(m1)), http.StatusAccepted, "") + if got := e.contacts(marta); len(got) != 1 { + t.Errorf("контакты marta после второго сообщения: %+v", got) + } +} + +// Повтор POST с тем же id не ломает запрос: сервер историю идентификаторов +// не хранит, склеивает клиент (ADR-017). +func TestRepeatedMessageID(t *testing.T) { + e := newEnv(t) + marta, m1 := e.join("marta", 1) + _, p1 := e.join("petya", 2) + + id := ulid(nowMillis(), 3) + for i := 0; i < 3; i++ { + expect(t, e.do(http.MethodPost, "/api/messages", message(id, "petya"), with(marta), withDevice(m1)), + http.StatusAccepted, "") + } + if got := e.queue(p1); len(got) != 1 { + t.Errorf("очередь: получено %d конвертов, ожидался 1", len(got)) + } +} + +func TestMessageRejects(t *testing.T) { + cases := []struct { + name string + change func(map[string]any) + status int + code string + field string + }{ + {"id не ulid", func(m map[string]any) { m["id"] = "не ulid" }, http.StatusBadRequest, "invalid", "id"}, + {"строчный ulid", func(m map[string]any) { + m["id"] = "01hqzz0000zzzzzzzzzzzzzzzz" + }, http.StatusBadRequest, "invalid", "id"}, + {"буква вне алфавита", func(m map[string]any) { + m["id"] = "0" + "I" + ulid(nowMillis(), 1)[2:] + }, http.StatusBadRequest, "invalid", "id"}, + {"нет адресата", func(m map[string]any) { delete(m, "to") }, http.StatusBadRequest, "invalid", "to"}, + {"оба адресата", func(m map[string]any) { + m["to"] = map[string]string{"dm": "petya", "room": bytesOf(16, 1)} + }, http.StatusBadRequest, "invalid", "to"}, + {"кривой ник", func(m map[string]any) { + m["to"] = map[string]string{"dm": "МАРТА"} + }, http.StatusBadRequest, "invalid", "to"}, + {"чужой keyId в личном чате", func(m map[string]any) { + m["keyId"] = bytesOf(16, 1) + }, http.StatusBadRequest, "invalid", "keyId"}, + {"iv не 12 байт", func(m map[string]any) { m["iv"] = bytesOf(16, 21) }, http.StatusBadRequest, "invalid", "iv"}, + {"короткий ct", func(m map[string]any) { m["ct"] = bytesOf(8, 23) }, http.StatusBadRequest, "invalid", "ct"}, + {"ct не base64url", func(m map[string]any) { m["ct"] = "!!!" }, http.StatusBadRequest, "invalid", "ct"}, + {"неизвестный ник", func(m map[string]any) { + m["to"] = map[string]string{"dm": "kolya"} + }, http.StatusNotFound, "unknown_user", ""}, + {"себе", func(m map[string]any) { + m["to"] = map[string]string{"dm": "marta"} + }, http.StatusBadRequest, "self", ""}, + // Комнаты — этап 3; членства нет ни у кого. + {"в комнату", func(m map[string]any) { + m["to"] = map[string]string{"room": bytesOf(16, 1)} + m["keyId"] = bytesOf(16, 2) + }, http.StatusForbidden, "not_member", ""}, + {"кривой roomId", func(m map[string]any) { + m["to"] = map[string]string{"room": "нет"} + }, http.StatusBadRequest, "invalid", "to"}, + {"кривой keyId комнаты", func(m map[string]any) { + m["to"] = map[string]string{"room": bytesOf(16, 1)} + m["keyId"] = "dm" + }, http.StatusBadRequest, "invalid", "keyId"}, + } + + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + e := newEnv(t) + marta, m1 := e.join("marta", 1) + _, p1 := e.join("petya", 2) + + body := message(ulid(nowMillis(), 3), "petya") + c.change(body) + rec := e.do(http.MethodPost, "/api/messages", body, with(marta), withDevice(m1)) + expect(t, rec, c.status, c.code) + if c.field != "" { + var got struct { + Field string `json:"field"` + } + decodeBody(t, rec, &got) + if got.Field != c.field { + t.Errorf("field: получено %q, ожидалось %q", got.Field, c.field) + } + } + if got := e.queue(p1); len(got) != 0 { + t.Errorf("отвергнутое сообщение попало в очередь: %v", got) + } + }) + } +} + +// Лимит сообщений — 30 в минуту на пользователя, пакет 10 (ADR-021). +func TestMessageRateLimit(t *testing.T) { + e := newEnv(t) + marta, m1 := e.join("marta", 1) + e.join("petya", 2) + + for i := 0; i < 10; i++ { + expect(t, e.do(http.MethodPost, "/api/messages", message(ulid(nowMillis(), byte(i)), "petya"), + with(marta), withDevice(m1)), http.StatusAccepted, "") + } + rec := e.do(http.MethodPost, "/api/messages", message(ulid(nowMillis(), 11), "petya"), with(marta), withDevice(m1)) + expect(t, rec, http.StatusTooManyRequests, "rate_limited") + // Токен набегает раз в две секунды: пакет кончился, ждать до двух. + after, err := strconv.Atoi(rec.Header().Get("Retry-After")) + if err != nil || after < 1 || after > 2 { + t.Errorf("Retry-After: получено %q", rec.Header().Get("Retry-After")) + } + + // Лимит на пользователе, а не на устройстве. + m2 := e.addDevice(marta, deviceOf(3)) + expect(t, e.do(http.MethodPost, "/api/messages", message(ulid(nowMillis(), 12), "petya"), + with(marta), withDevice(m2)), http.StatusTooManyRequests, "rate_limited") + + // Другому пользователю чужой лимит не мешает. + petya, p1 := e.join("kolya", 4) + expect(t, e.do(http.MethodPost, "/api/messages", message(ulid(nowMillis(), 13), "marta"), + with(petya), withDevice(p1)), http.StatusAccepted, "") +} diff --git a/internal/api/ulid.go b/internal/api/ulid.go new file mode 100644 index 0000000..14ada55 --- /dev/null +++ b/internal/api/ulid.go @@ -0,0 +1,37 @@ +package api + +import "strings" + +// ULID — 48 бит миллисекунд и 80 бит случайности, Crockford base32, +// 26 символов (docs/crypto.md, «Идентификаторы»). Сервер читает из него +// только время: расхождение с серверными часами больше пяти минут — +// clock_skew (ADR-017). +const ulidLen = 26 + +// crockford — алфавит Crockford base32: без I, L, O и U. Канонический +// ULID записывается заглавными; строчные буквы сервер не принимает — +// идентификатор входит в AAD шифротекста побайтно (docs/crypto.md). +const crockford = "0123456789ABCDEFGHJKMNPQRSTVWXYZ" + +// ulidTime разбирает ULID и отдаёт его метку времени в миллисекундах. +func ulidTime(id string) (int64, bool) { + if len(id) != ulidLen { + return 0, false + } + var ms int64 + for i := 0; i < ulidLen; i++ { + v := strings.IndexByte(crockford, id[i]) + if v < 0 { + return 0, false + } + if i < 10 { + ms = ms<<5 | int64(v) + } + } + // Первые десять символов — 50 бит, времени отведено 48: старшие два + // обязаны быть нулевыми. + if ms > 1<<48-1 { + return 0, false + } + return ms, true +} diff --git a/internal/api/valid.go b/internal/api/valid.go index 0244736..2a2e050 100644 --- a/internal/api/valid.go +++ b/internal/api/valid.go @@ -14,6 +14,7 @@ import ( // base64url, длины, версии (docs/crypto.md, «Что сервер проверяет»). const ( authKeyLen = 32 // байт + idLen = 16 // байт: deviceId, keyId, roomId ivLen = 12 // байт minCTLen = 16 // байт: короче тега AES-GCM шифротекста не бывает maxBlob = 8 << 10 // ключевой блоб, docs/protocol.md @@ -39,6 +40,13 @@ func decodeExactly(s string, n int) ([]byte, bool) { // authKey разбирает authKey клиента: base64url ровно 32 байта. func authKey(s string) ([]byte, bool) { return decodeExactly(s, authKeyLen) } +// validID — deviceId, keyId и roomId устроены одинаково: 16 случайных +// байт base64url, 22 символа (docs/crypto.md, «Идентификаторы»). +func validID(s string) bool { + _, ok := decodeExactly(s, idLen) + return ok +} + // jwkPublic — публичный ключ в том виде, в каком сервер его хранит // и отдаёт: четыре поля и ничего больше. type jwkPublic struct { diff --git a/internal/hub/hub.go b/internal/hub/hub.go new file mode 100644 index 0000000..b39f4f5 --- /dev/null +++ b/internal/hub/hub.go @@ -0,0 +1,123 @@ +// Package hub держит открытые SSE-потоки устройств (ADR-004, ADR-017). +// +// На устройство приходится один поток: новое соединение закрывает +// предыдущее. Потерянное живое событие не теряет сообщения — оно лежит +// в очереди до ACK и выдаётся заново при следующем подключении +// (docs/protocol.md, «События»). +package hub + +import "sync" + +// buffer — сколько событий поток держит, пока обработчик их не разобрал. +const buffer = 32 + +// Event — одно событие SSE: имя и готовый JSON. Данные — строка: её +// нельзя изменить после того, как она ушла в несколько потоков сразу. +type Event struct { + Name string + Data string +} + +// Hub — карта «устройство → открытый поток». Пуст, пока никто не подключён. +type Hub struct { + mu sync.Mutex + streams map[string]*Stream +} + +// New заводит пустой hub. +func New() *Hub { return &Hub{streams: make(map[string]*Stream)} } + +// Stream — поток одного устройства. Обработчик читает Events до тех пор, +// пока не закроется Done или не уйдёт клиент. +type Stream struct { + hub *Hub + device string + events chan Event + done chan struct{} + once sync.Once +} + +// Open открывает поток устройства и закрывает предыдущий, если он был: +// одно соединение на устройство (docs/protocol.md, «События»). +func (h *Hub) Open(device string) *Stream { + s := &Stream{ + hub: h, + device: device, + events: make(chan Event, buffer), + done: make(chan struct{}), + } + h.mu.Lock() + prev := h.streams[device] + h.streams[device] = s + h.mu.Unlock() + if prev != nil { + prev.stop() + } + return s +} + +// Send отдаёт событие подключённому устройству. Устройство не подключено — +// молча ничего: конверт уже лежит в его очереди. +func (h *Hub) Send(device string, ev Event) { + h.mu.Lock() + s := h.streams[device] + h.mu.Unlock() + if s == nil { + return + } + select { + case s.events <- ev: + default: + // Клиент не успевает читать. Закрываем поток: переподключение + // выдаст очередь целиком, а копить события в памяти сервера — + // не его дело (ADR-008). + h.drop(s) + } +} + +// Close закрывает поток устройства: устройство удалили (docs/protocol.md, +// «Устройства»). +func (h *Hub) Close(device string) { + h.mu.Lock() + s := h.streams[device] + delete(h.streams, device) + h.mu.Unlock() + if s != nil { + s.stop() + } +} + +// CloseAll закрывает все потоки: сервер останавливается. Без этого +// остановка ждала бы, пока клиенты уйдут сами. +func (h *Hub) CloseAll() { + h.mu.Lock() + streams := h.streams + h.streams = make(map[string]*Stream) + h.mu.Unlock() + for _, s := range streams { + s.stop() + } +} + +// drop снимает регистрацию именно этого потока и закрывает его. Если +// устройство успело подключиться заново, новый поток остаётся на месте. +func (h *Hub) drop(s *Stream) { + h.mu.Lock() + if h.streams[s.device] == s { + delete(h.streams, s.device) + } + h.mu.Unlock() + s.stop() +} + +// Events — события, пришедшие потоку. +func (s *Stream) Events() <-chan Event { return s.events } + +// Done закрывается, когда поток закрыт: новым соединением того же +// устройства, удалением устройства или остановкой сервера. +func (s *Stream) Done() <-chan struct{} { return s.done } + +// Close закрывает поток — его зовёт обработчик, когда клиент ушёл. +func (s *Stream) Close() { s.hub.drop(s) } + +func (s *Stream) stop() { s.once.Do(func() { close(s.done) }) } diff --git a/internal/hub/hub_test.go b/internal/hub/hub_test.go new file mode 100644 index 0000000..e66adc4 --- /dev/null +++ b/internal/hub/hub_test.go @@ -0,0 +1,170 @@ +package hub + +import ( + "strconv" + "sync" + "testing" + "time" +) + +const wait = 2 * time.Second + +// next ждёт событие потока. +func next(t *testing.T, s *Stream) Event { + t.Helper() + select { + case ev := <-s.Events(): + return ev + case <-time.After(wait): + t.Fatal("событие не пришло") + } + return Event{} +} + +// closed ждёт закрытия потока. +func closed(t *testing.T, s *Stream) { + t.Helper() + select { + case <-s.Done(): + case <-time.After(wait): + t.Fatal("поток не закрылся") + } +} + +func open(t *testing.T, s *Stream) { + t.Helper() + select { + case <-s.Done(): + t.Fatal("поток закрыт") + default: + } +} + +func TestSend(t *testing.T) { + h := New() + s := h.Open("device") + + h.Send("device", Event{Name: "msg", Data: `{"id":"1"}`}) + if ev := next(t, s); ev.Name != "msg" || ev.Data != `{"id":"1"}` { + t.Errorf("событие: %+v", ev) + } + + // Неподключённое устройство — молча ничего: конверт лежит в очереди. + h.Send("другое", Event{Name: "msg"}) + open(t, s) +} + +// Одно соединение на устройство: новое закрывает предыдущее. +func TestOpenClosesPrevious(t *testing.T) { + h := New() + first := h.Open("device") + second := h.Open("device") + + closed(t, first) + open(t, second) + + h.Send("device", Event{Name: "msg"}) + if ev := next(t, second); ev.Name != "msg" { + t.Errorf("событие ушло не в тот поток: %+v", ev) + } +} + +// Close закрывает поток устройства: устройство удалили. +func TestCloseDevice(t *testing.T) { + h := New() + s := h.Open("device") + + h.Close("device") + closed(t, s) + + // Второе закрытие и закрытие неизвестного устройства — не беда. + h.Close("device") + h.Close("другое") +} + +// CloseAll — остановка сервера. +func TestCloseAll(t *testing.T) { + h := New() + first := h.Open("first") + second := h.Open("second") + + h.CloseAll() + closed(t, first) + closed(t, second) +} + +// Клиент, который не читает, теряет поток, а не память сервера: +// переподключение выдаст очередь целиком. +func TestOverflowDropsStream(t *testing.T) { + h := New() + s := h.Open("device") + + for i := 0; i < buffer+1; i++ { + h.Send("device", Event{Name: "msg", Data: strconv.Itoa(i)}) + } + closed(t, s) + + // Место в карте освободилось: следующее подключение начинает с нуля. + fresh := h.Open("device") + h.Send("device", Event{Name: "msg", Data: "снова"}) + if ev := next(t, fresh); ev.Data != "снова" { + t.Errorf("событие: %+v", ev) + } +} + +// Закрытие потока обработчиком не трогает уже открытый новый. +func TestStreamCloseKeepsNewer(t *testing.T) { + h := New() + first := h.Open("device") + second := h.Open("device") + first.Close() + + h.Send("device", Event{Name: "msg"}) + if ev := next(t, second); ev.Name != "msg" { + t.Errorf("событие: %+v", ev) + } +} + +// Доставки идут из разных горутин: hub обязан это переживать. +func TestConcurrent(t *testing.T) { + h := New() + done := make(chan struct{}) + var wg sync.WaitGroup + + for i := 0; i < 4; i++ { + wg.Add(1) + go func(n int) { + defer wg.Done() + device := "device" + strconv.Itoa(n%2) + for { + select { + case <-done: + return + default: + } + h.Send(device, Event{Name: "msg"}) + h.Open(device) + } + }(i) + } + // Читатель, чтобы буфер не переполнялся мгновенно. + wg.Add(1) + go func() { + defer wg.Done() + s := h.Open("device0") + for { + select { + case <-done: + return + case <-s.Events(): + case <-s.Done(): + s = h.Open("device0") + } + } + }() + + time.Sleep(50 * time.Millisecond) + close(done) + wg.Wait() + h.CloseAll() +} diff --git a/internal/store/contacts.go b/internal/store/contacts.go new file mode 100644 index 0000000..45c3e7e --- /dev/null +++ b/internal/store/contacts.go @@ -0,0 +1,65 @@ +package store + +import ( + "context" + "fmt" +) + +// Contact — строка списка чатов: собеседник и его публичный ключ. +// Доверие к ключу — TOFU на клиенте (ADR-016). +type Contact struct { + Nick string + PublicKey string + CreatedAt int64 +} + +// Contacts — контакты пользователя в порядке появления. +func (s *Store) Contacts(ctx context.Context, nick string) ([]Contact, error) { + rows, err := s.db.QueryContext(ctx, ` + SELECT c.peer, u.public_key, c.created_at + FROM contacts c JOIN users u ON u.nick = c.peer + WHERE c.nick = ? ORDER BY c.created_at, c.peer`, nick) + if err != nil { + return nil, fmt.Errorf("store: список контактов: %w", err) + } + defer rows.Close() + + var out []Contact + for rows.Next() { + var c Contact + if err := rows.Scan(&c.Nick, &c.PublicKey, &c.CreatedAt); err != nil { + return nil, fmt.Errorf("store: список контактов: %w", err) + } + out = append(out, c) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("store: список контактов: %w", err) + } + return out, nil +} + +// AddContact заводит одну строку списка чатов. Первое значение — была ли +// она создана. Зеркальную строку собеседнику эта операция не заводит: +// обе стороны появляются только при первом сообщении (ADR-019). +func (s *Store) AddContact(ctx context.Context, nick, peer string, now int64) (bool, error) { + res, err := s.db.ExecContext(ctx, ` + INSERT INTO contacts (nick, peer, created_at) VALUES (?, ?, ?) + ON CONFLICT(nick, peer) DO NOTHING`, nick, peer, now) + if err != nil { + return false, fmt.Errorf("store: добавление контакта: %w", err) + } + n, err := res.RowsAffected() + if err != nil { + return false, fmt.Errorf("store: добавление контакта: %w", err) + } + return n > 0, nil +} + +// DeleteContact убирает чат из списка. Только своя строка: зеркальная +// у собеседника остаётся, блокировок в v1 нет (ADR-019). +func (s *Store) DeleteContact(ctx context.Context, nick, peer string) error { + if _, err := s.db.ExecContext(ctx, `DELETE FROM contacts WHERE nick = ? AND peer = ?`, nick, peer); err != nil { + return fmt.Errorf("store: удаление контакта: %w", err) + } + return nil +} diff --git a/internal/store/devices.go b/internal/store/devices.go new file mode 100644 index 0000000..5da765e --- /dev/null +++ b/internal/store/devices.go @@ -0,0 +1,129 @@ +package store + +import ( + "context" + "database/sql" + "errors" + "fmt" +) + +// ErrDeviceTaken — идентификатор устройства занят другим пользователем +// (ADR-017): клиент генерирует новый. +var ErrDeviceTaken = errors.New("store: устройство занято") + +// Device — строка devices без самой push-подписки: клиенту отдаётся +// только факт её наличия. +type Device struct { + ID string + CreatedAt int64 + LastSeen int64 + HasPush bool +} + +// RegisterDevice заводит устройство или подтверждает уже заведённое, +// обновляет last_seen и привязывает к устройству текущую сессию (ADR-021). +// Первое значение — было ли устройство создано; занятый чужим id — +// ErrDeviceTaken. +func (s *Store) RegisterDevice(ctx context.Context, id, nick string, tokenHash []byte, now int64) (bool, error) { + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return false, fmt.Errorf("store: регистрация устройства: %w", err) + } + defer tx.Rollback() + + // ON CONFLICT DO NOTHING вместо разбора кода ошибки драйвера: занятый + // id виден по нулю затронутых строк, а чей он — по следующему запросу. + res, err := tx.ExecContext(ctx, ` + INSERT INTO devices (id, nick, created_at, last_seen) VALUES (?, ?, ?, ?) + ON CONFLICT(id) DO NOTHING`, id, nick, now, now) + if err != nil { + return false, fmt.Errorf("store: регистрация устройства: %w", err) + } + n, err := res.RowsAffected() + if err != nil { + return false, fmt.Errorf("store: регистрация устройства: %w", err) + } + created := n == 1 + if !created { + var owner string + if err := tx.QueryRowContext(ctx, `SELECT nick FROM devices WHERE id = ?`, id).Scan(&owner); err != nil { + return false, fmt.Errorf("store: регистрация устройства: %w", err) + } + if owner != nick { + return false, ErrDeviceTaken + } + if _, err := tx.ExecContext(ctx, `UPDATE devices SET last_seen = ? WHERE id = ?`, now, id); err != nil { + return false, fmt.Errorf("store: регистрация устройства: %w", err) + } + } + if _, err := tx.ExecContext(ctx, `UPDATE sessions SET device_id = ? WHERE token_hash = ?`, id, tokenHash); err != nil { + return false, fmt.Errorf("store: привязка сессии к устройству: %w", err) + } + if err := tx.Commit(); err != nil { + return false, fmt.Errorf("store: регистрация устройства: %w", err) + } + return created, nil +} + +// DeviceOwned — принадлежит ли устройство этому пользователю. Чужое +// и несуществующее неразличимы: снаружи и то и другое unknown_device. +func (s *Store) DeviceOwned(ctx context.Context, id, nick string) (bool, error) { + var one int + err := s.db.QueryRowContext(ctx, `SELECT 1 FROM devices WHERE id = ? AND nick = ?`, id, 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 +} + +// Devices — устройства пользователя в порядке появления. +func (s *Store) Devices(ctx context.Context, nick string) ([]Device, error) { + rows, err := s.db.QueryContext(ctx, ` + SELECT id, created_at, last_seen, push_subscription IS NOT NULL + FROM devices WHERE nick = ? ORDER BY created_at, id`, nick) + if err != nil { + return nil, fmt.Errorf("store: список устройств: %w", err) + } + defer rows.Close() + + var out []Device + for rows.Next() { + var d Device + if err := rows.Scan(&d.ID, &d.CreatedAt, &d.LastSeen, &d.HasPush); err != nil { + return nil, fmt.Errorf("store: список устройств: %w", err) + } + out = append(out, d) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("store: список устройств: %w", err) + } + return out, nil +} + +// DeleteDevice удаляет устройство пользователя; очередь, push-подписку +// и сессии устройства уносит каскад (docs/storage.md). Первое значение — +// была ли строка: чужое устройство удалить нельзя. +func (s *Store) DeleteDevice(ctx context.Context, id, nick string) (bool, error) { + res, err := s.db.ExecContext(ctx, `DELETE FROM devices WHERE id = ? AND nick = ?`, id, nick) + if err != nil { + return false, fmt.Errorf("store: удаление устройства: %w", err) + } + n, err := res.RowsAffected() + if err != nil { + return false, fmt.Errorf("store: удаление устройства: %w", err) + } + return n > 0, nil +} + +// TouchDevice — устройство подключилось по SSE: неотработанного пуша +// больше нет (ADR-023), время последнего появления — сейчас. +func (s *Store) TouchDevice(ctx context.Context, id string, now int64) error { + if _, err := s.db.ExecContext(ctx, ` + UPDATE devices SET push_pending = 0, last_seen = ? WHERE id = ?`, now, id); err != nil { + return fmt.Errorf("store: подключение устройства: %w", err) + } + return nil +} diff --git a/internal/store/queue.go b/internal/store/queue.go new file mode 100644 index 0000000..d798abe --- /dev/null +++ b/internal/store/queue.go @@ -0,0 +1,133 @@ +package store + +import ( + "context" + "database/sql" + "fmt" + "strings" +) + +// Транзитная очередь недоставленных конвертов, по строке на устройство +// (ADR-008). Доставлено и подтверждено ACK — удалено; не забрано +// за 30 дней — удалено фоновой чисткой. + +// Queue — очередь устройства в порядке выдачи при подключении: +// created_at, msg_id (docs/protocol.md, «События»). Строки — готовые +// конверты, сервер их не разбирает. +func (s *Store) Queue(ctx context.Context, device string) ([]string, error) { + rows, err := s.db.QueryContext(ctx, ` + SELECT envelope FROM queue WHERE device_id = ? ORDER BY created_at, msg_id`, device) + if err != nil { + return nil, fmt.Errorf("store: очередь устройства: %w", err) + } + defer rows.Close() + + var out []string + for rows.Next() { + var envelope string + if err := rows.Scan(&envelope); err != nil { + return nil, fmt.Errorf("store: очередь устройства: %w", err) + } + out = append(out, envelope) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("store: очередь устройства: %w", err) + } + return out, nil +} + +// Ack удаляет из очереди устройства перечисленные сообщения: клиент +// записал их в IndexedDB (docs/storage.md). +func (s *Store) Ack(ctx context.Context, device string, ids []string) error { + if len(ids) == 0 { + return nil + } + args := make([]any, 0, len(ids)+1) + args = append(args, device) + for _, id := range ids { + args = append(args, id) + } + query := `DELETE FROM queue WHERE device_id = ? AND msg_id IN (?` + + strings.Repeat(", ?", len(ids)-1) + `)` + if _, err := s.db.ExecContext(ctx, query, args...); err != nil { + return fmt.Errorf("store: подтверждение доставки: %w", err) + } + return nil +} + +// Delivery — одна доставка: готовый конверт и всё, что нужно, чтобы +// разложить его по очередям. Envelope сервер не разбирает, поэтому id +// приходит отдельным полем. +type Delivery struct { + From string // отправитель, он же один из получателей + To string // собеседник + Exclude string // устройство отправителя: эхо ему не нужно (ADR-017) + MsgID string // id конверта, вторая половина ключа очереди + Envelope string // готовый JSON конверта + Now int64 // серверное время, оно же ts конверта +} + +// DeliverDM кладёт конверт личного чата в очередь всех устройств обоих +// собеседников, кроме отправившего, и заводит недостающие строки contacts +// в обе стороны — всё в одной транзакции (docs/protocol.md, «Сообщения»). +// Возвращает устройства, которым конверт надо отдать живьём. +// +// Повторный POST с тем же id — не ошибка: сервер историю идентификаторов +// не хранит, повтор порождает повторную доставку, а склеивает её клиент +// (ADR-017). Поэтому вставка молча пропускает уже лежащую в очереди +// строку, а список устройств от этого не зависит. +func (s *Store) DeliverDM(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() + + for _, pair := range [2][2]string{{d.From, d.To}, {d.To, d.From}} { + if _, err := tx.ExecContext(ctx, ` + INSERT INTO contacts (nick, peer, created_at) VALUES (?, ?, ?) + ON CONFLICT(nick, peer) DO NOTHING`, pair[0], pair[1], d.Now); err != nil { + return nil, fmt.Errorf("store: доставка (контакты): %w", err) + } + } + + devices, err := deviceIDs(ctx, tx, d.From, d.To, 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, ` + SELECT id FROM devices WHERE nick IN (?, ?) AND id <> ? ORDER BY id`, from, to, 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 +} diff --git a/web/app.css b/web/app.css index 855cc52..550c4a6 100644 --- a/web/app.css +++ b/web/app.css @@ -299,6 +299,56 @@ input[type="password"] { list-style: none; } +.items:last-child { + margin-bottom: 0; +} + +/* строка списка: чат или «+ новый чат» */ + +.item { + display: flex; + align-items: center; + gap: 8px; + width: 100%; + padding: 6px 8px; + border: 0; + border-radius: 0; + background: none; + color: var(--text2); + font: inherit; + font-size: 13px; + text-align: left; + cursor: pointer; +} + +.item__name { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.item .n { + margin-left: auto; + color: var(--mark); +} + +/* активный чат — инверсия (docs/identity/brief.md) */ + +.item.is-active { + background: var(--ink); + color: var(--bone); +} + +.item.is-active .n { + color: var(--bone); +} + +.item--new { + margin-bottom: 18px; + color: var(--mute); +} + .me { display: flex; align-items: center; @@ -394,6 +444,250 @@ input[type="password"] { margin-top: 14px; } +.fp-hint { + margin: 10px 0 0; + font-size: 11px; + color: var(--mute); +} + +/* чат: шапка, лента, ввод — docs/identity/screens.html */ + +.chat-title { + padding: 0; + border: 0; + border-radius: 0; + background: none; + color: var(--ink); + font: inherit; + font-size: 15px; + text-align: left; + cursor: pointer; +} + +/* лента прижата к низу: короткая переписка не висит под шапкой */ + +.feed { + flex: 1; + min-height: 0; + padding: 20px; + display: flex; + flex-direction: column; + overflow-y: auto; +} + +.grid { + margin-top: auto; + display: flex; + flex-direction: column; + font-size: 14px; + line-height: 1.5; +} + +/* мобильный: автор над группой сообщений */ + +.line { + display: flex; + flex-direction: column; + gap: 4px; + margin-top: 4px; +} + +.line.is-head { + margin-top: 14px; +} + +.grid > :first-child { + margin-top: 0; +} + +.author { + display: flex; + flex-wrap: wrap; + column-gap: 6px; + color: var(--mute); + font-size: 11px; + overflow-wrap: anywhere; +} + +.author--me { + color: var(--mark); +} + +.author .t { + color: var(--stone); +} + +.text { + min-width: 0; +} + +.text__body { + margin: 0; + white-space: pre-wrap; + overflow-wrap: anywhere; +} + +.text--pending { + color: var(--stone); +} + +.text--none { + color: var(--mute); + font-style: italic; +} + +.fail { + display: flex; + align-items: center; + flex-wrap: wrap; + /* пробел вокруг «·» держит gap: у соседних элементов строки он + схлопывается */ + gap: 0 5px; + margin: 2px 0 0; + color: var(--mark); + font-size: 12px; +} + +.link { + padding: 0; + border: 0; + border-radius: 0; + background: none; + color: inherit; + font: inherit; + text-decoration: underline; + cursor: pointer; +} + +/* разделители: дата — линией line, «новые» — линией mark */ + +.divider { + display: flex; + align-items: center; + gap: 14px; + margin: 14px 0; + color: var(--stone); + font-size: 10px; +} + +.divider::before, +.divider::after { + content: ""; + flex: 1; + height: 1px; + background: var(--line); +} + +.divider--new { + color: var(--mark); +} + +.divider--new::before, +.divider--new::after { + background: var(--mark); +} + +/* ввод: рамка 1 px ink, слева «>» цветом mark */ + +.compose { + flex: none; + padding: 0 16px 20px; +} + +.input { + display: flex; + align-items: center; + gap: 10px; + padding: 0 14px; + border: 1px solid var(--ink); + font-size: 14px; +} + +.input .p { + flex: none; + color: var(--mark); +} + +/* фокус показывает вся строка ввода: рамка у неё одна, второй рамки + вокруг поля внутри не нужно */ + +.input:focus-within { + outline: 2px solid var(--ink); + outline-offset: 2px; +} + +.input .input__field:focus-visible { + outline: none; +} + +.input .input__field { + flex: 1; + min-width: 0; + min-height: 0; + padding: 12px 0; + border: 0; + border-radius: 0; + background: none; + color: var(--ink); + font: inherit; + font-size: 14px; + line-height: 1.5; +} + +/* строка ввода перебивает общее правило для input: рамка здесь одна, + у всей строки */ + +.input textarea.input__field { + resize: none; + overflow-y: auto; + max-height: 7em; + /* растёт под текст там, где браузер это умеет; где нет — прокрутка */ + field-sizing: content; +} + +.input .input__field::placeholder { + color: var(--stone); +} + +.input__send { + flex: none; + align-self: stretch; + min-width: 44px; + padding: 0; + border: 0; + border-radius: 0; + background: none; + color: var(--ink); + font: inherit; + font-size: 14px; + cursor: pointer; +} + +.counter, +.enter { + flex: none; + margin-left: auto; + color: var(--stone); + font-size: 11px; +} + +.counter + .enter { + margin-left: 0; +} + +/* полоса над вводом: нет соединения — stone, отказ отправки — mark */ + +.bar { + margin: 0 0 10px; + font-size: 11px; + line-height: 1.5; + color: var(--stone); +} + +.bar--mark { + color: var(--mark); +} + @media (min-width: 760px) { .shell { display: grid; @@ -416,6 +710,66 @@ input[type="password"] { .body { padding: 28px 32px; } + + /* сайдбар всегда на месте: «назад» из чата некуда */ + + .back--chat { + display: none; + } + + /* сетка «автор 132 px + текст»: строка сообщения раскрывается + в две ячейки сетки, поэтому display: contents */ + + .feed { + padding: 28px 32px; + } + + .grid { + display: grid; + grid-template-columns: 132px 1fr; + column-gap: 20px; + row-gap: 6px; + line-height: 1.55; + } + + .line, + .line.is-head { + display: contents; + margin-top: 0; + } + + .author { + padding-top: 2px; + font-size: 12px; + } + + .divider { + grid-column: 1 / -1; + margin: 0; + padding: 16px 0; + font-size: 11px; + } + + .compose { + padding: 0 32px 28px; + } + + .input { + gap: 12px; + padding: 0 16px; + } + + .input .input__field { + padding: 13px 0; + } + + /* на десктопе отправляет enter — кнопка не нужна ни в чате, ни в + строке `@ник`: в рамке ввода «>» остаётся один, слева + (docs/identity/brief.md, «Компоновка») */ + + .input__send { + display: none; + } } /* мобильный: один экран за раз, цели нажатия не меньше 44 px */ @@ -430,7 +784,24 @@ input[type="password"] { } .tab, - .back { + .back, + .item, + .chat-title, + .link { min-height: 44px; } + + /* подсказку «enter — отправить» видит только десктоп: на мобильном + enter переносит строку (docs/ui.md, «Чат») */ + + .enter { + display: none; + } + + /* автор стоит над группой, поэтому пустая ячейка автора не занимает + места; на десктопе она держит колонку сетки и остаётся */ + + .author:empty { + display: none; + } } diff --git a/web/js/api.js b/web/js/api.js index f296443..16057c4 100644 --- a/web/js/api.js +++ b/web/js/api.js @@ -1,6 +1,10 @@ -// Обёртки над fetch. Форма запросов и ответов — docs/protocol.md: -// JSON в обе стороны, cookie сессии, ошибка — {error, message}. -// SSE и ACK появятся на этапе 2. +// Обёртки над fetch и поток событий. Форма запросов и ответов — +// docs/protocol.md: JSON в обе стороны, cookie сессии, ошибка — +// {error, message}. + +// MAX_ACK — сколько идентификаторов принимает один POST /api/ack +// (docs/protocol.md, «Сообщения»). +export const MAX_ACK = 500; // ApiError — ответ сервера с кодом из перечня docs/protocol.md. export class ApiError extends Error { @@ -30,6 +34,9 @@ const TEXT = { invite_required: "нужен инвайт-код", invalid_invite: "инвайт-код не подходит", rate_limited: "слишком часто, попробуйте позже", + unknown_user: "такого ника нет", + self: "нельзя писать себе", + clock_skew: "проверьте часы на устройстве: расхождение больше 5 минут", }; export function errorText(err) { @@ -53,12 +60,21 @@ export function onSessionExpired(handler) { // quiet: не звать expired() на 401 unauthenticated. Нужно ровно там, где // «сессии нет» — не конец сеанса, а ожидаемый ответ (dropSession). -async function request(method, path, body, { quiet = false } = {}) { +// device: заголовок X-Device — он обязателен там, где важно, с какого +// устройства пришёл запрос (docs/protocol.md, «Общие правила»). +async function request(method, path, body, { quiet = false, device = null } = {}) { const init = { method, credentials: "same-origin", cache: "no-store" }; + const headers = {}; if (body !== undefined) { - init.headers = { "Content-Type": "application/json" }; + headers["Content-Type"] = "application/json"; init.body = JSON.stringify(body); } + if (device) { + headers["X-Device"] = device; + } + if (Object.keys(headers).length > 0) { + init.headers = headers; + } let response; try { response = await fetch(path, init); @@ -128,3 +144,79 @@ export function password(body) { export function deleteMe(authKey) { return request("DELETE", "/api/me", { authKey }); } + +export function user(nick) { + return request("GET", `/api/users/${encodeURIComponent(nick)}`); +} + +// --- устройства -------------------------------------------------------- + +// registerDevice — 201 при создании, 200 если устройство уже наше, +// 409 device_conflict, если идентификатор занят другим (ADR-017). +export function registerDevice(id) { + return request("POST", "/api/devices", { id }); +} + +export function devices() { + return request("GET", "/api/devices"); +} + +export function removeDevice(id) { + return request("DELETE", `/api/devices/${encodeURIComponent(id)}`); +} + +// --- контакты ---------------------------------------------------------- + +export function contacts() { + return request("GET", "/api/contacts"); +} + +// addContact заводит строку списка чатов и отдаёт публичный ключ +// собеседника: 404 unknown_user, 400 self (ADR-019). +export function addContact(nick) { + return request("POST", "/api/contacts", { nick }); +} + +export function removeContact(nick) { + return request("DELETE", `/api/contacts/${encodeURIComponent(nick)}`); +} + +// --- сообщения --------------------------------------------------------- + +// sendMessage отдаёт конверт серверу; from и ts он поставит сам (ADR-017). +// Ответ — 202 {id, ts}. +export function sendMessage(device, envelope) { + return request("POST", "/api/messages", envelope, { device }); +} + +// ack подтверждает запись сообщений в IndexedDB: сервер убирает их +// из очереди устройства (docs/storage.md). Не больше MAX_ACK за раз. +export function ack(device, ids) { + return request("POST", "/api/ack", { ids }, { device }); +} + +// --- события ----------------------------------------------------------- + +// stream открывает поток событий устройства (docs/protocol.md, «События»). +// Устройство передаётся в query: EventSource не умеет заголовки. +// +// Переподключение делает браузер сам. Ответ не 200 он считает +// окончательным отказом и больше не подключается — это видно +// по readyState CLOSED и передаётся в handlers.error вторым состоянием. +// +// Отдаёт функцию закрытия потока. +export function stream(device, handlers) { + const source = new EventSource(`/api/events?device=${encodeURIComponent(device)}`); + source.addEventListener("msg", (event) => handlers.msg(parse(event.data))); + source.addEventListener("ready", () => handlers.ready()); + source.addEventListener("error", () => handlers.error(source.readyState === EventSource.CLOSED)); + return () => source.close(); +} + +function parse(data) { + try { + return JSON.parse(data); + } catch { + return null; + } +} diff --git a/web/js/crypto.js b/web/js/crypto.js index 1e92ae1..f5322b5 100644 --- a/web/js/crypto.js +++ b/web/js/crypto.js @@ -13,11 +13,21 @@ const SALT_PREFIX = "bare-v1:"; const INFO_AUTH = "bare-auth-v1"; const INFO_KEK = "bare-kek-v1"; const BLOB_AAD = "bare-blob-v1|"; +const DM_SALT = "bare-dm-v1"; +const MSG_AAD = "bare-msg-v1|"; + +// DM_KEY_ID — keyId личного чата: ключ выводится из ECDH, отдельного +// идентификатора у него нет (docs/crypto.md, «Сообщение»). +export const DM_KEY_ID = "dm"; // Длина секрета аккаунта (ADR-014) и вектора инициализации AES-GCM. export const SECRET_LEN = 32; const IV_LEN = 12; +// ID_LEN — deviceId, keyId и roomId устроены одинаково: 16 случайных +// байт base64url, 22 символа (docs/crypto.md, «Идентификаторы»). +const ID_LEN = 16; + // Границы числа итераций PBKDF2 (ADR-013, ADR-030). Число приходит от // сервера — в ответе /api/kdf, /api/config или полем iter в блобе, — а // считает по нему клиент, поэтому проверить его может только он. @@ -47,6 +57,11 @@ export function random(length) { return bytes; } +// newId — идентификатор устройства, ключа комнаты или комнаты. +export function newId() { + return b64url(random(ID_LEN)); +} + export function b64url(input) { const bytes = input instanceof Uint8Array ? input : new Uint8Array(input); let binary = ""; @@ -240,3 +255,82 @@ export async function openBlob(blob, kek, nick) { } return { priv: parsed.priv, secret }; } + +// --- чат 1:1 ----------------------------------------------------------- + +// order — ники пары по возрастанию. Сравниваются кодовые единицы, а не +// буквы языка: ник — это [a-z0-9_], и порядок обязан совпасть у обеих +// сторон побайтно (docs/crypto.md, «Чат 1:1»). +export function order(a, b) { + return a < b ? [a, b] : [b, a]; +} + +// dmLabel — метка чата для AAD сообщения: "dm:" + a + ":" + b. +// Это не ключ хранилища chats: там чат зовётся "dm:<собеседник>". +export function dmLabel(a, b) { + const [first, second] = order(a, b); + return `dm:${first}:${second}`; +} + +// dmKey выводит ключ личного чата (docs/crypto.md, «Чат 1:1»). +// Ключ симметричен для обеих сторон и всех их устройств; в IndexedDB +// не пишется — выводится заново из peers. +export async function dmKey(privateKey, peerPublicJwk, me, peer) { + const [a, b] = order(me, peer); + const publicKey = await importPublic(peerPublicJwk); + const shared = await subtle.deriveBits({ name: "ECDH", public: publicKey }, privateKey, 256); + const material = await subtle.importKey("raw", shared, "HKDF", false, ["deriveKey"]); + wipe(new Uint8Array(shared)); + return subtle.deriveKey( + { name: "HKDF", hash: "SHA-256", salt: utf8(DM_SALT), info: utf8(`${a}\0${b}`) }, + material, + { name: "AES-GCM", length: 256 }, + false, + ["encrypt", "decrypt"], + ); +} + +// --- сообщение --------------------------------------------------------- + +// messageAad привязывает открытые поля конверта к шифротексту: подмена +// любого из них ломает расшифровку (docs/crypto.md, «Сообщение»). +function messageAad({ id, chat, from, keyId }) { + return utf8(`${MSG_AAD}${id}|${chat}|${from}|${keyId}`); +} + +// sealMessage шифрует текст сообщения. plain — JSON {"t": текст}; +// ничего кроме текста внутрь не кладётся. +export async function sealMessage(key, { id, chat, from, keyId, text }) { + const iv = random(IV_LEN); + const plain = utf8(JSON.stringify({ t: text })); + const ct = await subtle.encrypt( + { name: "AES-GCM", iv, additionalData: messageAad({ id, chat, from, keyId }) }, + key, + plain, + ); + wipe(plain); + return { iv: b64url(iv), ct: b64url(ct) }; +} + +// openMessage расшифровывает конверт и отдаёт текст. Бросает при любой +// порче: не тот ключ, изменившиеся открытые поля, битый base64url. +// Для вызывающего это не фатально — сообщение сохраняется нерасшифрованным +// с кодом причины (docs/storage.md). +export async function openMessage(key, { id, chat, from, keyId, iv, ct }) { + const nonce = unb64url(iv); + if (nonce.length !== IV_LEN) { + throw new Error("iv — не 12 байт"); + } + const plain = await subtle.decrypt( + { name: "AES-GCM", iv: nonce, additionalData: messageAad({ id, chat, from, keyId }) }, + key, + unb64url(ct), + ); + const bytes = new Uint8Array(plain); + const parsed = JSON.parse(decoder.decode(bytes)); + wipe(bytes); + if (parsed === null || typeof parsed !== "object" || typeof parsed.t !== "string") { + throw new Error("в сообщении нет текста"); + } + return parsed.t; +} diff --git a/web/js/db.js b/web/js/db.js index e226d00..090acc7 100644 --- a/web/js/db.js +++ b/web/js/db.js @@ -59,6 +59,20 @@ function value(request) { }); } +// get и put — одна запись одного хранилища. Ключ у chats, messages +// и peers лежит внутри значения (keyPath), поэтому put берёт запись целиком. +async function get(name, key) { + const db = await open(); + return value(db.transaction(name, "readonly").objectStore(name).get(key)); +} + +async function put(name, record) { + const db = await open(); + const tx = db.transaction(name, "readwrite"); + tx.objectStore(name).put(record); + await done(tx); +} + // meta читает несколько ключей одной транзакцией. export async function meta(keys) { const db = await open(); @@ -81,6 +95,245 @@ export async function putMeta(entries) { await done(tx); } +// --- чаты --------------------------------------------------------------- + +// PAGE — страница ленты: 50 сообщений (docs/storage.md). +export const PAGE = 50; + +const DM = "dm:"; +const ROOM = "room:"; + +// Ключ чата — "dm:<собеседник>" или "room:" (docs/storage.md). +// Это не метка чата в AAD сообщения: там у личного чата оба ника. +export function dmChatId(peer) { + return DM + peer; +} + +export function roomChatId(roomId) { + return ROOM + roomId; +} + +// peerOf — с кем личный чат; у комнаты собеседника нет. +export function peerOf(chatId) { + return chatId.startsWith(DM) ? chatId.slice(DM.length) : null; +} + +// blankChat — пустая запись чата по её ключу. title — имя без «@» и «#»: +// сигил ставит экран. Комнате имя приходит из GET /api/rooms (этап 3), +// до этого вместо имени стоит идентификатор. +export function blankChat(id) { + const base = { id, title: "", lastId: null, lastReadId: null, unread: 0, hidden: false }; + const peer = peerOf(id); + if (peer !== null) { + return { ...base, type: "dm", title: peer, peer }; + } + const roomId = id.slice(ROOM.length); + return { ...base, type: "room", title: roomId, roomId }; +} + +// chats — список чатов в порядке docs/ui.md: по lastId по убыванию. +// Скрытые («убрать из списка») не отдаются, пока их не попросят. +export async function chats({ hidden = false } = {}) { + const db = await open(); + const store = db.transaction("chats", "readonly").objectStore("chats"); + const list = await value(store.getAll()); + return list.filter((c) => hidden || !c.hidden).sort(byLastId); +} + +function byLastId(a, b) { + if (a.lastId !== b.lastId) { + if (!a.lastId) { + return 1; + } + if (!b.lastId) { + return -1; + } + return a.lastId < b.lastId ? 1 : -1; + } + return a.id < b.id ? -1 : 1; +} + +export function chat(id) { + return get("chats", id); +} + +export function putChat(record) { + return put("chats", record); +} + +// markRead — чат прочитан: счётчик обнуляется, граница «новых» уезжает +// к последнему сообщению. Обе величины локальные, на сервер не уходят +// (docs/storage.md). +export async function markRead(chatId) { + const db = await open(); + const tx = db.transaction("chats", "readwrite"); + const store = tx.objectStore("chats"); + const record = await value(store.get(chatId)); + if (record) { + record.unread = 0; + record.lastReadId = record.lastId; + store.put(record); + } + await done(tx); + return record ?? null; +} + +// hideChat прячет чат из списка или возвращает его туда. История +// не трогается: «убрать из списка» — не удаление (ADR-019). +export async function hideChat(chatId, hidden) { + const db = await open(); + const tx = db.transaction("chats", "readwrite"); + const store = tx.objectStore("chats"); + const record = (await value(store.get(chatId))) ?? blankChat(chatId); + record.hidden = hidden; + store.put(record); + await done(tx); + return record; +} + +// --- сообщения ---------------------------------------------------------- + +export function message(id) { + return get("messages", id); +} + +// saveMessages пишет сообщения и обновляет их чаты одной транзакцией. +// ACK серверу уходит только после успешной записи (docs/storage.md), +// поэтому лента и счётчик непрочитанных не должны расходиться. +// +// remove — идентификаторы, которые надо убрать: устаревший ULID +// неотправленного сообщения меняется на свежий, и старая запись уходит +// (ADR-036). +// me — собственный ник: свои сообщения непрочитанными не считаются. +// incoming — сообщения пришли из потока событий: известный id +// игнорируется целиком, перезаписи нет (ADR-034). +// +// Отдаёт ключи затронутых чатов. +export async function saveMessages({ + messages = [], + remove = [], + me = null, + incoming = false, +} = {}) { + if (messages.length === 0 && remove.length === 0) { + return []; + } + const db = await open(); + const tx = db.transaction(["messages", "chats"], "readwrite"); + const store = tx.objectStore("messages"); + const chatStore = tx.objectStore("chats"); + + for (const id of remove) { + store.delete(id); + } + // Оба чтения — запросы этой же транзакции: она живёт, пока их ждут. + const known = await Promise.all(messages.map((m) => value(store.get(m.id)))); + const ids = [...new Set(messages.map((m) => m.chatId))]; + const records = await Promise.all(ids.map((id) => value(chatStore.get(id)))); + + const touched = new Map(); + ids.forEach((id, i) => touched.set(id, records[i] ?? blankChat(id))); + + // Повтор доставки не должен ни дублировать ленту, ни двигать счётчик: + // сервер выдаёт очередь заново при каждом подключении и вправе + // прислать конверт дважды в одной пачке (ADR-017). Дубли внутри пачки + // видны только здесь: known собран до первого put. + const seen = new Set(); + messages.forEach((m, i) => { + const twice = seen.has(m.id); + seen.add(m.id); + // Входящее с уже известным id игнорируется целиком: id открыт + // в конверте, и перезапись отдала бы собеседнику чужую запись + // в истории (ADR-034). Исходящее по своему id пишется всегда — + // это переход pending → sent/failed. + if (twice || (incoming && known[i] !== undefined)) { + return; + } + store.put(m); + const record = touched.get(m.chatId); + if (!record.lastId || record.lastId < m.id) { + record.lastId = m.id; + } + if (known[i] !== undefined) { + return; + } + if (m.from !== me && (!record.lastReadId || record.lastReadId < m.id)) { + record.unread += 1; + } + // Новое сообщение возвращает скрытый чат в список. + record.hidden = false; + }); + + for (const record of touched.values()) { + chatStore.put(record); + } + await done(tx); + return [...touched.keys()]; +} + +// messagesBefore — страница ленты назад от before, не включая его, +// по индексу "chat" (docs/storage.md). Отдаёт по возрастанию id. +export async function messagesBefore(chatId, before = null, limit = PAGE) { + const db = await open(); + const store = db.transaction("messages", "readonly").objectStore("messages"); + // Ключ индекса — [chatId, id]. Массив больше любой строки, поэтому + // [chatId, []] — верхняя граница всех сообщений чата, а [chatId] — + // нижняя: короткий массив идёт раньше своих продолжений. + const range = before + ? IDBKeyRange.bound([chatId], [chatId, before], false, true) + : IDBKeyRange.bound([chatId], [chatId, []]); + const out = []; + await cursor(store.index("chat").openCursor(range, "prev"), (record) => { + out.push(record); + return out.length < limit; + }); + out.reverse(); + return out; +} + +// pendingMessages — неотправленное по возрастанию id. Индекса по статусу +// в схеме нет (docs/storage.md), поэтому это проход курсором: он делается +// один раз при старте, дальше отправитель ведёт свой список. +export async function pendingMessages() { + const db = await open(); + const store = db.transaction("messages", "readonly").objectStore("messages"); + const out = []; + await cursor(store.openCursor(), (record) => { + if (record.status === "pending") { + out.push(record); + } + return true; + }); + return out; +} + +// cursor обходит курсор, пока step не скажет «хватит». +function cursor(request, step) { + return new Promise((resolve, reject) => { + request.onsuccess = () => { + const current = request.result; + if (!current || !step(current.value)) { + resolve(); + return; + } + current.continue(); + }; + request.onerror = () => reject(request.error); + }); +} + +// --- собеседники -------------------------------------------------------- + +// peers — доверие к ключам, TOFU (ADR-016). Запись заводится при первом +// получении ключа; сверка изменившегося ключа и pending — этап 3. +export function peer(nick) { + return get("peers", nick); +} + +export function putPeer(record) { + return put("peers", record); +} + // persist просит браузер не вычищать базу: история на устройстве — // единственная копия (docs/storage.md). export async function persist() { diff --git a/web/js/main.js b/web/js/main.js index 49cacf8..9a38e18 100644 --- a/web/js/main.js +++ b/web/js/main.js @@ -6,6 +6,7 @@ import * as api from "./api.js"; import * as db from "./db.js"; +import * as sync from "./sync.js"; import { deriveAccountKeys, exportPrivateJwk, @@ -21,8 +22,11 @@ import { validIterations, wipe, } from "./crypto.js"; -import { clear } from "./ui/dom.js"; +import { DESKTOP, clear, wide } from "./ui/dom.js"; import { renderAuth } from "./ui/auth.js"; +import { renderChat } from "./ui/chat.js"; +import { renderContact } from "./ui/contact.js"; +import { renderNew } from "./ui/new.js"; import { renderSettings } from "./ui/settings.js"; import { frame } from "./ui/shell.js"; @@ -32,7 +36,7 @@ const MIN_PASSWORD = 12; // Ник — ADR-019. Клиент проверяет ту же форму, что и сервер. const NICK = /^[a-z0-9_]{2,32}$/; -const state = { config: null, me: null }; +const state = { config: null, me: null, dispose: null, paint: 0, shown: null }; // AccountError — то, что случилось с ключевым материалом, а не с сетью. // Сообщение уже пригодно для показа человеку (ADR-028). @@ -65,22 +69,83 @@ const ctx = { // --- роутинг ----------------------------------------------------------- -// render рисует экран под текущий hash. Маршруты — docs/ui.md, «Каркас»; -// на этом этапе есть только список и настройки, остальные ведут в пустой -// список: чатов, контактов и комнат ещё нет. -function render() { +// route разбирает hash. Маршруты — docs/ui.md, «Каркас»; комнаты придут +// на этапе 3, до тех пор `#/room/…` — неизвестный путь и ведёт в список. +const NICK_ROUTE = /^#\/(dm|contact)\/([a-z0-9_]{2,32})$/; + +function route() { + const hash = location.hash || "#/"; + if (hash === "#/settings") { + return { kind: "settings" }; + } + if (hash === "#/new") { + return { kind: "new" }; + } + const nick = NICK_ROUTE.exec(hash); + if (nick) { + return { kind: nick[1], nick: nick[2] }; + } + return { kind: "root" }; +} + +// render рисует экран под текущий hash. Перерисовка гасит подписки +// прежнего экрана: список чатов и лента слушают sync. +async function render() { + const mine = ++state.paint; const app = document.getElementById("app"); - clear(app); if (!state.me) { + release(); + clear(app); renderAuth(app, ctx); return; } - const settings = (location.hash || "#/") === "#/settings"; - const { root, main } = frame(ctx, settings ? "screen" : "list"); - if (settings) { - renderSettings(main, ctx); + const where = route(); + // На десктопе `#/` показывает первый чат — тот, что вверху списка. + let chatId = where.kind === "dm" ? sync.dmChatId(where.nick) : null; + if (where.kind === "root" && wide()) { + const list = await sync.chats().catch(() => []); + if (mine !== state.paint) { + return; + } + chatId = list.length > 0 ? list[0].id : null; } + + release(); + clear(app); + state.shown = chatId; + const { root, main, dispose } = frame(ctx, where.kind === "root" ? "list" : "screen", chatId); + // Сначала в документ, потом содержимое: экраны ставят фокус и мотают + // ленту, а на неприсоединённом узле это не работает. app.append(root); + const parts = [dispose]; + if (where.kind === "settings") { + renderSettings(main, ctx); + } else if (where.kind === "new") { + renderNew(main, ctx); + } else if (where.kind === "contact") { + renderContact(main, ctx, where.nick); + } else if (chatId !== null) { + parts.push(renderChat(main, ctx, chatId)); + } + state.dispose = () => parts.forEach((off) => off()); +} + +function release() { + if (state.dispose) { + state.dispose(); + state.dispose = null; + } + state.shown = null; +} + +// Первый чат на десктопе показывается и тогда, когда список приехал позже +// экрана: после входа на новом устройстве чаты приходят с контактами, уже +// после первой отрисовки. Открытый чат при этом не трогаем — иначе новое +// сообщение в соседнем чате уводило бы из текущего. +function fill() { + if (state.me && state.shown === null && route().kind === "root" && wide()) { + render(); + } } function go(hash) { @@ -234,6 +299,13 @@ async function adopt(nick, priv, secret) { accountSecret: await importSecret(secret), }); state.me = { nick, publicKey, fingerprint }; + connect(); +} + +// connect поднимает поток событий и синхронизацию. Отказы разбирает сам +// sync: экран входа их уже не касается. +function connect() { + sync.start().catch(() => {}); } // raise — автоматическое повышение итераций сразу после входа, молча @@ -303,6 +375,7 @@ async function signOut() { // forget уносит историю: она на этом устройстве единственная копия // (docs/storage.md, docs/ui.md). async function forget() { + sync.stop(); await db.destroy(); state.me = null; } @@ -318,6 +391,25 @@ function errorText(err) { async function boot() { db.persist(); + // Обработчик ставится раньше первого запроса: 401 unauthenticated + // на любом из них — на экран входа, IndexedDB цела. + api.onSessionExpired(() => { + if (state.me) { + sync.stop(); + state.me = null; + render(); + } + }); + addEventListener("hashchange", render); + // Перелом ширины меняет только выбор маршрута: на десктопе `#/` — первый + // чат, на мобильном — список. Открытый экран не трогаем: в нём набранный + // текст, а всё остальное разбирает CSS. + matchMedia(DESKTOP).addEventListener("change", () => { + if (route().kind === "root") { + render(); + } + }); + sync.on("chats", fill); try { await ensureConfig(); } catch { @@ -325,14 +417,9 @@ async function boot() { } state.me = await restore(); render(); - addEventListener("hashchange", render); - // 401 unauthenticated на любом запросе — на экран входа, IndexedDB цела. - api.onSessionExpired(() => { - if (state.me) { - state.me = null; - render(); - } - }); + if (state.me) { + connect(); + } } boot(); diff --git a/web/js/sync.js b/web/js/sync.js new file mode 100644 index 0000000..a78656f --- /dev/null +++ b/web/js/sync.js @@ -0,0 +1,911 @@ +// Транспорт и данные чата: устройство, поток событий, приём и отправка. +// Экраны берут отсюда данные и сюда же отдают действия; в db.js и api.js +// они не ходят — пишет в базу только этот модуль. +// +// Правила — docs/protocol.md («События», «Сообщения») и docs/storage.md: +// ACK уходит только после успешной записи в IndexedDB, исходящее живёт +// в pending до 202 и держится за свой ULID, пока время в нём годится +// серверу; отвергнутый по часам переиспользованный id меняется на свежий +// один раз (ADR-036). + +import * as api from "./api.js"; +import { ApiError, NetworkError } from "./api.js"; +import * as db from "./db.js"; +import { + DM_KEY_ID, + dmKey, + dmLabel, + fingerprintOf, + newId, + openMessage, + sealMessage, +} from "./crypto.js"; +import { ulid, ulidTime, validUlid } from "./ulid.js"; + +// Пауза перед восстановлением закрытого потока: удваивается, пока +// не упрётся в предел. Живой ready сбрасывает её обратно. +const RETRY_MIN = 1000; +const RETRY_MAX = 30000; + +// Владение потоком одно на браузерный профиль: устройство у вкладок общее, +// а соединение на устройство сервер держит одно (ADR-035). +const STREAM_LOCK = "bare-stream"; +const CHANNEL = "bare"; + +// Оба API нужны вместе: замок выбирает владельца потока, канал раздаёт +// его находки остальным вкладкам. Нет хотя бы одного — работаем как +// одна вкладка (ADR-035). +const shared = typeof BroadcastChannel === "function" && !!navigator.locks; + +const state = { + running: false, + nick: null, + privateKey: null, + device: null, + close: null, // закрыть поток событий + online: false, + timer: null, + wait: RETRY_MIN, + // Владение потоком: release отпускает замок, claim отменяет ожидание. + release: null, + claim: null, + channel: null, + // Ключи личных чатов — только в памяти: в IndexedDB они не пишутся, + // а выводятся заново из peers (docs/crypto.md, «Чат 1:1»). + keys: new Map(), + // Неотправленное. Полный проход по messages делается один раз при + // старте: индекса по статусу в схеме нет (docs/storage.md). + pending: new Set(), + // Конверты, пришедшие по SSE и ещё не разобранные. + inbox: [], + scheduled: false, + // Отложенный разбор конвертов, которые сейчас не разобрать. + inboxTimer: null, + hold: RETRY_MIN, +}; + +// --- события для экранов ----------------------------------------------- + +const bus = new EventTarget(); + +// on подписывает обработчик и отдаёт функцию отписки. События три: +// +// "net" {online} — доходят ли запросы до сервера +// "chats" {} — список чатов изменился +// "messages" {chatId, ids, removed} — в чате появились, изменились +// или исчезли сообщения +// +// removed непуст, только когда повтор отправки выдал сообщению новый +// ULID: старую запись из ленты надо убрать. Обычный повтор идёт с прежним +// идентификатором, removed пуст, и лента не перерисовывается (ADR-036). +export function on(type, handler) { + const wrapped = (event) => handler(event.detail); + bus.addEventListener(type, wrapped); + return () => bus.removeEventListener(type, wrapped); +} + +function emit(type, detail = {}) { + bus.dispatchEvent(new CustomEvent(type, { detail })); +} + +// notify рассылает изменения: экрану чата нужна лента, сайдбару — список. +// Те же изменения уходят соседним вкладкам: поток событий у профиля один, +// а база общая (ADR-035). +function notify(messages, removed = []) { + const byChat = new Map(); + const slot = (chatId) => { + if (!byChat.has(chatId)) { + byChat.set(chatId, { chatId, ids: [], removed: [] }); + } + return byChat.get(chatId); + }; + for (const m of messages) { + slot(m.chatId).ids.push(m.id); + } + for (const r of removed) { + slot(r.chatId).removed.push(r.id); + } + const details = [...byChat.values()]; + for (const detail of details) { + emit("messages", detail); + } + emit("chats"); + share({ + kind: "changed", + details, + // Отправленное и похороненное повтора больше не ждёт. О том, что + // осталось pending, соседям говорит settle: пока попытка идёт, + // повтор из соседней вкладки отправил бы то же сообщение второй + // раз (ADR-035). + settled: messages.filter((m) => m.status !== "pending").map((m) => m.id) + .concat(removed.map((r) => r.id)), + }); +} + +// announceChats — список чатов изменился без сообщений: прочитан чат, +// заведён или скрыт собеседник. +function announceChats() { + emit("chats"); + share({ kind: "chats" }); +} + +// --- соседние вкладки --------------------------------------------------- + +// share отдаёт изменение соседним вкладкам. Канал открыт, только пока +// синхронизация жива: после выхода база стирается, рассылать нечего. +function share(payload) { + state.channel?.postMessage(payload); +} + +function openChannel() { + if (!shared || state.channel) { + return; + } + state.channel = new BroadcastChannel(CHANNEL); + state.channel.addEventListener("message", (event) => receive(event.data)); + // Вкладка, открытая позже владельца, состояния сети ещё не знает. + share({ kind: "hello" }); +} + +function closeChannel() { + state.channel?.close(); + state.channel = null; +} + +// receive применяет чужое изменение: в базу оно уже записано той вкладкой, +// здесь остаётся поднять экраны. Рассылать это дальше нельзя — иначе +// сообщение ходило бы по кругу. +function receive(data) { + if (!state.running || data === null || typeof data !== "object") { + return; + } + switch (data.kind) { + case "hello": + // Отвечает владелец: только он знает, цел ли поток. + if (state.release) { + share({ kind: "net", online: state.online }); + } + return; + case "net": + applyOnline(data.online === true); + return; + case "chats": + emit("chats"); + return; + case "pending": + // Соседняя вкладка не отправила сообщение и повторять его не будет: + // повторяет владелец потока. + for (const id of data.ids ?? []) { + state.pending.add(id); + } + return; + case "changed": + for (const id of data.settled ?? []) { + state.pending.delete(id); + } + for (const detail of data.details ?? []) { + emit("messages", detail); + } + emit("chats"); + return; + default: + } +} + +// --- жизненный цикл ----------------------------------------------------- + +// start поднимает синхронизацию после входа или восстановления сессии. +// Ключи берутся из IndexedDB: наружу они не выходят. +export async function start() { + if (state.running) { + return; + } + let meta; + try { + meta = await db.meta(["nick", "privateKey"]); + } catch { + return; + } + if (!meta.nick || !meta.privateKey) { + return; + } + state.running = true; + state.nick = meta.nick; + state.privateKey = meta.privateKey; + openChannel(); + try { + for (const m of await db.pendingMessages()) { + state.pending.add(m.id); + } + } catch { + // Не прочли — повторим при следующем запуске; отправка не сломана. + } + await connect(); +} + +// stop гасит синхронизацию: выход, удаление аккаунта, истёкшая сессия. +// Базу не трогает — это дело main.js. +export function stop() { + state.running = false; + clearTimer(); + if (state.inboxTimer !== null) { + clearTimeout(state.inboxTimer); + state.inboxTimer = null; + } + if (state.close) { + state.close(); + state.close = null; + } + // Замок отпускается раньше, чем гаснет всё остальное: соседняя вкладка + // ждёт очереди и займёт поток сразу (ADR-035). + yieldStream(); + closeChannel(); + state.nick = null; + state.privateKey = null; + state.device = null; + state.keys.clear(); + state.pending.clear(); + state.inbox.length = 0; + state.wait = RETRY_MIN; + state.hold = RETRY_MIN; + setOnline(false); +} + +export function online() { + return state.online; +} + +export function nick() { + return state.nick; +} + +export function deviceId() { + return state.device; +} + +// --- устройство --------------------------------------------------------- + +// ensureDevice — deviceId устройства: 16 случайных байт base64url, +// заводится при первом входе и живёт в IndexedDB (ADR-017). +// 409 device_conflict означает, что идентификатор занят другим аккаунтом: +// берём новый. +async function ensureDevice() { + let id = (await db.meta(["deviceId"])).deviceId ?? null; + for (let attempt = 0; attempt < 3; attempt += 1) { + if (!id) { + id = newId(); + await db.putMeta({ deviceId: id }); + } + try { + await api.registerDevice(id); + return id; + } catch (err) { + if (err instanceof ApiError && err.code === "device_conflict") { + id = null; + continue; + } + throw err; + } + } + throw new Error("не удалось завести устройство"); +} + +// --- поток событий ------------------------------------------------------ + +async function connect() { + if (!state.running) { + return; + } + clearTimer(); + try { + state.device = await ensureDevice(); + } catch (err) { + // 401 unauthenticated уже увёл на экран входа и остановил нас. + if (err instanceof NetworkError) { + // Запрос не дошёл — это и есть «нет соединения» (ADR-028). + setOnline(false); + } + if (transient(err)) { + retryLater(); + } + return; + } + if (state.release) { + // Поток уже наш: переподключение идёт под тем же замком. + openStream(); + return; + } + claimStream(); +} + +// claimStream берёт владение потоком. Устройство у вкладок одного профиля +// общее (ADR-017), а соединение на устройство сервер держит одно: без +// арбитража вкладки бесконечно отбирали бы поток друг у друга. Замок +// держится, пока жива синхронизация; ожидающие вкладки живут на +// broadcast от владельца (ADR-035). +function claimStream() { + if (state.claim) { + return; + } + if (!shared) { + openStream(); + return; + } + const claim = new AbortController(); + state.claim = claim; + navigator.locks.request(STREAM_LOCK, { signal: claim.signal }, () => new Promise((release) => { + state.claim = null; + if (!state.running) { + release(); + return; + } + state.release = release; + openStream(); + })).catch(() => { + // Ожидание отменено выходом или замок не дался — потока у нас нет. + if (state.claim === claim) { + state.claim = null; + } + }); +} + +// yieldStream отпускает владение: соседняя вкладка займёт поток сразу. +function yieldStream() { + if (state.claim) { + state.claim.abort(); + state.claim = null; + } + if (state.release) { + state.release(); + state.release = null; + } +} + +function openStream() { + if (state.close) { + state.close(); + } + state.close = api.stream(state.device, { + msg: (envelope) => { + if (envelope) { + state.inbox.push(envelope); + schedule(); + } + }, + ready: () => { + state.wait = RETRY_MIN; + setOnline(true); + serial(afterReady); + }, + error: (closed) => { + setOnline(false); + // Браузер переподключается сам, пока поток не закрыт насовсем. + if (closed) { + retryLater(); + } + }, + }); +} + +function retryLater() { + if (state.timer !== null || !state.running) { + return; + } + const delay = state.wait; + state.wait = Math.min(delay * 2, RETRY_MAX); + state.timer = setTimeout(() => { + state.timer = null; + recover(); + }, delay); +} + +function clearTimer() { + if (state.timer !== null) { + clearTimeout(state.timer); + state.timer = null; + } +} + +// recover разбирает окончательно закрытый поток. Причин две: сессии +// больше нет — это увидит GET /api/me и уведёт на экран входа; или +// устройства больше нет — тогда его надо завести заново. +async function recover() { + if (!state.running) { + return; + } + try { + await api.me(); + } catch (err) { + if (err instanceof NetworkError) { + retryLater(); + } + return; + } + await connect(); +} + +// setOnline — состояние сети этой вкладки. Владелец потока рассказывает +// о нём соседям: своего потока у них нет (ADR-035). +function setOnline(value) { + if (state.online === value) { + return; + } + applyOnline(value); + if (state.release) { + share({ kind: "net", online: value }); + } +} + +function applyOnline(value) { + if (state.online === value) { + return; + } + state.online = value; + emit("net", { online: value }); +} + +// --- очередь работ ------------------------------------------------------ + +// serial выстраивает работу с базой в очередь: приём, отправка и повтор +// не должны идти одновременно. +let chain = Promise.resolve(); + +function serial(task) { + const next = chain.then(() => task()); + chain = next.catch(() => {}); + return next; +} + +// schedule откладывает разбор входящих на следующий такт: очередь при +// подключении приходит событием на конверт, а записать её и подтвердить +// лучше пачкой. Разбор забирает всё, что успело накопиться. +function schedule() { + if (state.scheduled) { + return; + } + state.scheduled = true; + setTimeout(() => { + state.scheduled = false; + serial(flush); + }, 0); +} + +// --- приём -------------------------------------------------------------- + +async function flush() { + const batch = state.inbox.splice(0, state.inbox.length); + if (batch.length === 0) { + return; + } + const messages = []; + const acked = []; + const kept = []; + for (const envelope of batch) { + if (!usable(envelope)) { + // Разобрать нечего, но и держать это в очереди сервера незачем. + if (typeof envelope?.id === "string") { + acked.push(envelope.id); + } + continue; + } + const record = await decode(envelope); + if (record === null) { + // Ключа сейчас не добыть по причине, которая пройдёт: конверт + // остаётся у нас и разбирается заново. Ждать переподключения + // нельзя — поток цел и рваться не собирается. + kept.push(envelope); + continue; + } + messages.push(record); + acked.push(record.id); + } + if (messages.length > 0) { + await db.saveMessages({ messages, me: state.nick, incoming: true }); + notify(messages); + } + if (kept.length > 0) { + state.inbox.unshift(...kept); + postpone(); + } else { + state.hold = RETRY_MIN; + } + // ACK — только после успешной записи (docs/storage.md). + await ackAll(acked); +} + +// postpone откладывает повторный разбор: причина, по которой конверт не +// разобрался, проходит сама, но сообщать о себе не умеет. Пауза +// удваивается, удачный разбор возвращает её к минимуму. +function postpone() { + if (state.inboxTimer !== null || !state.running) { + return; + } + const delay = state.hold; + state.hold = Math.min(delay * 2, RETRY_MAX); + state.inboxTimer = setTimeout(() => { + state.inboxTimer = null; + schedule(); + }, delay); +} + +// usable — форма конверта (docs/protocol.md, «Типы»). Сервер её проверяет, +// но запись в базу собирается из этих полей, и мусор до неё не доходит. +function usable(e) { + return e !== null && typeof e === "object" + && typeof e.id === "string" && validUlid(e.id) + && typeof e.from === "string" + && typeof e.keyId === "string" + && typeof e.iv === "string" && typeof e.ct === "string" + && Number.isFinite(e.ts) + && (typeof e.to?.dm === "string") !== (typeof e.to?.room === "string"); +} + +// decode превращает конверт в запись messages. null означает «сейчас +// не разобрать по причине, которая пройдёт»: конверт остаётся и у нас, +// и в очереди сервера — ACK по нему не уходит. Ошибка AEAD +// и неизвестный keyId причиной не являются — +// сообщение сохраняется нерасшифрованным (docs/crypto.md, «Сообщение»). +async function decode(envelope) { + const me = state.nick; + const peer = envelope.to.dm + ? (envelope.from === me ? envelope.to.dm : envelope.from) + : null; + const base = { + id: envelope.id, + chatId: peer === null ? db.roomChatId(envelope.to.room) : db.dmChatId(peer), + from: envelope.from, + text: null, + ts: envelope.ts, + status: "sent", + }; + // Комнаты — этап 3: ключа комнаты на устройстве ещё нет. + if (peer === null || envelope.keyId !== DM_KEY_ID) { + return { ...base, undecryptable: "unknown_key", raw: envelope }; + } + + let key; + try { + key = await chatKey(peer); + } catch (err) { + if (transient(err)) { + return null; + } + // Ник исчез: публичного ключа не будет и позже, но raw остаётся. + return { ...base, undecryptable: "unknown_key", raw: envelope }; + } + try { + const text = await openMessage(key, { ...envelope, chat: dmLabel(me, peer) }); + return { ...base, text }; + } catch { + // Смену ключа собеседника разбирает TOFU (ADR-016) — этап 3; + // до тех пор любая неудача AEAD выглядит одинаково. + return { ...base, undecryptable: "bad_aead", raw: envelope }; + } +} + +async function ackAll(ids) { + for (let i = 0; i < ids.length; i += api.MAX_ACK) { + try { + await api.ack(state.device, ids.slice(i, i + api.MAX_ACK)); + } catch { + // Не подтвердили — сервер выдаст конверты заново, а put по тому же + // id дублей не создаст (ADR-017). + return; + } + } +} + +// --- после ready -------------------------------------------------------- + +// afterReady — очередь выдана целиком. Клиент перечитывает контакты +// и повторяет неотправленное (docs/ui.md, «Сеть и состояния»). +// Комнаты — этап 3. +async function afterReady() { + await refreshContacts(); + await retryPending(); +} + +async function refreshContacts() { + let list; + try { + list = await api.contacts(); + } catch { + return; + } + let changed = false; + for (const contact of list) { + await rememberPeer(contact.nick, contact.publicKey, contact.createdAt); + const chatId = db.dmChatId(contact.nick); + if (!(await db.chat(chatId))) { + await db.putChat(db.blankChat(chatId)); + changed = true; + } + } + if (changed) { + announceChats(); + } +} + +// retryPending повторяет неотправленное после подключения. Идёт прямо, +// без serial: afterReady уже внутри очереди. +async function retryPending() { + for (const id of [...state.pending]) { + let record; + try { + record = await db.message(id); + } catch { + return; + } + if (!record || record.status !== "pending") { + state.pending.delete(id); + continue; + } + await attempt(record, record.id); + } +} + +// --- собеседники -------------------------------------------------------- + +// chatKey — ключ личного чата из памяти или выведенный заново. +async function chatKey(peer) { + const cached = state.keys.get(peer); + if (cached) { + return cached; + } + const record = await knownPeer(peer); + const key = await dmKey(state.privateKey, record.publicKey, state.nick, peer); + state.keys.set(peer, key); + return key; +} + +// knownPeer — запись TOFU. Ключа нет — берём у сервера и запоминаем +// как есть: сверка изменившегося ключа — этап 3 (ADR-016). +async function knownPeer(nick) { + const known = await db.peer(nick); + if (known) { + return known; + } + const user = await api.user(nick); + return rememberPeer(user.nick, user.publicKey); +} + +// rememberPeer запоминает ключ при первом контакте. Уже знакомый ник +// не трогается: смена ключа — состояние, а не перезапись (ADR-016). +async function rememberPeer(nick, publicKey, firstSeen = Date.now()) { + const known = await db.peer(nick); + if (known) { + return known; + } + const record = { + nick, + publicKey, + fingerprint: await fingerprintOf(publicKey), + firstSeen, + pending: null, + }; + await db.putPeer(record); + return record; +} + +// --- отправка ----------------------------------------------------------- + +// send — новое исходящее сообщение. Пустая строка не отправляется; +// предел в maxMessageChars держит строка ввода (docs/ui.md, «Чат»). +// Отдаёт id записи или null, если отправлять нечего. +export function send(chatId, text) { + const body = String(text ?? "").trim(); + if (!state.running || body === "" || db.peerOf(chatId) === null) { + return Promise.resolve(null); + } + return serial(() => attempt({ chatId, text: body }, null)); +} + +// retry — повтор с пометки «не отправлено». +export function retry(id) { + if (!state.running) { + return Promise.resolve(null); + } + return serial(async () => { + const record = await db.message(id); + if (!record || record.status === "sent") { + return null; + } + return attempt(record, record.id); + }); +} + +// REUSE — запас под окно часов сервера: он принимает сообщение, пока время +// в ULID расходится с его часами не больше чем на пять минут (ADR-017). +// Идентификатор переиспользуется, пока до края окна остаётся минута: за неё +// успевают шифрование, очередь работ и сама сеть, так что дошедший запрос +// застаёт окно ещё открытым. +const REUSE = 4 * 60 * 1000; + +// attempt — одна попытка отправки. Прежний ULID сохраняется, пока его время +// годится серверу: ответ на POST мог потеряться после того, как сервер +// сообщение принял, и повтор с тем же идентификатором получатель молча +// пропустит (ADR-034), а повтор с новым лёг бы у него вторым сообщением +// (ADR-036). Идентификатор старше запаса заменяется свежим, и тогда старая +// запись удаляется: время в id должно совпадать с временем фактической +// отправки — иначе после долгого офлайна сервер ответит clock_skew. +// +// fresh требует свежий идентификатор, каким бы годным ни выглядел прежний: +// так возвращается попытка, у которой переиспользованный id сервер отверг +// по часам. +async function attempt(source, previousId, fresh = false) { + const peer = db.peerOf(source.chatId); + if (peer === null) { + state.pending.delete(previousId); + return null; + } + const keep = !fresh && previousId !== null && reusable(previousId); + const message = { + id: keep ? previousId : ulid(), + chatId: source.chatId, + from: state.nick, + text: source.text, + // Время показа идёт за идентификатором: сохранённый id оставляет + // и прежнее ts — до 202, которое принесёт серверное. + ts: keep ? source.ts : Date.now(), + status: "pending", + }; + const stale = previousId !== null && !keep; + if (stale) { + state.pending.delete(previousId); + } + state.pending.add(message.id); + await db.saveMessages({ + messages: [message], + remove: stale ? [previousId] : [], + me: state.nick, + }); + notify([message], stale ? [{ chatId: source.chatId, id: previousId }] : []); + const err = await post(message, peer); + if (err === null) { + return message.id; + } + // Возраст переиспользованного id сервер считает по своим часам: к времени, + // проведённому в pending, добавляется расхождение часов. Отставание в пару + // минут выводит за окно идентификатор, который клиенту кажется свежим. + // Это ровно та причина, ради которой id и меняется, — берём свежий и идём + // второй раз. Второго круга нет: fresh снимает переиспользование, и такой + // же отказ на свежем id означает, что часы врут по-настоящему (ADR-036). + if (keep && err instanceof ApiError && err.code === "clock_skew") { + return attempt(message, message.id, true); + } + await settle(message, err); + return message.id; +} + +// reusable — годится ли прежний идентификатор для новой попытки. Часы +// сравниваются со своими же: других у клиента нет, и первый ULID берётся +// из них же. Часы, врущие сверх окна, отсекает сервер: clock_skew на +// переиспользованном id разбирает attempt, на свежем — settle. +function reusable(id) { + const ms = ulidTime(id); + return ms !== null && Math.abs(Date.now() - ms) < REUSE; +} + +// post шифрует и отдаёт конверт серверу. from в AAD — собственный ник: +// сервер проставит то же значение из сессии, и AAD сойдётся у получателя +// (docs/crypto.md, «Сообщение»). +// +// Отдаёт null при 202 и отказ, если он был: судьбу отказа решает attempt — +// clock_skew на переиспользованном идентификаторе кончается не полосой, +// а второй попыткой. +async function post(message, peer) { + let envelope; + try { + const sealed = await sealMessage(await chatKey(peer), { + id: message.id, + chat: dmLabel(state.nick, peer), + from: state.nick, + keyId: DM_KEY_ID, + text: message.text, + }); + envelope = { + id: message.id, + to: { dm: peer }, + keyId: DM_KEY_ID, + iv: sealed.iv, + ct: sealed.ct, + }; + } catch (err) { + return err; + } + try { + const answer = await api.sendMessage(state.device, envelope); + // Запрос дошёл: сеть есть, что бы ни думал поток событий (ADR-028). + setOnline(true); + state.pending.delete(message.id); + const sent = { ...message, status: "sent", ts: answer?.ts ?? message.ts }; + await db.saveMessages({ messages: [sent], me: state.nick }); + notify([sent]); + return null; + } catch (err) { + // Ответ с кодом — то же доказательство, что запрос дошёл, что и 202: + // сеть есть, что бы ни думал поток событий (ADR-028). Ошибка шифрования + // сюда не попадает — она случается до запроса. 401 unauthenticated уже + // увёл на экран входа: состояние сети там ничьё. + if (err instanceof ApiError && state.running) { + setOnline(true); + } + return err; + } +} + +// settle разбирает отказ. Сеть и 500 сообщение не хоронят: оно остаётся +// pending и повторится при следующем подключении (ADR-027). Удалённое +// устройство чинится тем же способом — переподключением. Остальные 4xx — +// failed с текстом отказа (ADR-033). +async function settle(message, err) { + if (err instanceof NetworkError) { + // Поток событий молчания сети не замечает: у EventSource нет + // таймаута на тишину. Не дошедший запрос — та же полоса «нет + // соединения» (docs/ui.md, «Сеть и состояния», ADR-028). + setOnline(false); + } + if (transient(err)) { + share({ kind: "pending", ids: [message.id] }); + return; + } + if (err instanceof ApiError && err.code === "unknown_device") { + share({ kind: "pending", ids: [message.id] }); + retryLater(); + return; + } + state.pending.delete(message.id); + const failed = { ...message, status: "failed", error: api.errorText(err) }; + await db.saveMessages({ messages: [failed], me: state.nick }); + notify([failed]); +} + +// transient — отказ, который пройдёт сам: запрос не дошёл или сервер +// не справился. Повтор допустим (ADR-027). +function transient(err) { + return err instanceof NetworkError || (err instanceof ApiError && err.status >= 500); +} + +// --- действия экранов --------------------------------------------------- + +// openDm заводит личный чат с ником и отдаёт chatId. Строку списка +// заводит сервер (ADR-019), публичный ключ приходит тем же ответом. +// Ошибки — 404 unknown_user и 400 self (docs/ui.md, «Новый чат»). +export async function openDm(peer) { + const answer = await api.addContact(peer); + await rememberPeer(answer.nick, answer.publicKey); + const chatId = db.dmChatId(answer.nick); + const existing = await db.chat(chatId); + if (!existing || existing.hidden) { + // hideChat читает и пишет одной транзакцией и заводит недостающую + // запись: приём сообщений идёт своим чередом и в неё не врезается. + await db.hideChat(chatId, false); + announceChats(); + } + return chatId; +} + +// forgetChat — «убрать из списка» в карточке контакта. Строка на сервере +// уходит, зеркальная у собеседника остаётся: это не блокировка (ADR-019). +// История на устройстве не трогается — чат прячется. +export async function forgetChat(chatId) { + const peer = db.peerOf(chatId); + if (peer !== null) { + await api.removeContact(peer); + } + await db.hideChat(chatId, true); + announceChats(); +} + +// markRead — чат прочитан. Граница «новых» и счётчик локальные, на сервер +// не уходят (docs/storage.md). +export async function markRead(chatId) { + const record = await db.markRead(chatId); + announceChats(); + return record; +} + +// Чтение для экранов. Писать в базу им не нужно: всё, что меняет +// состояние, живёт здесь. dmChatId и peerOf — форма ключа чата +// (docs/storage.md): экраны собирают её из ника маршрута, а не из строки. +export { chats, chat, message, messagesBefore, peer, dmChatId, peerOf, PAGE } from "./db.js"; diff --git a/web/js/ui/chat.js b/web/js/ui/chat.js new file mode 100644 index 0000000..ff7ed8a --- /dev/null +++ b/web/js/ui/chat.js @@ -0,0 +1,425 @@ +// Экран чата — docs/ui.md, «Чат»; вид — docs/identity/screens.html. +// +// Данные и действия идут только через sync.js: экран не пишет в базу +// и не ходит в сеть сам. + +import * as sync from "../sync.js"; +import { DESKTOP, clear, el, wide } from "./dom.js"; + +// Предел текста и порог счётчика — docs/ui.md, «Чат». +const LIMIT = 4000; +const COUNTER_AT = 3500; + +// Разделители дат: на десктопе — полная дата, на мобильном — короткая, +// как в эталоне. Время — ЧЧ:ММ в локальной зоне. +const DAY_LONG = new Intl.DateTimeFormat("ru-RU", { weekday: "long", day: "numeric", month: "long" }); +const DAY_SHORT = new Intl.DateTimeFormat("ru-RU", { day: "numeric", month: "short" }); +const TIME = new Intl.DateTimeFormat("ru-RU", { hour: "2-digit", minute: "2-digit" }); + +// Тексты нерасшифрованного — docs/ui.md, «Чат». Ключа комнаты нет — +// это про комнату; всё остальное в личном чате означает чужой ключ. +const NO_ROOM_KEY = "не удалось расшифровать: нет ключа комнаты"; +const KEY_CHANGED = "не удалось расшифровать: ключ изменился"; + +// Насколько далеко от низа ленты человек ещё считается «внизу»: пришедшее +// сообщение подматывает ленту только тогда, когда он и так смотрит конец. +const NEAR_BOTTOM = 80; + +// renderChat рисует чат в root и отдаёт отписку. +export function renderChat(root, ctx, chatId) { + const view = { + ctx, + chatId, + me: ctx.me.nick, + peer: sync.peerOf(chatId), + limit: ctx.config?.maxMessageChars ?? LIMIT, + alive: true, + // Лента: записи по возрастанию id и их строки в разметке. + items: [], + nodes: new Map(), + // Граница «новых»: первый непрочитанный на момент открытия. + newId: null, + chain: Promise.resolve(), + }; + + root.append(head(view)); + + view.feed = el("div", "feed"); + view.body = el("div", "grid"); + view.body.setAttribute("aria-live", "polite"); + view.feed.append(view.body); + root.append(view.feed); + + root.append(composer(view)); + + const offMessages = sync.on("messages", (detail) => { + if (detail.chatId === view.chatId) { + run(view, () => apply(view, detail)); + } + }); + const offNet = sync.on("net", () => paintBar(view)); + const media = matchMedia(DESKTOP); + const onMedia = () => paint(view, true); + media.addEventListener("change", onMedia); + + run(view, () => load(view)); + + return () => { + view.alive = false; + offMessages(); + offNet(); + media.removeEventListener("change", onMedia); + }; +} + +// run выстраивает работу экрана в очередь: загрузка и приходящие события +// не должны перемешиваться. +function run(view, task) { + view.chain = view.chain.then(task).catch(() => {}); + return view.chain; +} + +// --- разметка ----------------------------------------------------------- + +// head — шапка: имя чата, по нажатию — карточка контакта. «назад» слева +// нужен там, где виден один экран за раз; на десктопе его прячет CSS. +function head(view) { + const bar = el("div", "head"); + const back = el("button", "back back--chat", "назад"); + back.type = "button"; + back.addEventListener("click", () => view.ctx.go("#/")); + const title = el("button", "chat-title", `@${view.peer}`); + title.type = "button"; + title.addEventListener("click", () => view.ctx.go(`#/contact/${view.peer}`)); + bar.append(back, title); + return bar; +} + +// composer — полоса состояния и строка ввода: рамка 1 px ink, слева «>» +// цветом mark. Enter отправляет только на десктопе; на мобильном он делает +// перенос, а отправляет кнопка «>» справа (docs/ui.md, «Чат»). +function composer(view) { + const form = el("form", "compose"); + form.noValidate = true; + + view.bar = el("p", "bar"); + view.bar.hidden = true; + view.bar.setAttribute("aria-live", "polite"); + + const row = el("div", "input"); + const prompt = el("span", "p", ">"); + prompt.setAttribute("aria-hidden", "true"); + + view.field = el("textarea", "input__field"); + view.field.rows = 1; + view.field.placeholder = "сообщение"; + view.field.maxLength = view.limit; + + view.counter = el("span", "counter"); + view.counter.hidden = true; + + const send = el("button", "input__send", ">"); + send.type = "submit"; + + row.append(prompt, view.field, view.counter, el("span", "enter", "enter — отправить"), send); + form.append(view.bar, row); + + view.field.addEventListener("input", () => count(view)); + view.field.addEventListener("keydown", (event) => { + if (event.key !== "Enter" || event.shiftKey || event.isComposing) { + return; + } + if (!wide()) { + return; + } + event.preventDefault(); + submit(view); + }); + form.addEventListener("submit", (event) => { + event.preventDefault(); + submit(view); + }); + + return form; +} + +// count — счётчик остатка: появляется после порога (docs/ui.md, «Чат»). +function count(view) { + const length = view.field.value.length; + view.counter.textContent = String(view.limit - length); + view.counter.hidden = length <= COUNTER_AT; +} + +function submit(view) { + const text = view.field.value; + if (text.trim() === "") { + return; + } + view.field.value = ""; + count(view); + run(view, () => sync.send(view.chatId, text)); +} + +// --- лента -------------------------------------------------------------- + +async function load(view) { + let record = null; + let list = []; + try { + record = await sync.chat(view.chatId); + list = await sync.messagesBefore(view.chatId); + } catch { + // Базы нет — рисуем пустую ленту: отправка от этого не ломается. + } + if (!view.alive) { + return; + } + view.items = list; + view.newId = firstUnread(record, list, view.me); + paint(view, true); + // Фокус в строку ввода при открытии чата на десктопе (docs/ui.md, + // «Доступность»); на мобильном это подняло бы клавиатуру на весь экран. + if (wide()) { + view.field.focus(); + } + await read(view); +} + +// firstUnread — граница «новых»: первый чужой непрочитанный. Своё +// непрочитанным не бывает, поэтому и границей не становится. +function firstUnread(record, list, me) { + if (!record || record.unread <= 0) { + return null; + } + const bound = record.lastReadId; + const found = list.find((m) => m.from !== me && (!bound || m.id > bound)); + return found ? found.id : null; +} + +// read помечает чат прочитанным — после отрисовки: до этого lastReadId +// и есть граница «новых» (docs/storage.md). +async function read(view) { + try { + await sync.markRead(view.chatId); + } catch { + // Счётчик непрочитанных подождёт до следующего раза. + } +} + +// apply разбирает изменения ленты. Дописать в конец дешевле, чем +// перерисовать: лента — живая область, и перерисовка заставила бы +// экранного диктора зачитать её целиком. +async function apply(view, detail) { + const incoming = []; + for (const id of detail.ids ?? []) { + let record = null; + try { + record = await sync.message(id); + } catch { + return; + } + if (record && record.chatId === view.chatId) { + incoming.push(record); + } + } + if (!view.alive) { + return; + } + const bottom = atBottom(view); + let whole = false; + let added = 0; + + for (const id of detail.removed ?? []) { + const at = view.items.findIndex((m) => m.id === id); + if (at >= 0) { + view.items.splice(at, 1); + whole = true; + } + } + incoming.sort((a, b) => (a.id < b.id ? -1 : 1)); + for (const record of incoming) { + const at = view.items.findIndex((m) => m.id === record.id); + if (at >= 0) { + // Та же запись в новом состоянии: pending стал sent или failed. + view.items[at] = record; + if (!whole) { + redraw(view, record); + } + continue; + } + const last = view.items[view.items.length - 1]; + if (last && last.id > record.id) { + // Из очереди сервера пришло то, что старше уже нарисованного. + view.items.splice(view.items.findIndex((m) => m.id > record.id), 0, record); + whole = true; + continue; + } + view.items.push(record); + if (!whole) { + line(view, record, view.items[view.items.length - 2] ?? null); + } + added += 1; + } + + if (whole) { + paint(view, bottom); + } else { + if (bottom && added > 0) { + down(view); + } + paintBar(view); + } + if (whole || added > 0) { + await read(view); + } +} + +// paint рисует ленту заново. +function paint(view, bottom) { + clear(view.body); + view.nodes.clear(); + let previous = null; + for (const record of view.items) { + line(view, record, previous); + previous = record; + } + paintBar(view); + if (bottom) { + down(view); + } +} + +// line дописывает сообщение в конец ленты вместе с разделителями, +// которые перед ним нужны. +function line(view, record, previous) { + const day = !previous || dayOf(previous.ts) !== dayOf(record.ts); + if (day) { + view.body.append(divider(label(record.ts), false)); + } + const fresh = record.id === view.newId; + if (fresh) { + view.body.append(divider("новые", true)); + } + // Подряд идущие сообщения одного автора — без повтора автора. + const first = day || fresh || !previous || previous.from !== record.from; + const node = el("div", first ? "line is-head" : "line"); + node.append(author(view, record, first), text(view, record)); + view.body.append(node); + view.nodes.set(record.id, node); +} + +// redraw обновляет одну строку на месте: автор и группировка от состояния +// сообщения не зависят. +function redraw(view, record) { + const node = view.nodes.get(record.id); + if (!node) { + return; + } + const first = node.classList.contains("is-head"); + clear(node); + node.append(author(view, record, first), text(view, record)); +} + +function divider(caption, fresh) { + const node = el("div", fresh ? "divider divider--new" : "divider"); + node.append(el("span", null, caption)); + return node; +} + +// author — колонка автора: ник и время. Свой ник — цветом mark. +function author(view, record, first) { + const node = el("div", record.from === view.me ? "author author--me" : "author"); + if (!first) { + return node; + } + node.append(el("span", null, record.from), el("span", "t", TIME.format(record.ts))); + return node; +} + +// text — само сообщение. Нерасшифрованное — курсивом с причиной, pending — +// цветом stone, failed — с пометкой «не отправлено · повторить». +function text(view, record) { + const node = el("div", "text"); + if (record.text === null) { + node.classList.add("text--none"); + node.textContent = view.peer === null && record.undecryptable === "unknown_key" + ? NO_ROOM_KEY + : KEY_CHANGED; + return node; + } + if (record.status === "pending") { + node.classList.add("text--pending"); + } + node.append(el("p", "text__body", record.text)); + if (record.status === "failed") { + const note = el("p", "fail"); + const again = el("button", "link", "повторить"); + again.type = "button"; + again.addEventListener("click", () => run(view, () => sync.retry(record.id))); + note.append(el("span", null, "не отправлено ·"), again); + node.append(note); + } + return node; +} + +// paintBar — полоса над вводом. Причина одна за раз: отказ отправки +// перебивает «нет соединения», потому что он про конкретное сообщение +// и уходит при следующей попытке (ADR-033). +function paintBar(view) { + const failed = lastFailed(view); + if (failed) { + view.bar.className = "bar bar--mark"; + view.bar.textContent = failed.error; + view.bar.hidden = false; + return; + } + if (!sync.online()) { + view.bar.className = "bar"; + view.bar.textContent = "нет соединения"; + view.bar.hidden = false; + return; + } + view.bar.hidden = true; + view.bar.textContent = ""; +} + +// lastFailed — последнее своё неотправленное сообщение с текстом отказа +// (docs/ui.md, «Чат»). Смотреть на состояние последнего своего нельзя: +// лента отсортирована по ULID, а время в нём — часы отправителя. Отставшие +// часы ставят новое сообщение перед его же старыми, и последним своим +// остаётся давно отправленное — ровно в том случае, ради которого текст +// про часы и заведён (ADR-033). +function lastFailed(view) { + for (let i = view.items.length - 1; i >= 0; i -= 1) { + const record = view.items[i]; + if (record.from === view.me && record.status === "failed" && record.error) { + return record; + } + } + return null; +} + +// --- прокрутка и даты --------------------------------------------------- + +function atBottom(view) { + const feed = view.feed; + return feed.scrollHeight - feed.scrollTop - feed.clientHeight < NEAR_BOTTOM; +} + +function down(view) { + view.feed.scrollTop = view.feed.scrollHeight; +} + +function dayOf(ts) { + const date = new Date(ts); + return `${date.getFullYear()}-${date.getMonth()}-${date.getDate()}`; +} + +// label — дата разделителя. Короткая форма на мобильном без точки +// сокращения: так в эталоне. +function label(ts) { + if (wide()) { + return DAY_LONG.format(ts); + } + return DAY_SHORT.format(ts).replace(/\.$/, ""); +} diff --git a/web/js/ui/chats.js b/web/js/ui/chats.js new file mode 100644 index 0000000..1589403 --- /dev/null +++ b/web/js/ui/chats.js @@ -0,0 +1,73 @@ +// Список чатов в сайдбаре — docs/ui.md, «Список чатов». +// +// Секции «каналы» и «личные», порядок — по lastId по убыванию (его держит +// sync.chats). Пустая секция не рисуется: комнат до этапа 3 нет. + +import * as sync from "../sync.js"; +import { clear, el } from "./dom.js"; + +const SECTIONS = [ + ["room", "каналы"], + ["dm", "личные"], +]; + +// mount рисует список в root и держит его в актуальном виде, пока экран +// жив. Отдаёт отписку. +export function mount(root, ctx, active) { + // Событий «chats» приходит больше одного подряд; рисует последнее. + let generation = 0; + const paint = async () => { + const mine = ++generation; + let list; + try { + list = await sync.chats(); + } catch { + return; + } + if (mine !== generation) { + return; + } + clear(root); + for (const [type, title] of SECTIONS) { + const part = list.filter((chat) => chat.type === type); + if (part.length === 0) { + continue; + } + const items = el("ul", "items"); + for (const chat of part) { + items.append(item(ctx, chat, active)); + } + root.append(el("h2", "section", title), items); + } + }; + const off = sync.on("chats", paint); + paint(); + return off; +} + +function item(ctx, chat, active) { + const row = el("li"); + const button = el("button", "item"); + button.type = "button"; + if (chat.id === active) { + // Активный чат — инверсия (docs/identity/brief.md). + button.classList.add("is-active"); + button.setAttribute("aria-current", "true"); + } + button.append(el("span", "item__name", sigil(chat) + chat.title)); + if (chat.unread > 0) { + button.append(el("span", "n", String(chat.unread))); + } + button.addEventListener("click", () => ctx.go(hashOf(chat))); + row.append(button); + return row; +} + +// Сигил ставит экран: в базе чат зовётся без «@» и «#» (docs/storage.md). +function sigil(chat) { + return chat.type === "dm" ? "@" : "#"; +} + +function hashOf(chat) { + return chat.type === "dm" ? `#/dm/${chat.peer}` : `#/room/${chat.roomId}`; +} diff --git a/web/js/ui/contact.js b/web/js/ui/contact.js new file mode 100644 index 0000000..5411c11 --- /dev/null +++ b/web/js/ui/contact.js @@ -0,0 +1,61 @@ +// Карточка контакта — docs/ui.md, «Карточка контакта». +// +// Смена ключа собеседника и «доверять новому ключу» появятся вместе +// с TOFU (этап 3, ADR-016): до тех пор у записи peers нет pending. + +import * as sync from "../sync.js"; +import { fingerprintGroups } from "../crypto.js"; +import { button, el, message, setError, setNote } from "./dom.js"; + +export function renderContact(root, ctx, nick) { + root.append(head(ctx, nick)); + const body = el("div", "body settings"); + root.append(body); + + const card = el("section", "block block--first"); + body.append(card, remove(ctx, nick)); + + // Отпечаток лежит в записи TOFU; её может ещё не быть, если чат + // открыли до первого ключа. + sync.peer(nick).then((record) => { + if (!record?.fingerprint || !card.isConnected) { + return; + } + const groups = fingerprintGroups(record.fingerprint); + card.append( + el("p", "fp", groups.slice(0, 8).join(" ")), + el("p", "fp", groups.slice(8).join(" ")), + el("p", "fp-hint", "сверьте с собеседником голосом или лично"), + ); + }).catch(() => {}); +} + +function head(ctx, nick) { + const bar = el("div", "head"); + const back = el("button", "back", "назад"); + back.type = "button"; + back.addEventListener("click", () => ctx.go(`#/dm/${nick}`)); + bar.append(back, el("span", "title", `@${nick}`)); + return bar; +} + +// remove — «убрать из списка»: строка контакта уходит с сервера, история +// на устройстве остаётся (ADR-019). +function remove(ctx, nick) { + const box = el("section", "block"); + const note = message(); + const drop = button("убрать из списка"); + drop.addEventListener("click", async () => { + drop.disabled = true; + setNote(note, ""); + try { + await sync.forgetChat(sync.dmChatId(nick)); + ctx.go("#/"); + } catch (err) { + setError(note, ctx.errorText(err)); + drop.disabled = false; + } + }); + box.append(drop, note); + return box; +} diff --git a/web/js/ui/dom.js b/web/js/ui/dom.js index f6271d5..9e98695 100644 --- a/web/js/ui/dom.js +++ b/web/js/ui/dom.js @@ -3,6 +3,15 @@ const SVG = "http://www.w3.org/2000/svg"; +// DESKTOP — порог десктопа: сайдбар и чат рядом, один экран за раз кончается +// (docs/ui.md, «Каркас»). Экраны спрашивают ширину в момент события, а не +// перерисовываются на каждое изменение размера. +export const DESKTOP = "(min-width: 760px)"; + +export function wide() { + return matchMedia(DESKTOP).matches; +} + export function el(tag, className, text) { const node = document.createElement(tag); if (className) { diff --git a/web/js/ui/new.js b/web/js/ui/new.js new file mode 100644 index 0000000..73cca3e --- /dev/null +++ b/web/js/ui/new.js @@ -0,0 +1,68 @@ +// Новый чат — docs/ui.md, «Новый чат». Строка `#имя комнаты` появится +// вместе с комнатами (этап 3): создавать пока нечего. + +import * as sync from "../sync.js"; +import { el, message, setError, setNote } from "./dom.js"; + +export function renderNew(root, ctx) { + root.append(head(ctx)); + + const body = el("div", "body"); + const form = el("form", "form"); + form.noValidate = true; + + const row = el("div", "input"); + const prompt = el("span", "p", ">"); + prompt.setAttribute("aria-hidden", "true"); + const field = el("input", "input__field"); + field.type = "text"; + field.placeholder = "@ник"; + field.autocapitalize = "off"; + field.autocomplete = "off"; + field.spellcheck = false; + const go = el("button", "input__send", ">"); + go.type = "submit"; + row.append(prompt, field, go); + + const note = message(); + form.append(row, note); + + form.addEventListener("submit", async (event) => { + event.preventDefault(); + if (go.disabled) { + return; + } + setNote(note, ""); + // Ник вводят как в списке: с «@» или без. Регистр не хранится — + // ники строчные (ADR-019). + const nick = field.value.trim().replace(/^@/, "").toLowerCase(); + if (nick === "") { + field.focus(); + return; + } + field.value = nick; + go.disabled = true; + try { + await sync.openDm(nick); + ctx.go(`#/dm/${nick}`); + } catch (err) { + setError(note, ctx.errorText(err)); + field.focus(); + } finally { + go.disabled = false; + } + }); + + body.append(form); + root.append(body); + field.focus(); +} + +function head(ctx) { + const bar = el("div", "head"); + const back = el("button", "back", "назад"); + back.type = "button"; + back.addEventListener("click", () => ctx.go("#/")); + bar.append(back, el("span", "title", "новый чат")); + return bar; +} diff --git a/web/js/ui/shell.js b/web/js/ui/shell.js index 5b015cf..eb9d2d0 100644 --- a/web/js/ui/shell.js +++ b/web/js/ui/shell.js @@ -1,19 +1,22 @@ // Каркас: сайдбар со списком чатов и место под экран — docs/ui.md, «Каркас» -// и «Список чатов». Чаты появятся на этапе 2, секции пока пустые. +// и «Список чатов». import { el, mark } from "./dom.js"; +import { mount } from "./chats.js"; -// frame отдаёт корень и место под экран. screen — что показывать -// на мобильном, где виден один экран за раз: "list" или "screen". -export function frame(ctx, screen) { +// frame отдаёт корень, место под экран и отписку списка чатов. +// screen — что показывать на мобильном, где виден один экран за раз: +// "list" или "screen". active — чат, который сейчас открыт. +export function frame(ctx, screen, active = null) { const root = el("div", "shell"); root.dataset.screen = screen; const main = el("main", "main"); - root.append(side(ctx), main); - return { root, main }; + const { nav, dispose } = side(ctx, active); + root.append(nav, main); + return { root, main, dispose }; } -function side(ctx) { +function side(ctx, active) { const nav = el("nav", "side"); const brand = el("div", "brand"); @@ -21,9 +24,11 @@ function side(ctx) { nav.append(brand); const list = el("div", "list"); - for (const title of ["каналы", "личные"]) { - list.append(el("h2", "section", title), el("ul", "items")); - } + const add = el("button", "item item--new", "+ новый чат"); + add.type = "button"; + add.addEventListener("click", () => ctx.go("#/new")); + const items = el("div"); + list.append(add, items); nav.append(list); const me = el("button", "me"); @@ -34,5 +39,5 @@ function side(ctx) { me.addEventListener("click", () => ctx.go("#/settings")); nav.append(me); - return nav; + return { nav, dispose: mount(items, ctx, active) }; } diff --git a/web/js/ulid.js b/web/js/ulid.js new file mode 100644 index 0000000..890e2c6 --- /dev/null +++ b/web/js/ulid.js @@ -0,0 +1,104 @@ +// ULID — идентификатор сообщения: 48 бит миллисекунд и 80 бит случайности, +// Crockford base32, 26 символов (docs/crypto.md, «Идентификаторы»). +// +// Заглавные буквы обязательны: идентификатор входит в AAD шифротекста +// побайтно, и сервер строчные не принимает. +// +// Модуль не знает про DOM: его можно импортировать в node и прогнать. + +// crockford — алфавит base32 без I, L, O и U. +const ALPHABET = "0123456789ABCDEFGHJKMNPQRSTVWXYZ"; + +const TIME_LEN = 10; // 50 бит, старшие два обязаны быть нулевыми +const RANDOM_LEN = 16; // 80 бит +const RANDOM_BYTES = 10; + +export const ULID_LEN = TIME_LEN + RANDOM_LEN; + +// MAX_TIME — предел 48 бит: дальше метка времени в ULID не помещается. +const MAX_TIME = 2 ** 48 - 1; + +// Последняя выданная миллисекунда и её случайная часть. Внутри одной +// миллисекунды случайная часть инкрементируется (docs/crypto.md): +// два сообщения, набранные подряд, не получают одинаковый идентификатор +// и сортируются в порядке отправки. +let lastMs = -1; +const lastRandom = new Uint8Array(RANDOM_BYTES); + +export function ulid(now = Date.now()) { + const ms = Math.floor(now); + if (!Number.isSafeInteger(ms) || ms < 0 || ms > MAX_TIME) { + throw new RangeError("время вне 48 бит"); + } + if (ms === lastMs) { + bump(lastRandom); + } else { + lastMs = ms; + globalThis.crypto.getRandomValues(lastRandom); + } + return encodeTime(ms) + encodeRandom(lastRandom); +} + +// ulidTime — метка времени идентификатора в миллисекундах; null, если +// это не ULID. Сервер считает ту же величину и сравнивает со своими +// часами: расхождение больше пяти минут — clock_skew (ADR-017). +export function ulidTime(id) { + if (typeof id !== "string" || id.length !== ULID_LEN) { + return null; + } + let ms = 0; + for (let i = 0; i < ULID_LEN; i += 1) { + const value = ALPHABET.indexOf(id[i]); + if (value < 0) { + return null; + } + if (i < TIME_LEN) { + ms = ms * 32 + value; + } + } + return ms > MAX_TIME ? null : ms; +} + +export function validUlid(id) { + return ulidTime(id) !== null; +} + +// bump увеличивает случайную часть на единицу. Переполнение всех 80 бит +// внутри одной миллисекунды невозможно на практике; если оно всё же +// случилось, берём новые случайные байты. +function bump(bytes) { + for (let i = bytes.length - 1; i >= 0; i -= 1) { + if (bytes[i] < 255) { + bytes[i] += 1; + return; + } + bytes[i] = 0; + } + globalThis.crypto.getRandomValues(bytes); +} + +function encodeTime(ms) { + const out = new Array(TIME_LEN); + let rest = ms; + for (let i = TIME_LEN - 1; i >= 0; i -= 1) { + out[i] = ALPHABET[rest % 32]; + rest = Math.floor(rest / 32); + } + return out.join(""); +} + +// encodeRandom режет 80 бит на 16 групп по 5: остатка нет. +function encodeRandom(bytes) { + let out = ""; + let acc = 0; + let bits = 0; + for (let i = 0; i < bytes.length; i += 1) { + acc = (acc << 8) | bytes[i]; + bits += 8; + while (bits >= 5) { + bits -= 5; + out += ALPHABET[(acc >>> bits) & 31]; + } + } + return out; +}