Этап 2: чат 1:1 — устройства, очередь, SSE, шифрование сообщений
Сервер: регистрация устройств и X-Device, hub с одним потоком на устройство, очередь per-device с фан-аутом без эха отправителю, POST /api/messages с проверками в порядке protocol.md, ACK, SSE с воспроизведением очереди, ready и пингом раз в 20 секунд, контакты в обе стороны при первом сообщении, лимит 30 сообщений в минуту. Клиент: ULID, ключ 1:1 из ECDH через HKDF, шифрование конверта с AAD, sync.js как единственный писатель в IndexedDB, ACK строго после записи, список чатов, экран чата по эталону, разделители дат и «новые», pending и failed с повтором, полоса «нет соединения». ADR-033: у неотправленного есть текст отказа — clock_skew стало видно. ADR-034: входящее с известным id не перезаписывает запись. Собеседник знает открытый id конверта и подменял им чужое сообщение в чужой истории — вплоть до стирания своего присланного, чего «удалить у всех не существует» не допускает. ADR-035: один поток событий на браузерный профиль (locks + BroadcastChannel): две вкладки отбирали поток друг у друга и оставались без живой доставки. ADR-036: повтор отправки сохраняет ULID, пока он в пределах окна часов, — иначе потерянный ответ давал у собеседника два сообщения вместо одного. Приёмка на боевом сервере: два аккаунта, пять устройств, живая доставка, копия на второе устройство, очередь офлайн-устройству, ACK, подмена from игнорируется, чужой deviceId и запрос без Origin отбиваются, плейнтекста в базе и WAL ноль вхождений. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015DbCjVfTFq4ZFG8juD45YJ
This commit is contained in:
+6
-1
@@ -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)
|
||||
|
||||
@@ -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` наконец видно: расхождение часов объясняется словами, а не молчаливым «не отправлено».
|
||||
- Текст переживает перезагрузку вместе с сообщением: он часть записи, а не состояние экрана.
|
||||
- Причин у полосы над вводом становится три — нет соединения, ключ изменился, отказ отправки. Больше одной сразу не показывается: полоса одна.
|
||||
@@ -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, больше не двигает счётчик непрочитанных.
|
||||
@@ -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).
|
||||
@@ -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`, а не два разных.
|
||||
- Сортировка исходящего перестаёт зависеть от числа попыток: сообщение остаётся на своём месте в ленте, а не переезжает в конец при каждом повторе.
|
||||
+5
-3
@@ -114,6 +114,7 @@ chats key: id // "dm:<peer>" | "room:<roomId>"
|
||||
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-<nick>-<YYYY-MM-DD>.bare`.
|
||||
Полезная нагрузка — `chats` (без `unread`, `lastReadId`), `messages` (без `raw` и `error`, только с `text`), `peers` (без `pending`). Формат файла и шифрование — `docs/crypto.md`. Имя файла — `bare-<nick>-<YYYY-MM-DD>.bare`.
|
||||
|
||||
@@ -38,6 +38,8 @@
|
||||
|
||||
Предупреждение о ключе — полоса над вводом цветом `mark`: «ключ @marta изменился. сверьте отпечаток лично. [доверять новому ключу]». Ввод заблокирован до подтверждения.
|
||||
|
||||
Отказ отправки — та же полоса над вводом цветом `mark` с текстом из поля `error` последнего неотправленного сообщения (ADR-033): «проверьте часы на устройстве: расхождение больше 5 минут», «слишком часто, попробуйте позже», «сервер не справился, попробуйте позже». Полоса исчезает при следующей попытке. Ввод не блокируется.
|
||||
|
||||
Первое отправленное сообщение за всю историю устройства → запрос разрешения на уведомления (см. «Уведомления»).
|
||||
|
||||
## Карточка контакта (`#/contact/<nick>`)
|
||||
@@ -70,6 +72,7 @@
|
||||
## Сеть и состояния
|
||||
|
||||
- SSE переподключается браузером; после `ready` клиент перечитывает комнаты и контакты и повторяет `pending`.
|
||||
- Вкладок одного профиля бывает несколько; поток событий держит одна из них, остальные получают изменения от неё и выглядят так же (ADR-035).
|
||||
- Без сети: полоса «нет соединения» цветом `stone` над вводом; ввод не блокируется — сообщения уходят в `pending`.
|
||||
- `clock_skew` — «проверьте часы на устройстве: расхождение больше 5 минут».
|
||||
- `401 unauthenticated` на любом запросе — выход на экран входа с сохранением IndexedDB (сессия истекла, история остаётся). Исключение одно: служебный выход перед повторным входом при смене пароля и удалении аккаунта (ADR-031) — там этот ответ означает, что сессии и так нет.
|
||||
|
||||
+39
-4
@@ -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) {
|
||||
|
||||
@@ -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 == "" {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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, "")
|
||||
}
|
||||
@@ -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", "это устройство не ваше")
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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()))
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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", "слишком часто, попробуйте позже")
|
||||
}
|
||||
@@ -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, "")
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) }) }
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
+372
-1
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
+97
-5
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
+253
@@ -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:<roomId>" (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() {
|
||||
|
||||
+106
-19
@@ -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();
|
||||
|
||||
+911
@@ -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";
|
||||
@@ -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(/\.$/, "");
|
||||
}
|
||||
@@ -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}`;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
+16
-11
@@ -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) };
|
||||
}
|
||||
|
||||
+104
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user