Этап 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:
@@ -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()`.
|
||||
|
||||
|
||||
@@ -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 символов. Внутри одной миллисекунды на одном клиенте случайная часть инкрементируется.
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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() {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user