Этап 5: история — экспорт и импорт .bare, устройства, место, пагинация

Экспорт: ключ архива из секрета аккаунта через HKDF, заголовок ровно 65 байт
(magic, версия, соль, 32 сырых байта отпечатка владельца, iv) и он же целиком
AAD шифротекста. Ника владельца в файле нет. Импорт сверяет отпечаток до
расшифровки, сливает идемпотентно по id, а записи peers берёт только для
ников, которых в локальном TOFU ещё нет: архивом доверие к ключу не перебить.

Настройки: устройства с датой и пометкой «это устройство», «занято N МБ»,
кнопка «экспортировать» в подтверждении выхода и в подтверждении входа
под другим ником — долг этапа 1 и обещание ADR-029 закрыты.
Лента: страницы по 50 с подгрузкой вверх без прыжка прокрутки; новая
страница вставляется, а не пересобирает ленту.

ADR-050: импорт не перезаписывает лежащую запись — у своей есть состояние
отправки, которого в архиве нет.
ADR-054: архив — недоверенный ввод. Ревью собрало архивы с ts вне диапазона
Date, мусорным lastId, ником с bidi-переопределением и roomId с обходом пути:
каждый из них навсегда ломал ленту или счётчик. Теперь форма ника, roomId,
id, ts и автора проверяется, а lastId из файла не читается вовсе.
ADR-051, 052, 053: тексты и кнопки, устройства и место, страницы ленты
без виртуализации.

Приёмка: формат сверен побайтно на модулях, скачанных с боевого сервера, —
смещения заголовка, отпечаток сырыми байтами, новые соль и iv на каждый
экспорт, подмена любого байта заголовка ломает расшифровку, чужой секрет
не открывает, семь видов битых файлов отвергнуты.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015DbCjVfTFq4ZFG8juD45YJ
This commit is contained in:
2026-08-23 02:57:51 +03:00
co-authored by Claude Opus 5
parent 8f67f4aa4d
commit 0c878477d2
21 changed files with 1274 additions and 53 deletions
+1 -1
View File
@@ -38,7 +38,7 @@ Forward secrecy — осознанный non-goal v1.
Сервер хранит только три вещи: аккаунты (ник, argon2-хеш, зашифрованный ключевой блоб), метаданные комнат и контактов (включая завёрнутые ключи комнат), транзитную очередь зашифрованных недоставленных сообщений. Очередь per-device: устройство — случайный идентификатор, который клиент создаёт при первом входе; доставлено и подтверждено ACK — удалено с сервера; не забрано за 30 дней — удалено. Схема — `docs/storage.md`, протокол — `docs/protocol.md`.
Клиент хранит историю в IndexedDB. messageId — ULID/UUIDv7: хронологическая сортировка и идемпотентный merge. Составной индекс (chatId, messageId). Пагинация курсором по ~50 сообщений, виртуализация списка в DOM.
Клиент хранит историю в IndexedDB. messageId — ULID/UUIDv7: хронологическая сортировка и идемпотентный merge. Составной индекс (chatId, messageId). Пагинация курсором по ~50 сообщений; загруженное держится в DOM целиком, виртуализации нет (ADR-053).
При старте клиент запрашивает `navigator.storage.persist()` и показывает занятое место через `storage.estimate()`.
+2
View File
@@ -106,6 +106,8 @@ header = "BARE" (4) || version u8 = 1 || salt (16) || fingerprint (32) || iv
Импорт: проверить magic и версию, сравнить `fingerprint` со своим — при несовпадении показать «архив создан другим аккаунтом» и остановиться, иначе вывести ключ и расшифровать. Слияние — идемпотентное по `id` сообщений и `id` чатов; записи `peers` из архива добавляются только для ников, которых в локальном TOFU ещё нет.
Чужая магия и незнакомая версия — файла в заголовке или нагрузки в поле `v` — показываются тем же текстом, что и порча: «файл повреждён». Третьего текста нет (ADR-054). Форму записей внутри нагрузки клиент проверяет сам — `docs/storage.md`, «Экспорт `.bare`».
## Идентификаторы
- ULID: 48 бит миллисекунд + 80 бит случайности, Crockford base32, 26 символов. Внутри одной миллисекунды на одном клиенте случайная часть инкрементируется.
+2
View File
@@ -1,5 +1,7 @@
# ADR-009: История — только на устройстве, в IndexedDB
Уточнён [ADR-053](053-feed-pages-without-virtualization.md): виртуализация списка в DOM снята, пагинация курсором по 50 в силе.
## Контекст
История, живущая на сервере, делает сервер архивом и целью атак. У Bare история — собственность устройства.
@@ -0,0 +1,31 @@
# ADR-050: Импорт архива не перезаписывает то, что уже лежит
## Контекст
`docs/crypto.md` описывает слияние одной строкой: «идемпотентное по `id` сообщений и `id` чатов». Что делать с записью, которая на устройстве уже есть, там не сказано, а вариантов два, и они дают разную историю.
ADR-034 такой же вопрос уже решал — для входящего из сети. Его довод к архиву не относится: `id` открыт собеседнику, и потому конверт с известным `id` игнорируется, а архив зашифрован секретом аккаунта, чужой его не соберёт. Значит правило нужно выбирать заново, а не наследовать.
Молчат и три соседних места. Счётчик непрочитанных и граница «новых» в архив не пишутся (`docs/storage.md`) — но что происходит с местными, когда приходит история за прошлый год, не сказано. `pending` — сообщение, набранное на другом устройстве и туда же не ушедшее, — по букве документа в архив попадает: текст у него есть. И `lastId` чата в архив попадает тоже, хотя указывает на последнюю строку чата, а ею бывает как раз то, чего в архиве нет.
## Решение
- Импорт не перезаписывает существующую запись сообщения. Своя запись знает то, чего в архиве нет: состояние отправки у каждого устройства своё — на одном сообщение `failed`, на другом то же самое доставлено, — а нерасшифрованная хранит `raw`.
- Чат с известным `id` не перезаписывается. Меняется одно: `lastId` уезжает вперёд под самое новое из добавленного, иначе чат не встанет на своё место в списке.
- `lastId` в архив не пишется. Устройство, принявшее архив, считает его само — по тому, что действительно добавило. Взятый из файла, он указывал бы на строку, которой в архиве нет: последней в чате бывает и неотправленная, и нерасшифрованная. Такой `lastId` ставит пустой чат в начало списка, а после открытия чата уезжает в `lastReadId` — и настоящее сообщение с тем же `id`, приехав позже, не поднимет счётчик.
- Счётчик непрочитанных импорт не трогает: архив приносит переписку, а не отметки о прочтении. Граница «новых» едет за лентой: `lastReadId` уезжает под новый `lastId`, пока непрочитанных у чата нет; у чата с непрочитанным граница уже показывает на него и остаётся на месте. Счётчик и граница считаются от одной точки — иначе счётчик говорит «1», а линия отчёркивает всю привезённую переписку.
- `hidden` в архив не пишется. «Убрать из списка» — решение устройства, а не история: перенесённое, оно спрятало бы привезённую переписку на новом устройстве, и показать её было бы нечем.
- В архив уносится только отправленное — `sent`. `pending` и `failed` привязаны к устройству и к своему ULID (ADR-036): на другом устройстве «повторить» у такой записи отправит собеседнику второе сообщение, а сама запись, ушедшая после повтора под свежим `id`, вернётся из того же файла дублем. Нерасшифрованное не уносится тоже: без текста от записи остаётся один заголовок, а `raw` — служебное поле.
- Ответ импорта — число добавленных сообщений: «добавлено N сообщений». Повторный импорт того же файла добавляет ноль.
- Лента открытого чата после импорта перечитывается целиком: добавленное ложится в середину пачками по несколько тысяч, и перечня в событии нет.
- Правила записаны в `docs/storage.md`, раздел «Экспорт `.bare`».
## Следствия
- Повторный импорт и склейка истории с двух устройств не создают дублей и не двигают ни одной прежней строки. Это верно и после «повторить»: отвергнутого в архиве нет.
- Нерасшифрованное остаётся нерасшифрованным, даже когда в архиве есть его текст. Чинит это `raw` и появившийся ключ, а не файл. Цена принята: правило одно и без исключений, а исключение стоило бы разбора, чья запись новее.
- Импорт истории годовой давности не превращает список чатов в стену непрочитанных и не отчёркивает её линией «новые».
- Архив, собранный устройством, у которого что-то не ушло, не заставляет второе устройство отправлять это за него.
- Неотправленное и отвергнутое живут ровно на одном устройстве. Потеря устройства без экспорта уносит их — как и всё, что не успело стать историей.
- Чат, убранный из списка на одном устройстве, на другом виден: вместе с ним видна и привезённая переписка. Комната, из которой мы вышли, прячется обратно при следующем `ready` — состав комнаты знает сервер (ADR-044).
- Чат, у которого в архиве нет ни одной строки истории, приезжает пустым и встаёт в конец списка: `lastId` у него пуст.
@@ -0,0 +1,24 @@
# ADR-051: Кнопка «экспортировать» в подтверждениях и тексты архива
## Контекст
История на устройстве — единственная копия, и стирают её два экрана: «выйти» в настройках и вход под другим ником (ADR-029). `docs/ui.md` держит кнопку «экспортировать» только в первом из них, потому что второго ADR-029 коснулся тогда, когда экспорта не существовало вовсе, и прямо пообещал: «когда он появится, кнопка придёт сюда тем же порядком — сначала `docs/ui.md`». Экспорт появился.
Ко второму экрану вопросов нет: секрет прежнего аккаунта лежит на устройстве, а сессия экспорту не нужна — архив собирается из IndexedDB (ADR-014).
Тексты раздела «история» перечислены, но двух вещей в них нет. «добавлено N сообщений» не сходится с числом: при одном сообщении получается «добавлено 1 сообщений». И отказ, который не про файл: истории не прочитать, места на устройстве нет, ключей аккаунта нет — в перечне ответов такого нет, а показывать его надо: молчащая кнопка «экспортировать» перед стиранием истории — худший из возможных исходов.
## Решение
- Подтверждение входа под другим ником получает третью кнопку: «экспортировать», «удалить», «отмена». Порядок и место — как у подтверждения выхода: «экспортировать», «выйти», «отмена».
- Экспорт подтверждение не закрывает: архив скачался, а стирать историю или нет — отдельное решение того же человека.
- Начальный фокус в обоих подтверждениях — на «экспортировать»: с неё безопасно начинать.
- «добавлено N сообщений» согласуется с числом: «добавлено 1 сообщение», «добавлено 2 сообщения», «добавлено 5 сообщений».
- «Тексты состояний» получают две строки: «экспорт не удался» — архив не собрался; «импорт не удался» — разобранный архив не дошёл до базы. Порча самого файла и чужой архив говорят о себе своими словами, они уже в перечне.
- Всё перечисленное записано в `docs/ui.md`: «Вход и регистрация», «Настройки», «Тексты состояний».
## Следствия
- Единственная копия истории не исчезает без предложения сохранить её ни на одном экране.
- Кнопок в подтверждении три, и в один ряд они помещаются не всегда: «экспортировать» в моноширинном шрифте шире трети колонки настроек, а насколько — решает системный шрифт платформы. Панель переносит их сама, цель нажатия остаётся 44 px.
- Обещание ADR-029 закрыто.
@@ -0,0 +1,25 @@
# ADR-052: Своё устройство из настроек не удаляется; занятое место — оценка браузера
## Контекст
`docs/ui.md` описывает раздел «устройства» одной строкой: список `id` (первые 8 символов), дата, «это устройство», «удалить». Три вещи в ней не решены, а решить их надо в коде.
Первая — что делает «удалить» у своей строки. `DELETE /api/devices/{id}` уносит очередь, подписку и сессии устройства (`docs/protocol.md`). На своём это означает: следующий же запрос получает `401 unauthenticated` и по `docs/ui.md` («Сеть и состояния») уводит на экран входа с целой IndexedDB. Выходом это не является: «выйти» стирает историю и сначала предлагает её сохранить (ADR-051). Получается третье состояние, которого в документе нет, — выход без вопроса и без стирания, с прежним `deviceId` в `meta`, который при следующем входе заведёт устройство заново.
Вторая — какая дата. Сервер отдаёт две: `createdAt` и `lastSeen`.
Третья — что такое `N` в «занято N МБ». Байты `storage.estimate()` в мегабайтах дают дробь с десятком знаков, а браузер, который `estimate()` не умеет, не даёт и её.
## Решение
- Своё устройство из раздела не удаляется. У своей строки вместо кнопки стоит пометка «это устройство». Отцепляет текущее устройство «выйти»: там и вопрос про историю, и стирание базы.
- Сервер не меняется: `DELETE /api/devices/{id}` принимает любое своё устройство, включая текущее. Запрет — правило экрана, а не протокола: устройство, потерявшее сессию с чужой руки, обязано оставаться рабочим сценарием.
- Дата в строке — дата появления устройства (`createdAt`), в местной зоне, цифрами: `22.08.2026`. `lastSeen` не показывается: список нужен, чтобы узнать своё среди чужих и отцепить лишнее.
- «занято N МБ» — `usage` из `navigator.storage.estimate()`, МБ равен 1024×1024 байтам. Число человеческое: до десятых, пока меньше десяти, дальше целое; десятые округляются вверх, потому что пара сотен килобайт — это не «0 МБ». Браузер без `estimate()` строки не получает: писать в неё нечего.
- Записано в `docs/ui.md`, «Настройки».
## Следствия
- Потерянное устройство отцепляется с любого другого; текущее — выходом.
- Кнопки «удалить» у своей строки нет никогда, даже когда устройство одно.
- Занятое место — оценка происхождения целиком, а не сумма длин записей: индексы и служебные страницы IndexedDB тоже место. Она же намеренно грубая у самого браузера, и точнее показывать нечего.
@@ -0,0 +1,27 @@
# ADR-053: Лента страницами по 50, без виртуализации списка
Уточняет [ADR-009](009-local-history.md): пагинация курсором остаётся, виртуализация снимается.
## Контекст
ADR-009 задаёт одной строкой две разные вещи: «пагинация курсором по ~50 сообщений, виртуализация списка в DOM». Первая — про данные и решает настоящую задачу: чат в десять тысяч сообщений не должен читаться из IndexedDB целиком при открытии. Вторая — про разметку, и её цена выяснилась только на этапе 5.
Виртуализация требует знать высоту строки до отрисовки. В ленте её нет: текст переносится, на десктопе строка — две ячейки грида через `display: contents` (`docs/identity/brief.md`), сообщение бывает в одну строку и в тридцать. Значит нужны измерение каждой строки, распорки сверху и снизу и пересчёт при смене ширины окна. Платят за это не только кодом: `aria-live` на ленте (`docs/ui.md`, «Доступность») зачитывает появление и исчезновение строк, а поиск по странице и выделение текста перестают видеть то, что убрано из разметки.
Выгоды при этом нет. В DOM попадает не вся история, а только то, что человек домотал прокруткой: открытие чата — 50 строк независимо от размера переписки.
## Решение
- Виртуализации в v1 нет. В разметке живёт всё загруженное.
- Лента открывается последней страницей в 50 сообщений и стоит в конце.
- Прокрутка к верхнему краю берёт следующие 50 назад по индексу `chat` (`docs/storage.md`). Страница короче полной означает, что выше ничего нет.
- Расстояние до низа при подгрузке сохраняется: то, что человек читает, не двигается.
- Страница короче окна прокрутки события `scroll` не порождает, поэтому следующая берётся сразу — пока лента не заполнит окно или сообщения не кончатся.
- Разделители дат и «новые» считаются по всему загруженному, а не по последней странице: граница «новых» уезжает вверх вместе с подгруженным.
- Записано в `docs/ui.md` («Чат») и `docs/architecture.md`.
## Следствия
- Домотавший до начала переписки в десять тысяч сообщений держит их все в разметке. Это его прокрутка и его выбор; обычное открытие чата — 50 строк.
- Экранный диктор, поиск по странице и выделение работают как в обычном документе.
- Возврат виртуализации — отдельный ADR, если появится жалоба, а не предположение.
@@ -0,0 +1,26 @@
# ADR-054: Архив — недоверенный ввод: форму записей проверяет клиент
## Контекст
Архив собрал владелец аккаунта: ключ выводится из секрета аккаунта, а заголовок целиком лежит под тегом AEAD (ADR-014). Отсюда легко сделать неверный вывод — что содержимому файла можно верить.
Разбирается он на устройстве и ложится в базу рядом с настоящей историей. На сетевом пути форму держит сервер (`internal/api/valid.go`): ник — `[a-z0-9_]{2,32}` (ADR-019), идентификаторы — 22 символа base64url (`docs/crypto.md`), `ts` сервер ставит сам (ADR-017). Поэтому `sync.js` и обходится проверкой типа. У архива такой опоры нет: тег AEAD ловит порчу, но всё, что лежит под тегом, написал клиент — своей же прошлой или будущей версии. Архив живёт дольше версии, которая его собрала, и его разбор — единственное место, где клиент ест данные, которых больше никто не проверял.
Цена видна на двух примерах. `ts` вне диапазона `Date` роняет отрисовку ленты на своей строке: `Intl` бросает `RangeError`, лента обрывается, чат не открывается больше никогда. Чат с ником не по форме нельзя ни открыть маршрутом (`docs/ui.md`, «Каркас»), ни убрать из списка — карточка контакта до такого ника не доходит. Убрать негодную запись из базы нечем: экрана для этого нет и не будет.
Отдельный вопрос — незнакомая версия. Клиент отвечает на неё тем же текстом, что и на порчу, а `docs/ui.md` этого не говорит.
## Решение
- Форма проверяется при разборе, до записи в базу. Ник — `[a-z0-9_]{2,32}` (ADR-019); `roomId` — 22 символа base64url (`docs/crypto.md`, «Идентификаторы»); `id` сообщения — ULID; `ts` — целое от нуля до 8 640 000 000 000 000 (предел `Date`). Автор сообщения в личном чате — свой ник или ник собеседника: третьего в переписке двоих не бывает. В комнате автором бывает и вышедший участник, поэтому там сверяется только форма ника.
- Что не по форме, до базы не доходит: пропускается запись целиком, а не поле.
- Ничего, что устройство может посчитать само, из архива не читается: `lastId` чата считается по добавленному (ADR-050).
- Незнакомая версия — файла в заголовке или нагрузки в поле `v` — показывается как «файл повреждён». Третьего текста нет: разобрать такой архив это устройство всё равно не может, а строку под формат, которого ещё нет, пришлось бы придумывать.
- Записано в `docs/storage.md` и `docs/crypto.md`.
## Следствия
- Запись, которую нельзя ни открыть, ни убрать, в базу не попадает.
- Правила формы живут в двух местах: на сервере — для сети, в клиенте — для архива. Это цена того, что архив приходит с диска, а не из протокола.
- Архив будущей версии старый клиент назовёт повреждённым. Цена принята: версия формата пока одна, а вторая заведёт свой текст тем же порядком — сначала `docs/ui.md`.
- Проверка не защищает от оператора и не претендует на это: подделать архив без секрета аккаунта нельзя, а порчу ловит тег AEAD. Она защищает от собственных ошибок — от того, что записал клиент другой версии, и от того, что запишет он же завтра.
+5 -1
View File
@@ -149,4 +149,8 @@ peers key: nick
## Экспорт `.bare`
Полезная нагрузка — `chats` (без `unread`, `lastReadId`), `messages` (без `raw` и `error`, только с `text`), `peers` (без `pending`). Формат файла и шифрование — `docs/crypto.md`. Имя файла — `bare-<nick>-<YYYY-MM-DD>.bare`.
Полезная нагрузка — `chats` (без `lastId`, `unread`, `lastReadId`, `hidden`), `messages` (без `raw` и `error`), `peers` (без `pending`). Уносится только отправленное — `sent`. Неотправленное и отвергнутое остаются устройству: `pending` и `failed` — незаконченная и отвергнутая попытки, привязанные к своему ULID (ADR-036), а не история. Нерасшифрованное не уносится: без текста от записи остаётся один заголовок, а `raw` — служебное поле. Показания устройства — место чата в списке, счётчики и «убрано из списка» — не уносятся тоже (ADR-050). Формат файла и шифрование — `docs/crypto.md`. Имя файла — `bare-<nick>-<YYYY-MM-DD>.bare`.
Архив — недоверенный ввод: форму каждой записи клиент проверяет сам, до записи в базу (ADR-054). Ник — `[a-z0-9_]{2,32}`, `roomId` — 22 символа base64url, `id` сообщения — ULID, `ts` — целое в пределах `Date`; автор сообщения в личном чате — свой ник или ник собеседника. Что не по форме, до базы не доходит: пропускается запись целиком, а не поле.
Импорт вливает архив одной транзакцией и не трогает то, что уже лежит (ADR-050): сообщение и чат с известным `id` остаются как есть, `unread` и `hidden` не меняются, запись `peers` добавляется только для ника, которого в TOFU ещё нет. У чата двигается `lastId` — под самое новое из добавленного, — и вместе с ним граница «новых»: `lastReadId` уезжает под новый `lastId`, пока непрочитанных у чата нет. Ответ — число добавленных сообщений; повторный импорт того же файла добавляет ноль.
+8 -4
View File
@@ -18,7 +18,7 @@
Кнопка одна, в стиле строки ввода. Пока идёт PBKDF2 — состояние «вычисляем ключ…», кнопка заблокирована. Ошибки — строкой под формой цветом `mark`: «неверный ник или пароль», «ник занят», «ник: 2–32 символа, a–z, 0–9, _», «нужен инвайт-код», «инвайт-код не подходит». Форму ника и длину пароля клиент проверяет сам, до PBKDF2, в обоих режимах. Остальные состояния — «Тексты состояний».
Если на устройстве лежат ключи другого ника, до вычисления ключа — подтверждение «на этом устройстве история @nick. вход под другим ником удалит её.» с кнопками «удалить» и «отмена» (ADR-029). База стирается после успешного входа или регистрации; отказ сервера её не трогает.
Если на устройстве лежат ключи другого ника, до вычисления ключа — подтверждение «на этом устройстве история @nick. вход под другим ником удалит её.» с кнопками «экспортировать», «удалить» и «отмена» (ADR-029, ADR-051). «экспортировать» скачивает `.bare` прежнего аккаунта и подтверждение не закрывает; сессия для этого не нужна. База стирается после успешного входа или регистрации; отказ сервера её не трогает.
## Список чатов (сайдбар)
@@ -34,6 +34,8 @@
Лента: десктоп — сетка «автор 132 px + текст», подряд идущие сообщения одного автора — без повтора автора; мобильный — автор над группой. Свой ник в колонке автора — цветом `mark`. Разделители дат — линия с датой; «новые» — линия цветом `mark` перед первым непрочитанным, исчезает при следующем открытии чата. Pending — текст цветом `stone`; failed — с пометкой «не отправлено · повторить». Нерасшифрованное — курсивом: «не удалось расшифровать: ключ изменился» / «…: нет ключа комнаты». Время — `ts` в локальной зоне, `ЧЧ:ММ`.
Лента открывается последними 50 сообщениями и стоит в конце. Прокрутка к верхнему краю подгружает следующие 50; то, что человек читает, при этом не двигается. Загруженное остаётся в разметке целиком — виртуализации нет (ADR-053).
Ввод: рамка 1 px ink, слева `>` цветом `mark`, placeholder «сообщение в #general» / «сообщение». Enter — отправить, Shift+Enter — перенос; на мобильном Enter — перенос, отправка — кнопка `>` справа. Подсказка «enter — отправить» только на десктопе. Лимит 4000 — счётчик появляется после 3500.
Предупреждение о ключе — полоса над вводом цветом `mark`: «ключ @marta изменился. сверьте отпечаток лично. [доверять новому ключу]». Ввод заблокирован до подтверждения. Полоса одна: предупреждение о ключе перебивает отказ отправки и «нет соединения» (ADR-038).
@@ -59,10 +61,10 @@
- «ты: @nick», свой отпечаток.
- «уведомления»: состояние (`включены` / `выключены` / `запрещены в браузере`), кнопка «включить» или «выключить». `запрещены в браузере` — разрешение отклонено или уведомлений в браузере нет вовсе; кнопки в этом состоянии нет (ADR-046). На iOS вне PWA — состояние `выключены` и вместо кнопки текст про установку, тот же, что в баннере.
- «установить приложение»: кнопка «установить», если есть `beforeinstallprompt`; на iOS вне PWA — инструкция «поделиться → на экран «домой»». Устанавливать нечего — раздела нет.
- «устройства»: список `id` (первые 8 символов), дата, «это устройство», «удалить».
- «история»: «занято N МБ»; «экспорт» → скачивание `.bare`; «импорт» → выбор файла → «добавлено N сообщений» / «архив создан другим аккаунтом» / «файл повреждён».
- «устройства»: список `id` (первые 8 символов), дата появления, «удалить». У своей строки кнопки нет: вместо неё пометка «это устройство». Своё устройство отсюда не отцепляется — это делает «выйти», где спрашивают про историю (ADR-052).
- «история»: «занято N МБ» — оценка браузера, до десятых, пока меньше десяти, дальше целые; браузер, который её не даёт, строки не показывает (ADR-052). «экспорт» → скачивание `.bare`; «импорт» → выбор файла → «добавлено N сообщений» / «архив создан другим аккаунтом» / «файл повреждён». Число согласуется со словом: «добавлено 1 сообщение», «добавлено 2 сообщения», «добавлено 5 сообщений» (ADR-051).
- «сменить пароль»: старый, новый, повтор; чекбокс «выйти на других устройствах». Ответ — «пароль изменён».
- «выйти»: подтверждение «история на этом устройстве будет удалена. экспортировать сначала?» с кнопками «экспортировать», «выйти», «отмена».
- «выйти»: подтверждение «история на этом устройстве будет удалена. экспортировать сначала?» с кнопками «экспортировать», «выйти», «отмена». «экспортировать» — то же скачивание, что и в разделе «история»; подтверждение оно не закрывает (ADR-051).
- «удалить аккаунт»: пароль + подтверждение «аккаунт и вся история будут удалены навсегда.» с кнопками «удалить» и «отмена».
## Баннер установки (iOS)
@@ -99,6 +101,8 @@
| ключевой блоб не разобран, не расшифрован или не соответствует публичному ключу | «ключ аккаунта повреждён» |
| `iter` блоба не равен ответу `GET /api/kdf` | «параметры ключа не совпали» |
| `401 invalid_credentials` в настройках | «неверный пароль» |
| архив не собрался: истории не прочитать или ключей аккаунта на устройстве нет | «экспорт не удался» |
| разобранный архив не дошёл до базы: нет места или ключей аккаунта | «импорт не удался» |
## Доступность
+49
View File
@@ -165,6 +165,17 @@ input[type="password"] {
width: auto;
}
/* три кнопки подтверждения в один ряд помещаются не всегда: переносятся
и делят место поровну (ADR-051) */
.row--wrap {
flex-wrap: wrap;
}
.row--wrap .button {
flex: 1 1 120px;
}
/* подписи и строки состояния: акцент mark — только ошибка */
.hint {
@@ -488,6 +499,44 @@ input[type="password"] {
color: var(--mute);
}
/* устройства — docs/ui.md, «Настройки». Строка: восемь символов
идентификатора, дата появления и «удалить»; у своей строки вместо
кнопки пометка (ADR-052) */
.devices {
margin: 0 0 12px;
padding: 0;
list-style: none;
}
.device {
display: flex;
align-items: center;
gap: 10px;
min-height: 28px;
font-size: 13px;
color: var(--text2);
}
.device__id {
flex: none;
}
.device .tag {
color: var(--stone);
font-size: 11px;
}
.device .tag:last-child {
margin-left: auto;
}
.device .link {
margin-left: auto;
color: var(--mute);
font-size: 12px;
}
/* баннер установки — docs/ui.md, «Баннер установки (iOS)».
Цель нажатия у крестика — 44 px, отрицательные поля не дают ей
растянуть сам баннер */
+135 -3
View File
@@ -105,6 +105,20 @@ export function fingerprintGroups(fingerprint) {
return fingerprint.match(/.{1,4}/g) ?? [];
}
// sameBytes — побайтное сравнение. Постоянного времени здесь не нужно:
// сравниваются отпечатки публичных ключей, а они не секрет.
export function sameBytes(a, b) {
if (a.length !== b.length) {
return false;
}
for (let i = 0; i < a.length; i += 1) {
if (a[i] !== b[i]) {
return false;
}
}
return true;
}
// wipe затирает сырые байты, когда они больше не нужны.
export function wipe(bytes) {
if (bytes instanceof Uint8Array) {
@@ -193,10 +207,17 @@ export async function importSecret(bytes) {
return subtle.importKey("raw", bytes, "HKDF", false, ["deriveKey", "deriveBits"]);
}
// fingerprint — SHA-256 несжатой точки публичного ключа, 64 hex строчными.
export async function fingerprint(publicKey) {
// fingerprintBytes — отпечаток сырыми байтами: SHA-256 несжатой точки
// публичного ключа, 32 байта. В заголовке архива лежат именно они,
// а не hex-строка (ADR-014).
export async function fingerprintBytes(publicKey) {
const raw = await subtle.exportKey("raw", publicKey);
return hex(await subtle.digest("SHA-256", raw));
return new Uint8Array(await subtle.digest("SHA-256", raw));
}
// fingerprint — тот же отпечаток для человека: 64 hex строчными.
export async function fingerprint(publicKey) {
return hex(await fingerprintBytes(publicKey));
}
export async function fingerprintOf(jwk) {
@@ -424,3 +445,114 @@ export async function openMessage(key, { id, chat, from, keyId, iv, ct }) {
}
return parsed.t;
}
// --- архив .bare -------------------------------------------------------
// Формат файла — docs/crypto.md, «Экспорт .bare»:
//
// header = "BARE" (4) || version u8 = 1 || salt (16) || fingerprint (32)
// || iv (12) // 65 байт
// file = header || AES-GCM(exportKey, iv, payload, AAD = header)
//
// Заголовок открыт и целиком входит в AAD: подмена любого его байта ломает
// расшифровку. Ника владельца в нём нет — это лишняя утечка (ADR-014).
const EXPORT_INFO = "bare-export-v1";
const MAGIC = "BARE";
// ARCHIVE_VERSION — версия формата файла. Не версия полезной нагрузки:
// та лежит внутри, полем v, и считается отдельно.
const ARCHIVE_VERSION = 1;
const SALT_LEN = 16;
const FP_LEN = 32;
const MAGIC_AT = 0;
const VERSION_AT = 4;
const SALT_AT = 5;
const FP_AT = SALT_AT + SALT_LEN;
const IV_AT = FP_AT + FP_LEN;
// HEADER_LEN — 65 байт, ровно как в docs/crypto.md.
const HEADER_LEN = IV_AT + IV_LEN;
// Тег AES-GCM — 16 байт: короче шифротекста не бывает даже у пустого архива.
const TAG_LEN = 16;
// archiveKey — ключ одного экспорта: HKDF из секрета аккаунта со случайной
// солью (ADR-014). Секрет — non-extractable CryptoKey типа HKDF; сырых байт
// у клиента нет и быть не должно.
function archiveKey(secret, salt) {
return subtle.deriveKey(
{ name: "HKDF", hash: "SHA-256", salt, info: utf8(EXPORT_INFO) },
secret,
{ name: "AES-GCM", length: 256 },
false,
["encrypt", "decrypt"],
);
}
function archiveHeader(salt, fingerprint, iv) {
const header = new Uint8Array(HEADER_LEN);
header.set(utf8(MAGIC), MAGIC_AT);
header[VERSION_AT] = ARCHIVE_VERSION;
header.set(salt, SALT_AT);
header.set(fingerprint, FP_AT);
header.set(iv, IV_AT);
return header;
}
// sealArchive шифрует полезную нагрузку и собирает файл целиком.
// fingerprint — 32 сырых байта отпечатка владельца.
export async function sealArchive(secret, fingerprint, payload) {
if (fingerprint.length !== FP_LEN) {
throw new Error("отпечаток — не 32 байта");
}
const salt = random(SALT_LEN);
const iv = random(IV_LEN);
const header = archiveHeader(salt, fingerprint, iv);
const ct = await subtle.encrypt(
{ name: "AES-GCM", iv, additionalData: header },
await archiveKey(secret, salt),
payload,
);
const file = new Uint8Array(HEADER_LEN + ct.byteLength);
file.set(header);
file.set(new Uint8Array(ct), HEADER_LEN);
return file;
}
// parseArchive читает заголовок, ничего не расшифровывая: отпечаток
// владельца сверяется до вывода ключа (ADR-014). Чужая магия, чужая версия
// и файл короче заголовка с тегом — null.
export function parseArchive(bytes) {
if (!(bytes instanceof Uint8Array) || bytes.length < HEADER_LEN + TAG_LEN) {
return null;
}
const magic = utf8(MAGIC);
for (let i = 0; i < magic.length; i += 1) {
if (bytes[MAGIC_AT + i] !== magic[i]) {
return null;
}
}
if (bytes[VERSION_AT] !== ARCHIVE_VERSION) {
return null;
}
return {
header: bytes.subarray(0, HEADER_LEN),
salt: bytes.subarray(SALT_AT, FP_AT),
fingerprint: bytes.subarray(FP_AT, IV_AT),
iv: bytes.subarray(IV_AT, HEADER_LEN),
ct: bytes.subarray(HEADER_LEN),
};
}
// openArchive расшифровывает разобранный файл. Ошибка AEAD — единственный
// признак порчи: заголовок целиком в AAD, а всё остальное под тегом.
export async function openArchive(secret, archive) {
const plain = await subtle.decrypt(
{ name: "AES-GCM", iv: archive.iv, additionalData: archive.header },
await archiveKey(secret, archive.salt),
archive.ct,
);
return new Uint8Array(plain);
}
+110
View File
@@ -438,6 +438,116 @@ export function putPeer(record) {
return put("peers", record);
}
// --- архив --------------------------------------------------------------
// allMessages и allPeers отдают хранилище целиком: архив .bare уносит всю
// историю устройства. Какие поля в него попадают, решает export.js — база
// отдаёт записи как есть (docs/storage.md, «Экспорт .bare»).
export async function allMessages() {
const db = await open();
return value(db.transaction("messages", "readonly").objectStore("messages").getAll());
}
export async function allPeers() {
const db = await open();
return value(db.transaction("peers", "readonly").objectStore("peers").getAll());
}
// mergeArchive вливает разобранный архив одной транзакцией: половина
// импорта хуже, чем ничего.
//
// Слияние идемпотентное по id сообщений и id чатов (docs/crypto.md):
// известная запись не трогается, а запись peers добавляется только для
// ника, которого в TOFU ещё нет. Своя запись всегда права — у неё есть
// состояние отправки, которого в архиве нет (ADR-050).
//
// Счётчик непрочитанных и «убрано из списка» — местные: импорт приносит
// историю, а не показания счётчиков. Место чата в списке при этом меняется:
// lastId растёт под самое новое из добавленного, и вместе с ним уезжает
// граница «новых» — пока непрочитанного у чата нет, ей нечего отчёркивать,
// а оставшись позади, она отчеркнула бы всю привезённую переписку при
// первом же входящем (ADR-050).
//
// Отдаёт число добавленных сообщений и ключи затронутых чатов.
export async function mergeArchive({ chats: list = [], messages = [], peers = [] } = {}) {
const db = await open();
const tx = db.transaction(["chats", "messages", "peers"], "readwrite");
const chatStore = tx.objectStore("chats");
const messageStore = tx.objectStore("messages");
const peerStore = tx.objectStore("peers");
// Все чтения — одним заходом до первой записи: что уже лежит в базе,
// надо знать целиком, а запросы этой же транзакции держат её живой.
const [ids, nicks, known] = await Promise.all([
value(messageStore.getAllKeys()),
value(peerStore.getAllKeys()),
value(chatStore.getAll()),
]);
const seen = new Set(ids);
const trusted = new Set(nicks);
const records = new Map(known.map((record) => [record.id, record]));
const touched = new Set();
for (const chat of list) {
if (records.has(chat.id)) {
continue;
}
// Показания устройства в архив не пишутся (docs/storage.md) — у новой
// записи они с чистого листа: место в списке считается по добавленному,
// счётчик пуст, чат в списке виден.
records.set(chat.id, {
...blankChat(chat.id),
...chat,
lastId: null,
lastReadId: null,
unread: 0,
hidden: false,
});
touched.add(chat.id);
}
let added = 0;
for (const record of messages) {
if (seen.has(record.id)) {
continue;
}
seen.add(record.id);
messageStore.put(record);
added += 1;
let chat = records.get(record.chatId);
if (!chat) {
chat = blankChat(record.chatId);
records.set(record.chatId, chat);
}
if (!chat.lastId || chat.lastId < record.id) {
chat.lastId = record.id;
}
touched.add(record.chatId);
}
for (const record of peers) {
if (trusted.has(record.nick)) {
continue;
}
trusted.add(record.nick);
peerStore.put(record);
}
for (const id of touched) {
const record = records.get(id);
// Граница «новых» едет за лентой, пока непрочитанного нет: счётчик
// и граница считаются от одной точки, иначе первое же входящее
// отчеркнёт «новыми» всю привезённую переписку. У чата с непрочитанным
// граница уже показывает на него и остаётся на месте (ADR-050).
if (record.unread === 0) {
record.lastReadId = record.lastId;
}
chatStore.put(record);
}
await done(tx);
return { added, chats: [...touched] };
}
// persist просит браузер не вычищать базу: история на устройстве —
// единственная копия (docs/storage.md).
export async function persist() {
+342
View File
@@ -0,0 +1,342 @@
// Архив `.bare` — экспорт и импорт истории.
//
// История живёт только на устройстве (ADR-009), и архив — единственный
// способ перенести её на другое (ADR-010). Файл привязан к аккаунту
// криптографически: ключ выводится из секрета аккаунта, и у чужого клиента
// его нет (ADR-014). Формат — docs/crypto.md, «Экспорт .bare», состав
// полезной нагрузки — docs/storage.md.
//
// Модуль работает и без сессии: и история, и секрет аккаунта лежат
// на устройстве. Это и есть смысл кнопки «экспортировать» в подтверждении
// выхода и в подтверждении входа под другим ником (docs/ui.md).
import * as db from "./db.js";
import * as sync from "./sync.js";
import {
fingerprintBytes,
fingerprintOf,
importPublic,
openArchive,
parseArchive,
publicJwk,
sameBytes,
sealArchive,
utf8,
wipe,
} from "./crypto.js";
import { validUlid } from "./ulid.js";
// Версия полезной нагрузки — поле v внутри шифротекста (docs/crypto.md).
// Версия самого файла живёт в заголовке и считается отдельно.
const PAYLOAD_VERSION = 1;
// Тексты отказа — docs/ui.md, «Настройки», раздел «история».
const BROKEN = "файл повреждён";
const FOREIGN = "архив создан другим аккаунтом";
// Файл отдаётся как двоичный: своего типа у .bare нет и заводить его
// незачем.
const MIME = "application/octet-stream";
// ARCHIVE_EXT — расширение файла (docs/storage.md). Оно же уходит в accept
// выбора файла: предлагать человеку всё подряд незачем.
export const ARCHIVE_EXT = ".bare";
// Временный адрес живёт до конца скачивания: браузер читает Blob по нему
// уже после click. Минута — с запасом на медленный диск.
const REVOKE_AFTER = 60_000;
const decoder = new TextDecoder();
// ArchiveError — отказ импорта. Сообщение уже пригодно для показа
// человеку (ADR-028): причин у отказа ровно две, и обе — в docs/ui.md.
export class ArchiveError extends Error {
constructor(text) {
super(text);
this.name = "ArchiveError";
}
}
// --- экспорт ------------------------------------------------------------
// exportHistory собирает архив и отдаёт его браузеру на скачивание.
export async function exportHistory() {
const { nick, publicKey, accountSecret } = await db.meta(["nick", "publicKey", "accountSecret"]);
if (!nick || !publicKey || !accountSecret) {
throw new Error("на устройстве нет ключей аккаунта");
}
const fingerprint = await fingerprintBytes(await importPublic(publicKey));
const payload = utf8(JSON.stringify(await collect()));
let file;
try {
file = await sealArchive(accountSecret, fingerprint, payload);
} finally {
// Плейнтекст истории в памяти дальше не нужен.
wipe(payload);
}
save(file, fileName(nick));
}
// collect — полезная нагрузка (docs/storage.md, «Экспорт .bare»).
async function collect() {
const [chats, messages, peers] = await Promise.all([
// Скрытые чаты — тоже история: «убрать из списка» не удаление (ADR-019).
db.chats({ hidden: true }),
db.allMessages(),
db.allPeers(),
]);
return {
v: PAYLOAD_VERSION,
exportedAt: Date.now(),
chats: chats.map(chatRecord),
messages: messages.filter(archivable).map(messageRecord),
peers: peers.map(peerRecord),
};
}
// archivable — что из ленты попадает в архив: только отправленное.
// Нерасшифрованное не уносится: без текста в архиве от него остался бы один
// заголовок, а raw — служебное поле. Незаконченная и отвергнутая попытки
// не уносятся тоже: pending и failed привязаны к устройству и к своему
// ULID (ADR-036) — на другом устройстве «повторить» отправило бы то же
// сообщение вторым, а запись, ушедшая после повтора под свежим id,
// вернулась бы из архива дублем (ADR-050).
function archivable(record) {
return typeof record?.text === "string" && record.status === "sent";
}
// fileName — bare-<nick>-<YYYY-MM-DD>.bare (docs/storage.md). Дата местная:
// это день человека, а не UTC.
function fileName(nick) {
const now = new Date();
const day = [
String(now.getFullYear()).padStart(4, "0"),
String(now.getMonth() + 1).padStart(2, "0"),
String(now.getDate()).padStart(2, "0"),
].join("-");
return `bare-${nick}-${day}${ARCHIVE_EXT}`;
}
// save отдаёт файл браузеру: Blob, временный адрес и <a download>. Ни
// inline-скриптов, ни атрибутов-обработчиков это не требует, а CSP
// default-src 'self' скачиванию не мешает: сохранение файла — не подгрузка
// ресурса страницы (ADR-021).
function save(bytes, name) {
const url = URL.createObjectURL(new Blob([bytes], { type: MIME }));
const link = document.createElement("a");
link.href = url;
link.download = name;
link.hidden = true;
document.body.append(link);
link.click();
link.remove();
setTimeout(() => URL.revokeObjectURL(url), REVOKE_AFTER);
}
// --- импорт -------------------------------------------------------------
// importHistory разбирает выбранный файл и вливает его в базу. Порядок —
// docs/crypto.md: магия и версия, потом отпечаток владельца, и только потом
// ключ. Чужой архив не расшифровывается вовсе: сверка отпечатка — вежливость,
// настоящая защита в том, что секрета аккаунта у чужого клиента нет (ADR-014).
//
// Отдаёт число добавленных сообщений.
export async function importHistory(file) {
const { nick, publicKey, accountSecret } = await db.meta(["nick", "publicKey", "accountSecret"]);
if (!nick || !publicKey || !accountSecret) {
throw new Error("на устройстве нет ключей аккаунта");
}
let bytes;
try {
bytes = new Uint8Array(await file.arrayBuffer());
} catch {
// Файл не прочитался: для человека это то же самое, что порча.
throw new ArchiveError(BROKEN);
}
const archive = parseArchive(bytes);
if (archive === null) {
throw new ArchiveError(BROKEN);
}
const mine = await fingerprintBytes(await importPublic(publicKey));
if (!sameBytes(archive.fingerprint, mine)) {
throw new ArchiveError(FOREIGN);
}
const payload = await unpack(accountSecret, archive);
const { added, chats } = await db.mergeArchive({
chats: list(payload.chats).filter(usableChat).map(chatRecord),
messages: list(payload.messages).filter((record) => usableMessage(record, nick)).map(messageRecord),
peers: await peersOf(payload.peers),
});
if (chats.length > 0) {
sync.imported(chats);
}
return added;
}
// unpack расшифровывает и разбирает нагрузку. Порча заголовка, порча
// шифротекста и мусор внутри — одно и то же для человека: файл повреждён.
async function unpack(secret, archive) {
let payload;
try {
payload = JSON.parse(decoder.decode(await openArchive(secret, archive)));
} catch {
throw new ArchiveError(BROKEN);
}
if (payload === null || typeof payload !== "object" || payload.v !== PAYLOAD_VERSION) {
throw new ArchiveError(BROKEN);
}
return payload;
}
function list(value) {
return Array.isArray(value) ? value : [];
}
// --- записи -------------------------------------------------------------
//
// Один и тот же отбор полей работает в обе стороны: что уходит в архив,
// то и приходит из него. Всё, чего в этих функциях нет, до базы не доходит.
//
// Место чата в списке, счётчик непрочитанных, граница «новых» и «убрано
// из списка» — показания устройства, а не история: lastId, unread,
// lastReadId и hidden в архив не пишутся (ADR-050). У lastId причина
// вторая: он указывает на последнюю строку чата, а ею бывает и та,
// которой в архиве нет, — неотправленная или нерасшифрованная. Устройство,
// принявшее архив, считает его само — по тому, что действительно добавило.
function chatRecord(chat) {
const record = {
id: chat.id,
type: chat.type,
title: chat.title,
};
if (chat.type === "dm") {
record.peer = chat.peer;
} else {
record.roomId = chat.roomId;
}
// Владелец и состав есть только у комнаты и приходят от сервера: пока
// комната не перечитана, их может не быть вовсе.
if (typeof chat.owner === "string") {
record.owner = chat.owner;
}
if (Array.isArray(chat.members)) {
record.members = chat.members.filter((nick) => typeof nick === "string");
}
return record;
}
function messageRecord(record) {
return {
id: record.id,
chatId: record.chatId,
from: record.from,
text: record.text,
ts: record.ts,
status: record.status,
};
}
function peerRecord(record) {
return {
nick: record.nick,
publicKey: publicJwk(record.publicKey),
fingerprint: record.fingerprint,
firstSeen: record.firstSeen,
};
}
// peersOf — записи TOFU из архива. Отпечаток считается заново из ключа:
// человек сверяет голосом именно его, и брать его на веру из файла рядом
// с ключом нельзя (ADR-016). Ключ, из которого отпечаток не считается, —
// не ключ, такая запись пропускается.
async function peersOf(peers) {
const out = [];
for (const record of list(peers)) {
if (!usablePeer(record)) {
continue;
}
try {
out.push({
...peerRecord(record),
fingerprint: await fingerprintOf(record.publicKey),
// Ждущий подтверждения ключ в архив не пишется (docs/storage.md).
pending: null,
});
} catch {
// Не ключ.
}
}
return out;
}
// --- разбор архива ------------------------------------------------------
//
// Архив собрал владелец аккаунта — чужой его не соберёт (ADR-014), — но
// разбирается он на устройстве и ложится в базу рядом с настоящей историей.
// На сетевом пути форму держит сервер (internal/api/valid.go), поэтому
// sync.js обходится проверкой типа; у файла с диска такой опоры нет, и
// форму проверяет клиент, до записи (ADR-054). Запись, которую потом
// нельзя ни открыть, ни убрать, лежала бы в базе навсегда.
// Ник — форма ADR-019; roomId — 16 случайных байт base64url, 22 символа
// (docs/crypto.md, «Идентификаторы»).
const NICK = /^[a-z0-9_]{2,32}$/;
const ROOM_ID = /^[A-Za-z0-9_-]{22}$/;
// MAX_TS — предел Date: дальше `new Date(ts)` не дата вовсе, а лента
// падает на такой строке целиком (ADR-054).
const MAX_TS = 8.64e15;
// known — ключ чата по форме docs/storage.md: «dm:<ник>» или «room:<id>».
function known(chatId) {
if (typeof chatId !== "string") {
return false;
}
const peer = db.peerOf(chatId);
if (peer !== null) {
return NICK.test(peer);
}
const roomId = db.roomIdOf(chatId);
return roomId !== null && ROOM_ID.test(roomId);
}
function usableChat(chat) {
if (chat === null || typeof chat !== "object" || !known(chat.id)) {
return false;
}
const peer = db.peerOf(chat.id);
// Вид чата задаёт его ключ: «dm:<ник>» или «room:<id>» (docs/storage.md).
if (chat.type !== (peer !== null ? "dm" : "room")) {
return false;
}
if (peer !== null ? chat.peer !== peer : chat.roomId !== db.roomIdOf(chat.id)) {
return false;
}
return typeof chat.title === "string";
}
// usableMessage — строка истории. Автор в личном чате — свой ник или ник
// собеседника: третьего в переписке двоих не бывает. В комнате автором
// бывает и вышедший участник, поэтому там сверяется только форма ника.
function usableMessage(record, me) {
if (record === null || typeof record !== "object" || !known(record.chatId)) {
return false;
}
const peer = db.peerOf(record.chatId);
if (peer !== null && record.from !== peer && record.from !== me) {
return false;
}
return typeof record.id === "string" && validUlid(record.id)
&& typeof record.from === "string" && NICK.test(record.from)
&& typeof record.text === "string"
&& Number.isSafeInteger(record.ts) && record.ts >= 0 && record.ts <= MAX_TS
&& record.status === "sent";
}
function usablePeer(record) {
return record !== null && typeof record === "object"
&& typeof record.nick === "string" && NICK.test(record.nick)
&& record.publicKey !== null && typeof record.publicKey === "object"
&& Number.isFinite(record.firstSeen);
}
+30
View File
@@ -62,6 +62,8 @@ const ctx = {
},
errorText,
ensureConfig,
devices,
removeDevice,
signUp,
signIn,
changePassword,
@@ -226,6 +228,34 @@ async function storedNick() {
}
}
// --- устройства ---------------------------------------------------------
// devices — устройства аккаунта для настроек (docs/protocol.md,
// «Устройства»). Своё сервер помечает по сессии; заодно сверяем
// с устройством этой вкладки: сессия привязывается к устройству
// в POST /api/devices, и до него current не проставлен (ADR-017).
async function devices() {
const list = await api.devices();
const mine = sync.deviceId();
if (!Array.isArray(list)) {
return [];
}
return list
.filter((item) => item !== null && typeof item === "object" && typeof item.id === "string")
.map((item) => ({
id: item.id,
createdAt: item.createdAt,
current: item.current === true || (mine !== null && item.id === mine),
}));
}
// removeDevice — «удалить» в настройках: очередь, подписка и сессии
// устройства уходят вместе с ним. Своё устройство сюда не приходит —
// его отцепляет «выйти» (ADR-052).
function removeDevice(id) {
return api.removeDevice(id);
}
// --- аккаунт -----------------------------------------------------------
// derive — вывод ключей по числу итераций, пришедшему от сервера. Границы
+19 -2
View File
@@ -98,8 +98,11 @@ const bus = new EventTarget();
//
// "net" {online} — доходят ли запросы до сервера
// "chats" {} — список чатов изменился
// "messages" {chatId, ids, removed} — в чате появились, изменились
// или исчезли сообщения
// "messages" {chatId, ids, removed, whole}
// — в чате появились, изменились
// или исчезли сообщения; whole
// означает «перечитай ленту
// целиком», без перечня (ADR-050)
// "peers" {nick} — доверие к ключу ника изменилось:
// появился pending или его подтвердили
// "rooms" {id} — комната изменилась: имя, состав,
@@ -174,6 +177,20 @@ function announceRoom(id) {
share({ kind: "rooms", id, blocked: needsTrust(id) });
}
// imported — импорт архива влил историю в базу (ADR-050). Перечня
// добавленного в событии нет: сообщений бывает несколько тысяч и они
// старые, поэтому лента перечитывается целиком, а не строка за строкой.
// Запись сделал export.js, здесь остаётся поднять экраны — свои
// и соседних вкладок (ADR-035).
export function imported(chatIds) {
const details = chatIds.map((chatId) => ({ chatId, ids: [], removed: [], whole: true }));
for (const detail of details) {
emit("messages", detail);
}
emit("chats");
share({ kind: "changed", details, settled: [] });
}
// --- соседние вкладки ---------------------------------------------------
// share отдаёт изменение соседним вкладкам. Канал открыт, только пока
+25 -4
View File
@@ -1,6 +1,7 @@
// Экран входа и регистрации — docs/ui.md, «Вход и регистрация».
import { clear, confirmPanel, el, field, mark, message, setError, setNote } from "./dom.js";
import { exportHistory } from "../export.js";
import { EXPORT_FAILED, clear, confirmPanel, el, field, mark, message, setError, setNote } from "./dom.js";
const HINT = "пароль — это ключ шифрования, а не запись в базе. восстановления нет. "
+ "не короче 12 символов; лучше — фраза из нескольких слов.";
@@ -58,8 +59,11 @@ function screen(ctx, view, paint) {
form.append(submit);
// На устройстве могут лежать ключи другого ника: вход под этим сотрёт
// историю прежнего, поэтому сначала подтверждение (ADR-029).
const wipe = confirmPanel("", "удалить");
// историю прежнего, поэтому сначала подтверждение (ADR-029). История
// на устройстве — единственная копия, и подтверждение предлагает сначала
// сохранить её: секрет прежнего аккаунта ещё здесь, экспорту сессия
// не нужна (ADR-014).
const wipe = confirmPanel("", "удалить", "экспортировать");
form.append(wipe.root);
const note = message();
@@ -101,6 +105,22 @@ function screen(ctx, view, paint) {
wipe.root.hidden = true;
submit.hidden = false;
};
// Экспорт подтверждение не закрывает: архив скачался, а входить или нет —
// отдельное решение.
wipe.extra.addEventListener("click", async () => {
if (wipe.extra.disabled) {
return;
}
wipe.extra.disabled = true;
fail("");
try {
await exportHistory();
} catch {
fail(EXPORT_FAILED);
} finally {
wipe.extra.disabled = false;
}
});
wipe.no.addEventListener("click", () => {
hideWipe();
submit.focus();
@@ -151,7 +171,8 @@ function screen(ctx, view, paint) {
wipe.text.textContent = `на этом устройстве история @${other}. вход под другим ником удалит её.`;
wipe.root.hidden = false;
submit.hidden = true;
wipe.yes.focus();
// Фокус — на «экспортировать»: с него безопасно начинать.
wipe.extra.focus();
return;
}
await run();
+157 -23
View File
@@ -34,6 +34,11 @@ const NAME_IN_HINT = 12;
// сообщение подматывает ленту только тогда, когда он и так смотрит конец.
const NEAR_BOTTOM = 80;
// Насколько близко к верхнему краю берётся следующая страница. Запас
// в экран: страница успевает приехать до того, как человек упрётся
// в край (ADR-053).
const NEAR_TOP = 200;
// renderChat рисует чат в root и отдаёт отписку.
export function renderChat(root, ctx, chatId) {
const view = {
@@ -53,9 +58,19 @@ export function renderChat(root, ctx, chatId) {
// Комнаты у нас больше нет: вышли сами, убрал владелец, комната
// удалена. Ввод заблокирован, лента остаётся (ADR-044).
gone: false,
// Лента: записи по возрастанию id и их строки в разметке.
// Лента: записи по возрастанию id и их разметка — строка и её
// разделители, id → {node, marks}.
items: [],
nodes: new Map(),
// Выше загруженного есть ещё сообщения: последняя страница пришла
// целой (ADR-053).
more: false,
// Страница уже едет: событий scroll приходит много подряд.
loading: false,
// Граница «новых» на момент открытия: было ли непрочитанное и докуда
// читали. Сама граница считается по всему загруженному — подгрузка
// вверх двигает её выше (ADR-053).
mark: { unread: false, bound: null },
// Граница «новых»: первый непрочитанный на момент открытия.
newId: null,
chain: Promise.resolve(),
@@ -67,6 +82,12 @@ export function renderChat(root, ctx, chatId) {
view.body = el("div", "grid");
view.body.setAttribute("aria-live", "polite");
view.feed.append(view.body);
// Прокрутка к верхнему краю берёт следующую страницу (ADR-053).
view.feed.addEventListener("scroll", () => {
if (view.feed.scrollTop <= NEAR_TOP) {
pull(view);
}
});
root.append(view.feed);
root.append(composer(view));
@@ -258,27 +279,133 @@ async function load(view) {
return;
}
view.items = list;
view.newId = firstUnread(record, list, view.me);
// Страница пришла целой — выше есть ещё (ADR-053).
view.more = list.length >= sync.PAGE;
view.mark = { unread: (record?.unread ?? 0) > 0, bound: record?.lastReadId ?? null };
view.newId = firstUnread(view.mark, list, view.me);
paint(view, true);
// Фокус в строку ввода при открытии чата на десктопе (docs/ui.md,
// «Доступность»); на мобильном это подняло бы клавиатуру на весь экран.
if (wide()) {
view.field.focus();
}
reach(view);
await read(view);
}
// firstUnread — граница «новых»: первый чужой непрочитанный. Своё
// непрочитанным не бывает, поэтому и границей не становится.
function firstUnread(record, list, me) {
if (!record || record.unread <= 0) {
//
// Считается по всему загруженному: чат с сотней непрочитанных открывается
// последней страницей, и первый из них лежит выше — граница находится,
// когда до него домотают (ADR-053).
function firstUnread(mark, list, me) {
if (!mark.unread) {
return null;
}
const bound = record.lastReadId;
const found = list.find((m) => m.from !== me && (!bound || m.id > bound));
const found = list.find((m) => m.from !== me && (!mark.bound || m.id > mark.bound));
return found ? found.id : null;
}
// --- страницы -----------------------------------------------------------
// pull просит следующую страницу. Событий scroll приходит много подряд,
// поэтому вход закрывается до постановки в очередь.
function pull(view) {
if (!view.more || view.loading || !view.alive) {
return;
}
view.loading = true;
run(view, () => older(view));
}
// older дописывает страницу сверху: курсор по индексу «chat» назад
// от самого старого загруженного, по 50 (docs/storage.md, ADR-053).
// Страница короче полной означает, что выше ничего нет.
async function older(view) {
const first = view.items[0] ?? null;
try {
const list = await sync.messagesBefore(view.chatId, first ? first.id : null);
if (!view.alive) {
return;
}
if (list.length < sync.PAGE) {
view.more = false;
}
if (list.length === 0) {
return;
}
view.items = [...list, ...view.items];
keep(view, () => grow(view, list, first));
} catch {
// Базы нет — оставляем то, что уже загружено.
} finally {
view.loading = false;
reach(view);
}
}
// grow дописывает страницу сверху, не пересобирая ленту: нарисованное
// переживает подгрузку — выделение текста не пропадает, а живая область
// не зачитывается экранным диктором заново (ADR-053). Заново считаются две
// строки: та, что держала линию «новые», если граница уехала выше, и бывшая
// первая — у неё появился сосед сверху, а от соседа зависят разделитель
// даты и повтор автора.
function grow(view, list, head) {
const was = view.newId;
view.newId = firstUnread(view.mark, view.items, view.me);
const page = document.createDocumentFragment();
let previous = null;
for (const record of list) {
line(view, record, previous, page);
previous = record;
}
view.body.insertBefore(page, view.body.firstChild);
if (was !== null && was !== view.newId && (head === null || was !== head.id)) {
const at = view.items.findIndex((m) => m.id === was);
if (at > 0) {
reline(view, view.items[at], view.items[at - 1]);
}
}
if (head !== null) {
reline(view, head, previous);
}
}
// reline перерисовывает одну строку вместе с её разделителями: у неё
// сменился сосед сверху или уехала линия «новые».
function reline(view, record, previous) {
const old = view.nodes.get(record.id);
if (!old) {
return;
}
const next = old.node.nextSibling;
for (const node of old.marks) {
node.remove();
}
old.node.remove();
const box = document.createDocumentFragment();
line(view, record, previous, box);
view.body.insertBefore(box, next);
}
// keep сохраняет расстояние до низа ленты: подгрузка вверх не должна
// двигать то, что человек читает.
function keep(view, draw) {
const feed = view.feed;
const bottom = feed.scrollHeight - feed.scrollTop;
draw();
feed.scrollTop = feed.scrollHeight - bottom;
}
// reach берёт следующую страницу, когда прокручивать нечего: лента короче
// окна, события scroll не будет, а сообщения выше есть.
function reach(view) {
if (view.feed.scrollHeight <= view.feed.clientHeight) {
pull(view);
}
}
// read помечает чат прочитанным — после отрисовки: до этого lastReadId
// и есть граница «новых» (docs/storage.md).
async function read(view) {
@@ -293,6 +420,13 @@ async function read(view) {
// перерисовать: лента — живая область, и перерисовка заставила бы
// экранного диктора зачитать её целиком.
async function apply(view, detail) {
// Импорт архива приносит недостающую историю пачкой и в середину ленты:
// перечитать её целиком дешевле, чем вставлять сообщение за сообщением
// (ADR-050).
if (detail.whole === true) {
await load(view);
return;
}
const incoming = [];
for (const id of detail.ids ?? []) {
let record = null;
@@ -372,35 +506,35 @@ function paint(view, bottom) {
}
}
// 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));
// line дописывает сообщение в конец parent вместе с разделителями, которые
// перед ним нужны. Разделители принадлежат строке: подгрузка страницы
// сверху перерисовывает строку вместе с ними, а не всю ленту.
function line(view, record, previous, parent = view.body) {
const marks = [];
if (!previous || dayOf(previous.ts) !== dayOf(record.ts)) {
marks.push(divider(label(record.ts), false));
}
const fresh = record.id === view.newId;
if (fresh) {
view.body.append(divider("новые", true));
if (record.id === view.newId) {
marks.push(divider("новые", true));
}
// Подряд идущие сообщения одного автора — без повтора автора.
const first = day || fresh || !previous || previous.from !== record.from;
const first = marks.length > 0 || !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);
parent.append(...marks, node);
view.nodes.set(record.id, { node, marks });
}
// redraw обновляет одну строку на месте: автор и группировка от состояния
// сообщения не зависят.
function redraw(view, record) {
const node = view.nodes.get(record.id);
if (!node) {
const known = view.nodes.get(record.id);
if (!known) {
return;
}
const first = node.classList.contains("is-head");
clear(node);
node.append(author(view, record, first), text(view, record));
const first = known.node.classList.contains("is-head");
clear(known.node);
known.node.append(author(view, record, first), text(view, record));
}
function divider(caption, fresh) {
+21 -5
View File
@@ -76,20 +76,36 @@ export function button(text, className = "button") {
return node;
}
// confirmPanel — вопрос и две кнопки; спрятан, пока не спросили.
// Вопрос отдаётся наружу: его текст бывает известен только к моменту показа.
export function confirmPanel(question, yesLabel) {
// confirmPanel — вопрос и кнопки; спрятан, пока не спросили. Вопрос
// отдаётся наружу: его текст бывает известен только к моменту показа.
//
// extraLabel — необязательная третья кнопка перед «да». Там, где
// подтверждение уносит историю, это «экспортировать»: единственная копия
// не должна исчезать без предложения сохранить её (docs/ui.md, ADR-029).
// Отдаётся отдельным полем; без неё оно null.
export function confirmPanel(question, yesLabel, extraLabel = null) {
const root = el("div");
root.hidden = true;
const text = el("p", "confirm", question);
const yes = button(yesLabel);
const no = button("отмена");
const row = el("div", "row");
const extra = extraLabel === null ? null : button(extraLabel);
// Три кнопки в один ряд помещаются не всегда: «экспортировать» шире
// трети колонки, и ряд переносится (ADR-051).
const row = el("div", extra === null ? "row" : "row row--wrap");
if (extra !== null) {
row.append(extra);
}
row.append(yes, no);
root.append(text, row);
return { root, text, yes, no };
return { root, text, yes, no, extra };
}
// EXPORT_FAILED — экспорт не собрался: истории не прочитать или ключей
// аккаунта на устройстве нет (docs/ui.md, «Тексты состояний»). Текст один
// на все три места, где стоит кнопка «экспортировать».
export const EXPORT_FAILED = "экспорт не удался";
// message — строка состояния под формой: ошибка цветом mark, ответ — mute.
export function message() {
const node = el("p", "message");
+233 -9
View File
@@ -1,11 +1,22 @@
// Настройки — docs/ui.md, «Настройки». На этом этапе только разделы,
// которые уже работают: кто ты, уведомления, установка приложения, смена
// пароля, выход, удаление аккаунта. Устройства и история — дальше по плану.
// Настройки — docs/ui.md, «Настройки»: кто ты, уведомления, установка
// приложения, устройства, история, смена пароля, выход, удаление аккаунта.
import { ApiError } from "../api.js";
import { fingerprintGroups } from "../crypto.js";
import { ARCHIVE_EXT, ArchiveError, exportHistory, importHistory } from "../export.js";
import * as pwa from "../pwa.js";
import { INSTALL_IOS, button, confirmPanel, el, field, message, setError, setNote } from "./dom.js";
import {
EXPORT_FAILED,
INSTALL_IOS,
button,
clear,
confirmPanel,
el,
field,
message,
setError,
setNote,
} from "./dom.js";
// Состояния уведомлений и инструкция установки — docs/ui.md, «Настройки».
const NOTIFICATIONS = {
@@ -16,6 +27,27 @@ const NOTIFICATIONS = {
const INSTALL_HINT = "поделиться → на экран «домой»";
// Сколько символов идентификатора устройства видно в списке (docs/ui.md,
// «Настройки»). Восьми хватает, чтобы отличить одно устройство от другого:
// идентификатор случайный.
const ID_SHOWN = 8;
// Дата появления устройства — местная, цифрами: она стоит в строке рядом
// с идентификатором, и длинная форма её бы утопила (ADR-052).
const DEVICE_DAY = new Intl.DateTimeFormat("ru-RU", {
day: "2-digit",
month: "2-digit",
year: "numeric",
});
// МБ занятого места — 1024×1024 байта (ADR-052).
const MB = 1024 * 1024;
// Импорт не дошёл до базы: места на устройстве нет или ключей аккаунта
// на нём нет (docs/ui.md, «Тексты состояний»). Порча самого файла говорит
// о себе своими словами.
const IMPORT_FAILED = "импорт не удался";
export function renderSettings(root, ctx) {
root.append(head(ctx));
const body = el("div", "body settings");
@@ -24,7 +56,7 @@ export function renderSettings(root, ctx) {
if (install !== null) {
body.append(install);
}
body.append(passwordBlock(ctx), exitBlock(ctx), deleteBlock(ctx));
body.append(devicesBlock(ctx), historyBlock(), passwordBlock(ctx), exitBlock(ctx), deleteBlock(ctx));
root.append(body);
}
@@ -137,6 +169,187 @@ function installBlock() {
return box;
}
// devicesBlock — «устройства»: список идентификаторов, дата появления
// и «удалить» (docs/ui.md, «Настройки»).
//
// У своей строки кнопки нет: там пометка «это устройство». Удаление уносит
// сессии устройства, и на своём это оставило бы человека на экране входа
// с целой историей и без объяснения; отцепляет своё устройство «выйти»
// (ADR-052).
function devicesBlock(ctx) {
const box = block("устройства");
const list = el("ul", "devices");
const note = message();
box.append(list, note);
const row = (item) => {
const line = el("li", "device");
line.append(el("span", "device__id", item.id.slice(0, ID_SHOWN)));
if (Number.isFinite(item.createdAt)) {
line.append(el("span", "tag", DEVICE_DAY.format(item.createdAt)));
}
if (item.current) {
line.append(el("span", "tag", "это устройство"));
return line;
}
const drop = el("button", "link", "удалить");
drop.type = "button";
drop.addEventListener("click", async () => {
drop.disabled = true;
setNote(note, "");
try {
await ctx.removeDevice(item.id);
await paint();
} catch (err) {
setError(note, ctx.errorText(err));
drop.disabled = false;
}
});
line.append(drop);
return line;
};
// paint перечитывает список: он же и есть ответ на удаление.
async function paint() {
let items;
try {
items = await ctx.devices();
} catch (err) {
setError(note, ctx.errorText(err));
return;
}
setNote(note, "");
clear(list);
for (const item of items) {
list.append(row(item));
}
}
paint();
return box;
}
// historyBlock — «история»: занятое место, экспорт и импорт архива
// (docs/ui.md, «Настройки»).
function historyBlock() {
const box = block("история");
// Место занято не только сообщениями: считает его браузер, а не мы
// (ADR-009). Браузер, который считать не умеет, строки не получает —
// писать в неё нечего (ADR-052).
const used = el("p", "state", "");
used.hidden = true;
// Строка перечитывается и после импорта: «добавлено N сообщений» рядом
// с прежней цифрой — две строки об одном действии, говорящие разное.
const showSpace = async () => {
const bytes = await space();
if (bytes === null) {
return;
}
used.textContent = `занято ${megabytes(bytes)} МБ`;
used.hidden = false;
};
showSpace();
const out = button("экспорт");
const take = button("импорт");
// Выбор файла — обычный <input type="file">, спрятанный за кнопкой:
// системный вид ему тут не к месту, а поведение нужно родное.
const picker = el("input");
picker.type = "file";
picker.accept = ARCHIVE_EXT;
picker.hidden = true;
const note = message();
box.append(used, out, take, picker, note);
out.addEventListener("click", () => runExport(out, note));
take.addEventListener("click", () => {
if (take.disabled) {
return;
}
setNote(note, "");
picker.click();
});
picker.addEventListener("change", async () => {
const file = picker.files?.[0] ?? null;
// Тот же файл, выбранный второй раз подряд, обязан считаться выбором:
// без сброса change не приходит.
picker.value = "";
if (file === null) {
return;
}
take.disabled = true;
try {
const added = await importHistory(file);
setNote(note, `добавлено ${plural(added)}`);
await showSpace();
} catch (err) {
setError(note, err instanceof ArchiveError ? err.message : IMPORT_FAILED);
} finally {
take.disabled = false;
}
});
return box;
}
// runExport — «экспорт» в разделе истории и «экспортировать» в подтверждении
// выхода: действие одно, кнопки две. Удача говорит сама за себя — браузер
// сохраняет файл, писать об этом нечего.
async function runExport(action, note) {
if (action.disabled) {
return;
}
action.disabled = true;
setNote(note, "");
try {
await exportHistory();
} catch {
setError(note, EXPORT_FAILED);
} finally {
action.disabled = false;
}
}
// space — занятое место в байтах или null, если браузер его не считает
// (docs/storage.md, ADR-009). Это оценка происхождения целиком: индексы
// и служебные страницы IndexedDB тоже занимают место.
async function space() {
if (!navigator.storage?.estimate) {
return null;
}
try {
const { usage } = await navigator.storage.estimate();
return Number.isFinite(usage) ? usage : null;
} catch {
return null;
}
}
// megabytes — «занято N МБ» человеческим числом: до десятых, пока меньше
// десяти, дальше целые (ADR-052). Десятые округляются вверх: пара сотен
// килобайт — это не «0 МБ».
function megabytes(bytes) {
const value = bytes / MB;
if (value >= 10) {
return String(Math.round(value));
}
return (Math.ceil(value * 10) / 10).toLocaleString("ru-RU");
}
// plural склоняет «сообщение» с числом: «добавлено 1 сообщение»,
// «добавлено 2 сообщения», «добавлено 5 сообщений» (docs/ui.md).
function plural(count) {
const tail = count % 100;
const last = count % 10;
if (tail < 11 || tail > 14) {
if (last === 1) {
return `${count} сообщение`;
}
if (last >= 2 && last <= 4) {
return `${count} сообщения`;
}
}
return `${count} сообщений`;
}
function passwordBlock(ctx) {
const box = block("сменить пароль");
const form = el("form", "form");
@@ -198,27 +411,38 @@ function passwordBlock(ctx) {
function exitBlock(ctx) {
const box = block("выйти");
const start = button("выйти");
// Кнопки «экспортировать» пока нет: экспорт — этап 5 (docs/plan.md).
const panel = confirmPanel("история на этом устройстве будет удалена. экспортировать сначала?", "выйти");
// История на этом устройстве — единственная копия, поэтому подтверждение
// предлагает сначала сохранить её (docs/ui.md, ADR-029).
const panel = confirmPanel(
"история на этом устройстве будет удалена. экспортировать сначала?",
"выйти",
"экспортировать",
);
const note = message();
start.addEventListener("click", () => {
start.hidden = true;
panel.root.hidden = false;
panel.yes.focus();
panel.extra.focus();
});
// Экспорт подтверждение не закрывает: человек скачивает архив и решает
// дальше сам.
panel.extra.addEventListener("click", () => runExport(panel.extra, note));
panel.no.addEventListener("click", () => {
panel.root.hidden = true;
setNote(note, "");
start.hidden = false;
start.focus();
});
panel.yes.addEventListener("click", async () => {
panel.yes.disabled = true;
panel.no.disabled = true;
panel.extra.disabled = true;
await ctx.signOut();
ctx.go("#/");
});
box.append(start, panel.root);
box.append(start, panel.root, note);
return box;
}
+2 -1
View File
@@ -7,7 +7,7 @@
// Имя кэша содержит версию; версия — константа, она меняется при релизе,
// и старые кэши уходят в activate.
const VERSION = "v2";
const VERSION = "v3";
const CACHE = `bare-${VERSION}`;
// Оболочка — всё, из чего клиент поднимается без сети. Список явный:
@@ -20,6 +20,7 @@ const SHELL = [
"/js/api.js",
"/js/crypto.js",
"/js/db.js",
"/js/export.js",
"/js/main.js",
"/js/pwa.js",
"/js/sync.js",