From 0c878477d2ed3695cd424175e9a55352ef010f53 Mon Sep 17 00:00:00 2001 From: Yuriy Mayatnikov Date: Sun, 23 Aug 2026 02:57:51 +0300 Subject: [PATCH] =?UTF-8?q?=D0=AD=D1=82=D0=B0=D0=BF=205:=20=D0=B8=D1=81?= =?UTF-8?q?=D1=82=D0=BE=D1=80=D0=B8=D1=8F=20=E2=80=94=20=D1=8D=D0=BA=D1=81?= =?UTF-8?q?=D0=BF=D0=BE=D1=80=D1=82=20=D0=B8=20=D0=B8=D0=BC=D0=BF=D0=BE?= =?UTF-8?q?=D1=80=D1=82=20.bare,=20=D1=83=D1=81=D1=82=D1=80=D0=BE=D0=B9?= =?UTF-8?q?=D1=81=D1=82=D0=B2=D0=B0,=20=D0=BC=D0=B5=D1=81=D1=82=D0=BE,=20?= =?UTF-8?q?=D0=BF=D0=B0=D0=B3=D0=B8=D0=BD=D0=B0=D1=86=D0=B8=D1=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Экспорт: ключ архива из секрета аккаунта через 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) Claude-Session: https://claude.ai/code/session_015DbCjVfTFq4ZFG8juD45YJ --- docs/architecture.md | 2 +- docs/crypto.md | 2 + docs/decisions/009-local-history.md | 2 + .../050-import-keeps-local-record.md | 31 ++ docs/decisions/051-export-button-and-texts.md | 24 ++ .../052-settings-devices-and-space.md | 25 ++ .../053-feed-pages-without-virtualization.md | 27 ++ .../054-archive-is-untrusted-input.md | 26 ++ docs/storage.md | 6 +- docs/ui.md | 12 +- web/app.css | 49 +++ web/js/crypto.js | 138 ++++++- web/js/db.js | 110 ++++++ web/js/export.js | 342 ++++++++++++++++++ web/js/main.js | 30 ++ web/js/sync.js | 21 +- web/js/ui/auth.js | 29 +- web/js/ui/chat.js | 180 +++++++-- web/js/ui/dom.js | 26 +- web/js/ui/settings.js | 242 ++++++++++++- web/sw.js | 3 +- 21 files changed, 1274 insertions(+), 53 deletions(-) create mode 100644 docs/decisions/050-import-keeps-local-record.md create mode 100644 docs/decisions/051-export-button-and-texts.md create mode 100644 docs/decisions/052-settings-devices-and-space.md create mode 100644 docs/decisions/053-feed-pages-without-virtualization.md create mode 100644 docs/decisions/054-archive-is-untrusted-input.md create mode 100644 web/js/export.js diff --git a/docs/architecture.md b/docs/architecture.md index 9f6d7e6..364a94e 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -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()`. diff --git a/docs/crypto.md b/docs/crypto.md index 63ac956..44d890e 100644 --- a/docs/crypto.md +++ b/docs/crypto.md @@ -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 символов. Внутри одной миллисекунды на одном клиенте случайная часть инкрементируется. diff --git a/docs/decisions/009-local-history.md b/docs/decisions/009-local-history.md index 56cddea..1df398d 100644 --- a/docs/decisions/009-local-history.md +++ b/docs/decisions/009-local-history.md @@ -1,5 +1,7 @@ # ADR-009: История — только на устройстве, в IndexedDB +Уточнён [ADR-053](053-feed-pages-without-virtualization.md): виртуализация списка в DOM снята, пагинация курсором по 50 в силе. + ## Контекст История, живущая на сервере, делает сервер архивом и целью атак. У Bare история — собственность устройства. diff --git a/docs/decisions/050-import-keeps-local-record.md b/docs/decisions/050-import-keeps-local-record.md new file mode 100644 index 0000000..18590f1 --- /dev/null +++ b/docs/decisions/050-import-keeps-local-record.md @@ -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` у него пуст. diff --git a/docs/decisions/051-export-button-and-texts.md b/docs/decisions/051-export-button-and-texts.md new file mode 100644 index 0000000..a328835 --- /dev/null +++ b/docs/decisions/051-export-button-and-texts.md @@ -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 закрыто. diff --git a/docs/decisions/052-settings-devices-and-space.md b/docs/decisions/052-settings-devices-and-space.md new file mode 100644 index 0000000..140c944 --- /dev/null +++ b/docs/decisions/052-settings-devices-and-space.md @@ -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 тоже место. Она же намеренно грубая у самого браузера, и точнее показывать нечего. diff --git a/docs/decisions/053-feed-pages-without-virtualization.md b/docs/decisions/053-feed-pages-without-virtualization.md new file mode 100644 index 0000000..cdf630f --- /dev/null +++ b/docs/decisions/053-feed-pages-without-virtualization.md @@ -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, если появится жалоба, а не предположение. diff --git a/docs/decisions/054-archive-is-untrusted-input.md b/docs/decisions/054-archive-is-untrusted-input.md new file mode 100644 index 0000000..cdd8561 --- /dev/null +++ b/docs/decisions/054-archive-is-untrusted-input.md @@ -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. Она защищает от собственных ошибок — от того, что записал клиент другой версии, и от того, что запишет он же завтра. diff --git a/docs/storage.md b/docs/storage.md index 30e31aa..3b405c0 100644 --- a/docs/storage.md +++ b/docs/storage.md @@ -149,4 +149,8 @@ peers key: nick ## Экспорт `.bare` -Полезная нагрузка — `chats` (без `unread`, `lastReadId`), `messages` (без `raw` и `error`, только с `text`), `peers` (без `pending`). Формат файла и шифрование — `docs/crypto.md`. Имя файла — `bare--.bare`. +Полезная нагрузка — `chats` (без `lastId`, `unread`, `lastReadId`, `hidden`), `messages` (без `raw` и `error`), `peers` (без `pending`). Уносится только отправленное — `sent`. Неотправленное и отвергнутое остаются устройству: `pending` и `failed` — незаконченная и отвергнутая попытки, привязанные к своему ULID (ADR-036), а не история. Нерасшифрованное не уносится: без текста от записи остаётся один заголовок, а `raw` — служебное поле. Показания устройства — место чата в списке, счётчики и «убрано из списка» — не уносятся тоже (ADR-050). Формат файла и шифрование — `docs/crypto.md`. Имя файла — `bare--.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`, пока непрочитанных у чата нет. Ответ — число добавленных сообщений; повторный импорт того же файла добавляет ноль. diff --git a/docs/ui.md b/docs/ui.md index 4a1f4fe..5b85526 100644 --- a/docs/ui.md +++ b/docs/ui.md @@ -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` в настройках | «неверный пароль» | +| архив не собрался: истории не прочитать или ключей аккаунта на устройстве нет | «экспорт не удался» | +| разобранный архив не дошёл до базы: нет места или ключей аккаунта | «импорт не удался» | ## Доступность diff --git a/web/app.css b/web/app.css index 322a781..32dcdf3 100644 --- a/web/app.css +++ b/web/app.css @@ -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, отрицательные поля не дают ей растянуть сам баннер */ diff --git a/web/js/crypto.js b/web/js/crypto.js index 3862032..cbd74a0 100644 --- a/web/js/crypto.js +++ b/web/js/crypto.js @@ -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); +} diff --git a/web/js/db.js b/web/js/db.js index 3539801..1e74149 100644 --- a/web/js/db.js +++ b/web/js/db.js @@ -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() { diff --git a/web/js/export.js b/web/js/export.js new file mode 100644 index 0000000..739bd4f --- /dev/null +++ b/web/js/export.js @@ -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--.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, временный адрес и . Ни +// 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:». +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:» (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); +} diff --git a/web/js/main.js b/web/js/main.js index 821e693..e16112e 100644 --- a/web/js/main.js +++ b/web/js/main.js @@ -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 — вывод ключей по числу итераций, пришедшему от сервера. Границы diff --git a/web/js/sync.js b/web/js/sync.js index b18fbf2..034d04a 100644 --- a/web/js/sync.js +++ b/web/js/sync.js @@ -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 отдаёт изменение соседним вкладкам. Канал открыт, только пока diff --git a/web/js/ui/auth.js b/web/js/ui/auth.js index 4d5043d..a0d14fc 100644 --- a/web/js/ui/auth.js +++ b/web/js/ui/auth.js @@ -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(); diff --git a/web/js/ui/chat.js b/web/js/ui/chat.js index 813706a..48aae63 100644 --- a/web/js/ui/chat.js +++ b/web/js/ui/chat.js @@ -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) { diff --git a/web/js/ui/dom.js b/web/js/ui/dom.js index 57e8468..ae9a5f3 100644 --- a/web/js/ui/dom.js +++ b/web/js/ui/dom.js @@ -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"); diff --git a/web/js/ui/settings.js b/web/js/ui/settings.js index d69da30..3cc1d08 100644 --- a/web/js/ui/settings.js +++ b/web/js/ui/settings.js @@ -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("импорт"); + // Выбор файла — обычный , спрятанный за кнопкой: + // системный вид ему тут не к месту, а поведение нужно родное. + 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; } diff --git a/web/sw.js b/web/sw.js index 7e59f29..cdc2eb4 100644 --- a/web/sw.js +++ b/web/sw.js @@ -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",