From ef068eca81e175e457afe866411007c7ddeae7dc1d9310a019d07afeb8cb0ff8 Mon Sep 17 00:00:00 2001 From: AidarKC Date: Fri, 11 Sep 2026 14:33:21 +0300 Subject: [PATCH] =?UTF-8?q?=D0=94=D0=BE=D1=80=D0=B0=D0=B1=D0=BE=D1=82?= =?UTF-8?q?=D0=B0=D1=82=D1=8C=20=D0=B2=D0=BB=D0=BE=D0=B6=D0=B5=D0=BD=D0=BD?= =?UTF-8?q?=D1=8B=D0=B5=20=D1=84=D0=B0=D0=B9=D0=BB=D1=8B=20=D0=B8=20=D0=B0?= =?UTF-8?q?=D1=80=D1=85=D0=B8=D0=B2=20=D0=BF=D1=80=D0=BE=D0=B5=D0=BA=D1=82?= =?UTF-8?q?=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../JSON/messages/SignedMessagesRealtime.java | 2 +- .../ws_protocol/JSON/push/WebPushSender.java | 3 +- .../main/java/server/files/DmFileServlet.java | 60 +- .../src/main/resources/application.properties | 4 +- VERSION.properties | 4 +- bundle.sh | 14 +- docs/API/18_DM_File_Storage_API.md | 24 +- docs/Personal_Messages/README.md | 1 + docs/Personal_Messages/Протокол_DM_v1.md | 16 + .../Технические_вставки_DM_v1.md | 23 + docs/Personal_Messages/Файлы_DM_v2.md | 124 ++ docs/Personal_Messages/Формат_DM_v1.md | 13 + docs/SHINE_ARCHIVE_PROTOCOL_v1.0_RU_FINAL.md | 1553 +++++++++++++++++ shine-UI/firebase-messaging-sw.js | 2 +- shine-UI/js/pages/advanced-settings-view.js | 4 +- shine-UI/js/pages/chat-view.js | 909 +++++++++- shine-UI/js/services/crypto-utils.js | 12 +- shine-UI/js/services/dm-file-service.js | 710 +++++++- shine-UI/js/services/dm-tech-blocks.js | 67 +- shine-UI/js/services/feature-settings.js | 2 +- shine-UI/js/services/torrent-v2-service.js | 234 +++ shine-UI/styles/features/chat.css | 300 ++++ 22 files changed, 3865 insertions(+), 216 deletions(-) create mode 100644 docs/Personal_Messages/Файлы_DM_v2.md create mode 100644 docs/SHINE_ARCHIVE_PROTOCOL_v1.0_RU_FINAL.md create mode 100644 shine-UI/js/services/torrent-v2-service.js diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/SignedMessagesRealtime.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/SignedMessagesRealtime.java index 76bd9306..a3f50099 100644 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/SignedMessagesRealtime.java +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/SignedMessagesRealtime.java @@ -147,7 +147,7 @@ public final class SignedMessagesRealtime { if (isBlank(session.getPushEndpoint()) || isBlank(session.getPushP256dhKey()) || isBlank(session.getPushAuthKey())) { return false; } - String text = "Вам пришло новое личное сообщение от " + message.getFromLogin() + "."; + String text = "Новое сообщение от " + message.getFromLogin(); String payload = "{\"kind\":\"new_message\",\"fromLogin\":\"" + jsonEscape(message.getFromLogin()) + "\",\"text\":\"" + jsonEscape(text) + "\"}"; return WebPushSender.sendBase64Payload( session.getPushEndpoint(), diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/push/WebPushSender.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/push/WebPushSender.java index 676e34cb..de71bc0f 100644 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/push/WebPushSender.java +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/push/WebPushSender.java @@ -3,6 +3,7 @@ package server.logic.ws_protocol.JSON.push; import nl.martijndwars.webpush.Notification; import nl.martijndwars.webpush.PushService; import nl.martijndwars.webpush.Subscription; +import nl.martijndwars.webpush.Urgency; import org.jose4j.lang.JoseException; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -61,7 +62,7 @@ public final class WebPushSender { endpoint, new Subscription.Keys(p256dhKey, authKey) ); - Notification notification = new Notification(subscription, payloadB64); + Notification notification = new Notification(subscription, payloadB64, Urgency.HIGH); var response = service().send(notification); int code = response.getStatusLine().getStatusCode(); return code >= 200 && code < 300; diff --git a/SHiNE-server/src/main/java/server/files/DmFileServlet.java b/SHiNE-server/src/main/java/server/files/DmFileServlet.java index 3c5f2ea4..f6758a23 100644 --- a/SHiNE-server/src/main/java/server/files/DmFileServlet.java +++ b/SHiNE-server/src/main/java/server/files/DmFileServlet.java @@ -102,15 +102,20 @@ public final class DmFileServlet extends HttpServlet { return; } - // Content-addressed объект неизменяем. Повторная корректно подписанная загрузка того же - // ciphertext безопасна и просто возвращает существующий объект. + // Content-addressed объект неизменяем. Нельзя доверять одному только имени файла: + // перед ответом «уже есть» повторно проверяем реальный SHA-256 сохранённых байтов. + // Если файл на диске повреждён/подменён, удаляем его и принимаем корректную загрузку заново. if (Files.isRegularFile(target)) { - if (Files.size(target) != auth.declaredLength) { - writeError(resp, HttpServletResponse.SC_CONFLICT, "FILE_ID_CONFLICT", "Файл с таким hash уже существует с другим размером"); + StoredObjectCheck stored = verifyStoredObject(target, fileId); + if (stored.valid()) { + if (stored.size() != auth.declaredLength) { + writeError(resp, HttpServletResponse.SC_CONFLICT, "FILE_ID_CONFLICT", "Файл с таким hash уже существует с другим размером"); + return; + } + writeUploadOk(resp, fileId, auth.declaredLength, true); return; } - writeUploadOk(resp, fileId, auth.declaredLength, true); - return; + Files.deleteIfExists(target); } Path temp = Files.createTempFile(storageDir, ".dm-upload-", ".tmp"); @@ -153,7 +158,19 @@ public final class DmFileServlet extends HttpServlet { } keepTemp = true; // temp уже перемещён, удалять нечего } catch (FileAlreadyExistsException race) { - alreadyExists = true; + StoredObjectCheck stored = verifyStoredObject(target, fileId); + if (stored.valid() && stored.size() == actualLength) { + alreadyExists = true; + } else { + // Даже в редкой гонке не подтверждаем объект, пока его реальные байты не прошли hash-проверку. + Files.deleteIfExists(target); + try { + Files.move(temp, target, StandardCopyOption.ATOMIC_MOVE); + } catch (AtomicMoveNotSupportedException ignored) { + Files.move(temp, target); + } + keepTemp = true; + } } writeUploadOk(resp, fileId, actualLength, alreadyExists); @@ -188,7 +205,15 @@ public final class DmFileServlet extends HttpServlet { return; } - long size = Files.size(target); + StoredObjectCheck stored = verifyStoredObject(target, fileId); + if (!stored.valid()) { + // Повреждённый content-addressed объект нельзя подтверждать через HEAD и нельзя отдавать через GET. + Files.deleteIfExists(target); + writeError(resp, HttpServletResponse.SC_NOT_FOUND, "FILE_NOT_FOUND", "Файл не найден"); + return; + } + + long size = stored.size(); resp.setStatus(HttpServletResponse.SC_OK); resp.setContentType("application/octet-stream"); resp.setHeader("Content-Length", Long.toString(size)); @@ -203,6 +228,25 @@ public final class DmFileServlet extends HttpServlet { } } + + private static StoredObjectCheck verifyStoredObject(Path target, String expectedFileId) throws IOException { + MessageDigest digest = sha256Digest(); + long size = 0L; + byte[] buffer = new byte[64 * 1024]; + try (InputStream in = Files.newInputStream(target)) { + int read; + while ((read = in.read(buffer)) >= 0) { + if (read == 0) continue; + size += read; + digest.update(buffer, 0, read); + } + } + String actualFileId = toBase58(digest.digest()); + return new StoredObjectCheck(Objects.equals(expectedFileId, actualFileId), size); + } + + private record StoredObjectCheck(boolean valid, long size) {} + private UploadAuth readAndVerifyUploadAuth(HttpServletRequest req, String fileId) throws AuthFailure { String sessionId = requiredHeader(req, HEADER_SESSION_ID); String timeRaw = requiredHeader(req, HEADER_TIME_MS); diff --git a/SHiNE-server/src/main/resources/application.properties b/SHiNE-server/src/main/resources/application.properties index 6e5fc2ba..8c79392e 100644 --- a/SHiNE-server/src/main/resources/application.properties +++ b/SHiNE-server/src/main/resources/application.properties @@ -60,7 +60,9 @@ server.ui.buildHash= # На диске лежит только AES-GCM ciphertext. Имя файла = Base58(SHA-256(ciphertext)). dm.files.enabled=true dm.files.storageDir=data/dm-files -# 50 MiB исходного файла + 16 bytes AES-GCM authentication tag. +# Лимит одного immutable ciphertext-объекта, НЕ всего файла. +# DM file v2 режет файл на 1 MiB части, поэтому общий размер файла этим параметром не ограничен. +# Значение 50 MiB сохранено для совместимости с DM file v1. dm.files.maxBytes=52428816 webpush.vapid.public=BOdoWZndZRaNe9kyUFsJ5-xEfFABXNKennAKg15Z7ycAwUIQ7yDV_sIWWYJCwJriN4g9oU-CyJPrn1U6lfxuDbI diff --git a/VERSION.properties b/VERSION.properties index 1f7dd8ea..b375809a 100644 --- a/VERSION.properties +++ b/VERSION.properties @@ -1,2 +1,2 @@ -client.version=1.12.14 -server.version=1.10.5 +client.version=1.12.15 +server.version=1.10.6 diff --git a/bundle.sh b/bundle.sh index e3d1dc27..431aecad 100644 --- a/bundle.sh +++ b/bundle.sh @@ -13,7 +13,13 @@ set -Eeuo pipefail ROOT="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" cd "$ROOT" -OUT="${1:-SHiNE-bundle-$(date +%Y%m%d-%H%M%S).zip}" +DEFAULT_OUT=0 +if [[ $# -gt 0 ]]; then + OUT="$1" +else + DEFAULT_OUT=1 + OUT="SHiNE-bundle-$(date +%Y.%m.%d-%H.%M.%S).zip" +fi case "$OUT" in /*) ;; *) OUT="$ROOT/$OUT" ;; @@ -169,7 +175,11 @@ if [[ ! -s "$SAFE_LIST" ]]; then exit 3 fi -rm -f -- "$OUT" +if (( DEFAULT_OUT == 1 )); then + rm -f -- "$ROOT"/SHiNE-bundle-*.zip +else + rm -f -- "$OUT" +fi ( cd "$ROOT" diff --git a/docs/API/18_DM_File_Storage_API.md b/docs/API/18_DM_File_Storage_API.md index ee722303..0e4070ef 100644 --- a/docs/API/18_DM_File_Storage_API.md +++ b/docs/API/18_DM_File_Storage_API.md @@ -25,7 +25,7 @@ dm.files.storageDir=data/dm-files dm.files.maxBytes=52428816 ``` -`dm.files.maxBytes` ограничивает размер ciphertext. Текущий официальный UI ограничивает исходный файл 50 MiB; AES-GCM добавляет 16-byte authentication tag. +`dm.files.maxBytes` ограничивает размер одного ciphertext-объекта. Для legacy DM file v1 это фактически ограничивало целый файл. В DM file v2 файл состоит из 1-MiB ciphertext-объектов, поэтому общий размер логического файла этим параметром не ограничивается. ## 3. PUT `/dm-files/{fileId}` @@ -57,7 +57,7 @@ DM_FILE_UPLOAD_V1:{sessionId}:{fileId}:{encryptedSize}:{timeMs} Сервер потоково пишет временный файл, одновременно считает SHA-256, проверяет фактический размер и только после успешной проверки атомарно перемещает объект под именем `{fileId}`. -Повторная корректно подписанная загрузка уже существующего immutable объекта идемпотентна. +Повторная корректно подписанная загрузка уже существующего immutable объекта идемпотентна. Перед ответом `alreadyExists=true` сервер повторно вычисляет `SHA-256` уже сохранённого объекта и сверяет его с `fileId`; одного совпадения имени файла на диске недостаточно. Если объект повреждён или подменён, сервер удаляет некорректную копию и принимает корректный `PUT` заново. Успешный ответ: @@ -84,16 +84,30 @@ GET не требует пользовательской сессии: `fileId` ## 5. HEAD и OPTIONS -- `HEAD /dm-files/{fileId}` возвращает метаданные ciphertext без тела; +- `HEAD /dm-files/{fileId}` возвращает метаданные ciphertext без тела только после повторной проверки `Base58(SHA-256(ciphertext)) == fileId`; официальный UI использует этот запрос перед `PUT`, чтобы не отправлять уже существующие байты повторно; - `OPTIONS /dm-files/*` обслуживает CORS preflight для браузерного PUT. ## 6. Reverse proxy Caddy/Nginx должен проксировать `/dm-files/*` в тот же Jetty, что обслуживает `/ws`. Route должен находиться до SPA fallback. -## 7. Ограничения v1 +## 7. Ограничения legacy v1 -- файл перед загрузкой целиком читается в память браузера, поэтому UI ограничен 50 MiB; +- legacy-файл перед загрузкой целиком читается в память браузера и ограничен 50 MiB; +- новые отправки официального UI используют v2 и этого ограничения не имеют; - удаления/TTL/garbage collection пока нет; - если ciphertext уже загружен, а отправка E2EE DM затем не удалась, объект может остаться orphan-файлом; - resumable/chunked upload не входит в v1. + +## 8. DM file v2: большие файлы + +Начиная с UI v2 один логический файл не загружается одним HTTP-объектом. Клиент режет plaintext на `1 MiB` части и каждый AES-GCM ciphertext-кусок загружает отдельным обычным `PUT /dm-files/{fileId}`. + +Поэтому `dm.files.maxBytes` — это лимит **одного immutable HTTP-объекта**, а не всего пользовательского файла. При стандартном `chunkSize=1 MiB` общий размер файла этим параметром не ограничивается. + +Манифест также хранится через тот же immutable API: + +- encrypted manifest page — до 256 chunk descriptors; +- encrypted root manifest — ссылки на страницы + BitTorrent v2 root metadata. + +Никаких новых доверенных серверных операций для v2 не требуется: сервер по-прежнему только проверяет подпись PUT, длину и `Base58(SHA-256(ciphertext))`. diff --git a/docs/Personal_Messages/README.md b/docs/Personal_Messages/README.md index 7814b5a8..1c7f5f21 100644 --- a/docs/Personal_Messages/README.md +++ b/docs/Personal_Messages/README.md @@ -9,6 +9,7 @@ - `docs/Personal_Messages/Доставка_и_синхронизация_DM.md` — доставка на единственный access-сервер, retry-воркер и UI-статусы - `docs/Personal_Messages/Технические_вставки_DM_v1.md` — формат специальных `` вставок внутри plaintext DM после расшифровки +- `docs/Personal_Messages/Файлы_DM_v2.md` — большие chunked-вложения, голосовые и BitTorrent v2 SHA-256/Merkle metadata - `docs/API/18_DM_File_Storage_API.md` — HTTP-хранилище зашифрованных файлов DM на access-сервере отправителя Исторический устаревший документ сохранён отдельно: diff --git a/docs/Personal_Messages/Протокол_DM_v1.md b/docs/Personal_Messages/Протокол_DM_v1.md index 2cebdf2a..5430673b 100644 --- a/docs/Personal_Messages/Протокол_DM_v1.md +++ b/docs/Personal_Messages/Протокол_DM_v1.md @@ -247,3 +247,19 @@ DeleteMessage принимает type=5/6. DeleteConversation принимает Отключение функции «Передача файлов» в локальных дополнительных настройках запрещает только отправку новых файлов; ранее полученные `` остаются скачиваемыми. Хранение ciphertext и HTTP-контракт описаны в `docs/API/18_DM_File_Storage_API.md`. + +## 16. Дедупликация файлов и пересылка сообщений (2026-09-11) + +Для HTTP-хранилища DM-файлов действует усиленное правило content-addressed хранения: + +- перед загрузкой каждого ciphertext-объекта официальный UI делает `HEAD /dm-files/{fileId}`; +- если сервер подтверждает объект с тем же `fileId` и размером, `PUT` с байтами повторно не выполняется; +- сервер перед ответом на `HEAD`, `GET` и перед `alreadyExists=true` сам пересчитывает `SHA-256` сохранённого объекта и сверяет его с `fileId`; +- повреждённый или подменённый объект не считается существующим и не выдаётся клиенту; при следующем корректном `PUT` он может быть записан заново. + +Пересылка личного сообщения не вводит новый тип DM и не добавляет признак «переслано». UI создаёт обычное новое контентное сообщение `type=1/2` выбранному собеседнику: + +- для обычного текста переносится отображаемый текст сообщения; +- исходный `` не переносится, поэтому новое сообщение не остаётся ответом на сообщение из старого чата; +- для сообщения с файлами повторно используются существующие ``-описатели, поэтому ciphertext не шифруется и не загружается повторно; +- сервер и получатель видят пересланное сообщение как обычное новое сообщение без служебной пометки об источнике. diff --git a/docs/Personal_Messages/Технические_вставки_DM_v1.md b/docs/Personal_Messages/Технические_вставки_DM_v1.md index a75585cf..69739762 100644 --- a/docs/Personal_Messages/Технические_вставки_DM_v1.md +++ b/docs/Personal_Messages/Технические_вставки_DM_v1.md @@ -172,3 +172,26 @@ fromLogin|toLogin|timeMs|nonce - сервер не обязан понимать этот формат; - будущие клиенты могут добавлять новые `kind`; - клиенты, которые распознают SHiNE-вставки, должны скрывать неизвестные блоки целиком, если они стоят в начале и корректно закрыты. + +## 10. Расширение `file` v2 + +Клиент обязан продолжать читать `v=1`. Новые chunked-вложения отправляются так: + +```text + +``` + +Дополнительные поля v2: + +- `id` / `url` указывают не на весь файл, а на зашифрованный root manifest; +- `ivp` — случайный 4-byte IV prefix, из которого детерминированно строятся уникальные IV chunks/pages/root; +- `chunk` — plaintext chunk size, сейчас `1048576`; +- `chunks` — число частей; +- `th` — BitTorrent v2 SHA-256 infohash; +- `pr` — BitTorrent v2 pieces root; +- `kind=file|voice`; +- `dur` — длительность voice в миллисекундах, для обычного файла `0`. + +В одном plaintext DM разрешено несколько последовательных `` блоков. Официальный UI собирает их в один список вложений и скрывает fallback-текст. + +Подробный формат chunk encryption, manifest pages и BitTorrent v2 hashing: `Файлы_DM_v2.md`. diff --git a/docs/Personal_Messages/Файлы_DM_v2.md b/docs/Personal_Messages/Файлы_DM_v2.md new file mode 100644 index 00000000..1e3fe64e --- /dev/null +++ b/docs/Personal_Messages/Файлы_DM_v2.md @@ -0,0 +1,124 @@ +# Файлы и голосовые DM v2: chunked AES-GCM + BitTorrent v2 hashes + +## Цели + +DM file v2 убирает ограничение размера исходного файла, не требует держать файл целиком в RAM и сохраняет серверную модель «сервер видит только ciphertext». + +Основные свойства: + +- plaintext режется на куски по `1 MiB`; +- каждый кусок независимо шифруется `AES-256-GCM` одним случайным ключом файла, но с уникальным 96-bit IV; +- каждый ciphertext-кусок хранится как immutable объект `Base58(SHA-256(ciphertext))`; +- список кусков хранится в зашифрованных страницах манифеста по 256 записей; +- корневой манифест тоже зашифрован и content-addressed; +- E2EE DM содержит только ссылку на корневой манифест, AES-key, IV-prefix и пользовательские метаданные; +- один DM может содержать до 10 `` блоков; +- `kind=voice` использует тот же формат хранения и отдельный UI проигрывателя. + +## IV и domain separation + +На один файл создаётся случайный 4-byte `ivPrefix`. + +12-byte IV строится как: + +```text +ivPrefix[4] || uint64_be(token) +``` + +Диапазоны `token` разделены: + +- chunks: `0 .. 2^63-1`; +- manifest pages: `2^63 + pageIndex`; +- root manifest: `0xffffffffffffffff`. + +AAD также содержит домен (`chunk`, `page`, `root`), prefix и индекс. Поэтому перестановка ciphertext-кусков не проходит AES-GCM authentication. + +## Manifest pages + +Каждая страница после расшифровки содержит до 256 записей: + +```json +{ + "v": 2, + "page": 0, + "chunks": [ + { + "i": 0, + "id": "Base58(SHA-256(ciphertext))", + "ps": 1048576, + "es": 1048592, + "ph": "BitTorrent-v2-piece-hash-base64url" + } + ] +} +``` + +Страницы сами AES-GCM зашифрованы и загружаются в `/dm-files/{id}`. + +## Root manifest + +После расшифровки: + +```json +{ + "v": 2, + "scheme": "SHINE-DM-CHUNKED-AES-256-GCM", + "chunkSize": 1048576, + "fileSize": 123456789, + "chunkCount": 118, + "pages": [{"id":"...","count":118,"encryptedSize":12345}], + "torrent": { + "metaVersion": 2, + "blockLength": 16384, + "pieceLength": 1048576, + "piecesRoot": "...", + "infoHash": "..." + } +} +``` + +Имя файла и MIME в manifest не пишутся. Они остаются внутри E2EE DM. + +## BitTorrent v2 совместимость + +Хеширование соответствует BEP 52: + +- базовый hash block: `16 KiB`; +- SHA-256; +- piece length: `1 MiB`; +- Merkle padding leaf = 32 zero bytes; +- `pieces root` вычисляется по правилам BitTorrent v2; +- `infoHash` = `SHA-256(bencode(info dictionary))`; +- piece-layer hash каждого 1-MiB куска хранится в зашифрованной manifest page. + +Из `name`, `size`, `piecesRoot` и списка `ph` можно построить tracker-less `.torrent` v2 без повторного хеширования исходного файла. + +Это совместимость метаданных и проверки контента. HTTP `/dm-files` пока не является BitTorrent peer transport: для настоящего P2P потребуется отдельный seeding/peer слой. + +## Скачивание + +Получатель: + +1. получает и проверяет ciphertext root manifest по Base58(SHA-256); +2. расшифровывает root manifest; +3. по очереди получает manifest pages; +4. получает каждый chunk с сервера отправителя; +5. проверяет content address ciphertext; +6. расшифровывает chunk локально; +7. проверяет BitTorrent-v2 piece hash; +8. пишет plaintext на диск; +9. в конце проверяет итоговый `pieces root`. + +В браузерах с File System Access API plaintext пишется на диск по частям и целиком в RAM не собирается. В остальных браузерах остаётся Blob fallback без искусственного лимита размера, но фактический предел зависит от памяти браузера. + +## Голосовые + +`MediaRecorder` пишет `Opus/WebM`, `Opus/Ogg` или поддерживаемый браузером audio MIME. После завершения запись проходит тот же DM file v2 pipeline. + +Технический блок отличается полями: + +```text +kind=voice;dur= +``` + +Получатель видит player с Play/Pause, прогрессом и длительностью. Аудио расшифровывается только на клиенте. diff --git a/docs/Personal_Messages/Формат_DM_v1.md b/docs/Personal_Messages/Формат_DM_v1.md index d67306f7..88e170d5 100644 --- a/docs/Personal_Messages/Формат_DM_v1.md +++ b/docs/Personal_Messages/Формат_DM_v1.md @@ -380,3 +380,16 @@ UI-примечание (байтовый формат не меняет): ра Для контентных `type=1/2` технический блок `` является частью обычного plaintext, который затем попадает в уже существующий зашифрованный `body`. Сам ciphertext внешнего файла в `SHiNE_DM` не включается. Поэтому подписи контейнера, `baseKey`, `revisionTimeMs`, `reencryptedAtMs`, алгоритм E2EE DM и правила парности входящей/исходящей копий остаются прежними. + +## 16. Примечание о пересылке сообщений (2026-09-11) + +Пересылка не меняет байтовый формат `SHiNE_DM` и не добавляет отдельный `messageType` или флаг forward/repost. + +Клиент формирует новый обычный plaintext для `type=1/2`: + +- текстовая часть копируется как новое сообщение; +- `` исходного сообщения удаляется; +- валидные `` могут быть скопированы без изменения, чтобы новое сообщение ссылалось на тот же уже загруженный зашифрованный объект; +- информация о том, из какого чата или сообщения выполнена пересылка, в контейнер не добавляется. + +Таким образом подпись, `baseKey`, шифрование и структура контейнера остаются полностью прежними: меняется только содержимое нового plaintext перед стандартной отправкой. diff --git a/docs/SHINE_ARCHIVE_PROTOCOL_v1.0_RU_FINAL.md b/docs/SHINE_ARCHIVE_PROTOCOL_v1.0_RU_FINAL.md new file mode 100644 index 00000000..eb4db25a --- /dev/null +++ b/docs/SHINE_ARCHIVE_PROTOCOL_v1.0_RU_FINAL.md @@ -0,0 +1,1553 @@ +# SHiNE Archive Protocol v1.0 + +## Полная спецификация серверной архивации SHiNE-блоков в Arweave и фиксации головы архива в Solana + +**Статус:** согласованная спецификация v1.0 +**Язык:** русский +**Magic:** `SHINE-ARCHIVE` +**Версия протокола:** `1.0` +**Endian:** big-endian для всех фиксированных целых чисел + +--- + +# 1. Назначение + +Этот документ описывает механизм, по которому SHiNE-сервер периодически: + +1. синхронизирует обычные SHiNE-блоки существующим механизмом; +2. определяет новые локальные блоки, ещё не попавшие в архив; +3. фиксирует неизменяемую дельту; +4. группирует все новые записи одной `blockchain_name` в один блок пользователя внутри большого архивного блока; +5. добавляет к каждому такому пользовательскому блоку одну ссылку на предыдущий пользовательский блок этой же `blockchain_name`; +6. формирует один большой бинарный блок `SHINE-ARCHIVE`; +7. загружает его в Arweave; +8. ждёт закрепления/подтверждения Arweave-транзакции; +9. обычным обновлением своего существующего User PDA записывает новый archive head в Solana; +10. только после Solana `finalized` переводит локальные курсоры на новую точку. + +На первом этапе предполагается один архивный Raspberry-сервер, но формат изначально не должен запрещать появление других архиваторов. + +--- + +# 2. Основные понятия + +## 2.1. Исходный SHiNE-блок / запись + +Это существующие в серверной БД оригинальные `block_bytes`, относящиеся к конкретной `blockchain_name`. + +Они уже содержат пользовательские данные и существующую криптографию SHiNE. + +Архиватор: + +- не изменяет их; +- не переподписывает их; +- не переводит их в Base64; +- не пересобирает их содержимое. + +## 2.2. `blockchain_name` + +В архивном формате основной идентификатор пользовательской цепочки — именно `blockchain_name`. + +Отдельно хранить `login` для пользовательского блока не требуется. + +`blockchain_name` уже однозначно идентифицирует конкретную пользовательскую blockchain и логически включает пользовательскую идентичность + номер/вариант цепочки. + +## 2.3. Большой архивный блок + +Один файл/объект `SHINE-ARCHIVE`, который сервер формирует за один цикл архивной публикации. + +Он содержит: + +- header; +- список предыдущих больших архивных блоков; +- по одному `UserBlockchainChunk` для каждой `blockchain_name`, у которой есть новые записи; +- footer; +- SHA-256 большого блока; +- подпись сервера, который этот большой блок сформировал и закрыл. + +Один большой архивный блок после публикации соответствует одной Arweave-транзакции. + +## 2.4. `UserBlockchainChunk` + +В одном большом архивном блоке для одной `blockchain_name` существует максимум один такой chunk. + +Если за текущую дельту у пользователя/цепочки накопилось 1, 3, 5 или 20 записей, все они помещаются внутрь одного `UserBlockchainChunk`. + +Навигационная ссылка одна на весь chunk. + +--- + +# 3. Главная модель пользовательской истории + +Допустим `alice-001` имела записи: + +```text +BigBlock 70: + AliceChunk = 2 записи + +BigBlock 81: + AliceChunk = 5 записей + +BigBlock 95: + AliceChunk = 3 записи + +BigBlock 100: + AliceChunk = 4 записи +``` + +Тогда история `alice-001` выглядит: + +```text +BigBlock 100 + AliceChunk + 4 записи + | + +--> PreviousBlockchainChunkRef + | + v +BigBlock 95 + AliceChunk + 3 записи + | + +--> PreviousBlockchainChunkRef + | + v +BigBlock 81 + AliceChunk + 5 записей + | + v +BigBlock 70 +``` + +Навигация идёт пачками записей одной blockchain, а не по одной записи за раз. + +--- + +# 4. Сервер сам добавляет навигацию + +Пользователь не обязан знать: + +- в какой большой архивный блок попадут его записи; +- какой будет Arweave TX ID; +- какие будут offsets; +- где находится предыдущий chunk. + +Все эти данные сервер добавляет при формировании большого блока. + +Исходные подписанные пользовательские `block_bytes` остаются неизменными. + +`PreviousBlockchainChunkRef` является внешней серверной архивной метаинформацией и защищается hash + подписью всего большого архивного блока. + +--- + +# 5. Необходимые ключи архивного сервера + +Архивный Raspberry/server хранит: + +## 5.1. SHiNE root private key + +Используется: + +- для подписания новой версии User PDA при обычном `update_user_pda`; +- в v1.0 — для подписи закрытого большого архивного блока. + +## 5.2. SHiNE client private key + +Используется как Solana transaction signer / fee payer в соответствии с текущей логикой `shine_users`. + +## 5.3. Arweave JWK / private wallet key + +Используется для загрузки и оплаты Arweave-транзакции. + +## 5.4. Blockchain private key + +Для архивной публикации не требуется. + +Архиватор не создаёт новые пользовательские blockchain-блоки. + +--- + +# 6. Периодичность + +Частота архивной публикации конфигурируется отдельно от существующей межсерверной синхронизации. + +Пример: + +```properties +archive.publish.enabled=true +archive.publish.intervalMinutes=720 +archive.publish.initialDelayMinutes=15 +``` + +`720` минут = раз в 12 часов. + +--- + +# 7. Состояние, относительно которого считается дельта + +Нужно различать: + +1. уже окончательно опубликованное состояние; +2. текущий замороженный job, который ещё проходит Arweave/Solana. + +Нельзя считать дельту от живой головы во время уже запущенной публикации. + +--- + +# 8. Таблица `archive_chain_cursor` + +Хранит окончательно подтверждённое архивное состояние для каждой `blockchain_name`. + +```sql +CREATE TABLE archive_chain_cursor ( + blockchain_name TEXT PRIMARY KEY, + + last_archived_source_block_number BIGINT NOT NULL, + last_archived_source_block_hash BYTEA NOT NULL, + + last_archive_big_block_number BIGINT, + last_archive_big_block_hash BYTEA, + + last_chunk_offset BIGINT, + last_chunk_size BIGINT, + + updated_at_ms BIGINT NOT NULL +); +``` + +Назначение: + +- `last_archived_source_block_number` — до какого исходного SHiNE-блока этой blockchain всё успешно опубликовано; +- `last_archived_source_block_hash` — hash этого исходного блока; +- `last_archive_big_block_number` и `last_archive_big_block_hash` — в каком большом `SHINE-ARCHIVE` находится последний chunk этой blockchain; +- `last_chunk_offset` и `last_chunk_size` — точное положение последнего chunk внутри большого блока. + +Этого достаточно, чтобы следующая публикация создала один backlink на предыдущий chunk. + +--- + +# 9. Отдельный индекс по каждой записи не нужен + +Поскольку все новые записи одной `blockchain_name` агрегируются в один `UserBlockchainChunk`, не нужно хранить offsets каждой отдельной записи в истории. + +Для следующей публикации достаточно помнить: + +```text +предыдущий big block +предыдущий big block hash +chunk offset +chunk size +``` + +--- + +# 10. Таблица `archive_publish_job` + +Хранит crash-safe состояние одной архивной публикации. + +```sql +CREATE TABLE archive_publish_job ( + id BIGSERIAL PRIMARY KEY, + + big_block_number BIGINT NOT NULL, + status TEXT NOT NULL, + + created_at_ms BIGINT NOT NULL, + + local_archive_path TEXT, + + archive_hash BYTEA, + arweave_tx_id BYTEA, + + arweave_confirmations INTEGER, + solana_signature TEXT, + + error_text TEXT, + updated_at_ms BIGINT NOT NULL +); +``` + +Рекомендуемые статусы: + +```text +SNAPSHOT_CREATED +FILE_BUILT +ARWEAVE_UPLOADED +ARWEAVE_CONFIRMED +SOLANA_SUBMITTED +SOLANA_FINALIZED +CURSORS_COMMITTED +FAILED +``` + +Завершённые rows этой таблицы одновременно дают локальную историю больших блоков: + +```text +big_block_number +archive_hash +arweave_tx_id +``` + +По ним строится FULL-список предыдущих больших блоков следующей публикации. + +--- + +# 11. Таблица `archive_publish_job_chain` + +Хранит frozen range каждой blockchain в текущем job и координаты создаваемого chunk. + +```sql +CREATE TABLE archive_publish_job_chain ( + job_id BIGINT NOT NULL, + blockchain_name TEXT NOT NULL, + + from_source_block_number BIGINT NOT NULL, + to_source_block_number BIGINT NOT NULL, + + previous_source_block_hash BYTEA NOT NULL, + last_source_block_hash BYTEA NOT NULL, + + previous_archive_big_block_number BIGINT, + previous_archive_big_block_hash BYTEA, + previous_chunk_offset BIGINT, + previous_chunk_size BIGINT, + + new_chunk_offset BIGINT, + new_chunk_size BIGINT, + + PRIMARY KEY (job_id, blockchain_name) +); +``` + +--- + +# 12. Заморозка дельты + +Пример: + +```text +локально: +alice-001 = source block 180 +bob-001 = source block 94 + +опубликовано: +alice-001 = 160 +bob-001 = 90 +``` + +Новый job фиксирует: + +```text +alice-001: 161..180 +bob-001: 91..94 +``` + +Если во время загрузки появятся: + +```text +alice-001 181..185 +bob-001 95..97 +``` + +они попадут только в следующий job. + +--- + +# 13. Проверка курсора перед публикацией + +Перед созданием frozen range сервер проверяет: + +```text +local hash(last_archived_source_block_number) +== +archive_chain_cursor.last_archived_source_block_hash +``` + +При несовпадении публикация этой chain останавливается до resync/fork handling. + +--- + +# 14. Ограничение размера большого блока + +Offsets и sizes внутри `SHINE-ARCHIVE v1.0` используют `u32`. + +Поэтому один большой archive-файл MUST быть меньше `4 GiB`. + +Рекомендуемый запас: + +```properties +archive.maxFileBytes=4000000000 +``` + +Если данных больше, один scheduler-run формирует несколько последовательных больших блоков. + +--- + +# 15. Magic и версия + +Файл начинается ASCII: + +```text +SHINE-ARCHIVE +``` + +Размер: 13 байт. + +Далее: + +```text +u8 version_major +u8 version_minor +``` + +Для v1.0: + +```text +1 +0 +``` + +--- + +# 16. Общий layout большого блока + +```text ++------------------------------------------+ +| FIXED HEADER | ++------------------------------------------+ +| creator_login | ++------------------------------------------+ +| PREVIOUS BIG BLOCK REFERENCES | +| ref[0] | +| ref[1] | +| ... | ++------------------------------------------+ +| UserBlockchainChunk #1 | +| UserBlockchainChunk #2 | +| ... | ++------------------------------------------+ +| FOOTER: closer_login | +| block_hash | +| signature | ++------------------------------------------+ +``` + +--- + +# 17. Fixed Header v1.0 + +Все integers — big-endian. + +| Поле | Тип | Размер | +|---|---:|---:| +| magic | bytes[13] | 13 | +| version_major | u8 | 1 | +| version_minor | u8 | 1 | +| header_size | u32 | 4 | +| big_block_number | u32 | 4 | +| created_at_ms | u64 | 8 | +| creator_login_length | u8 | 1 | +| references_mode | u8 | 1 | +| references_count | u32 | 4 | +| reference_entry_size | u16 | 2 | +| parent_reference_index | u32 | 4 | +| blockchain_chunks_count | u32 | 4 | +| total_user_records_count | u32 | 4 | + +После fixed header немедленно идут `creator_login` bytes. + +`header_size` — byte offset первого `PreviousBigBlockReference`. + +--- + +# 18. `creator_login` + +SHiNE login пользователя/сервера, который сформировал большой блок. + +```text +u8 creator_login_length +bytes[N] creator_login UTF-8 +``` + +Этот login находится в начале файла, поэтому его можно узнать без скачивания payload. + +--- + +# 19. `big_block_number` + +```text +u32 +``` + +Рекомендуемая нумерация: + +```text +genesis = 0 +next = 1 +next = 2 +... +``` + +--- + +# 20. `created_at_ms` + +```text +u64 +``` + +Unix Epoch UTC milliseconds. + +Это время заморозки содержимого big block. + +--- + +# 21. Режим ссылок на предыдущие большие блоки + +```text +references_mode u8 +``` + +Значения: + +```text +0 = FULL +1 = PARTIAL, зарезервировано на будущее +``` + +Writer v1.0 MUST использовать `FULL`. + +--- + +# 22. FULL previous-block table + +Новый big block содержит ссылки на ВСЕ предыдущие большие блоки своей ветки. + +Пример: + +```text +BigBlock #365 + +References: +#0 +#1 +#2 +... +#364 +``` + +При одном big block в день через год таблица занимает примерно 24.8 KiB. + +--- + +# 23. `PreviousBigBlockReference` + +Фиксированный размер: 68 байт. + +| Поле | Тип | Размер | +|---|---:|---:| +| big_block_number | u32 | 4 | +| big_block_hash | bytes[32] | 32 | +| arweave_tx_id | bytes[32] | 32 | + +Семантика: + +```text +big_block_number = логический номер +big_block_hash = криптографическая идентичность +arweave_tx_id = где скачать блок +``` + +--- + +# 24. `references_count` + +Количество entries в таблице ссылок. + +```text +u32 +``` + +--- + +# 25. `reference_entry_size` + +```text +u16 +``` + +Для v1.0: + +```text +68 +``` + +Поле оставлено для будущего расширения entry. + +--- + +# 26. `parent_reference_index` + +```text +u32 +``` + +Индекс непосредственного родителя внутри reference table. + +Для genesis: + +```text +0xFFFFFFFF +``` + +В простой FULL-линейной ветке обычно это последний entry. + +--- + +# 27. Быстрое скачивание начала блока + +Клиент сначала скачивает fixed header + creator login. + +Из него узнаёт: + +```text +header_size +references_count +reference_entry_size +``` + +Затем скачивает только reference table и уже имеет адреса/hash всех предыдущих больших блоков. + +Payload пользователей можно пока не скачивать. + +--- + +# 28. `blockchain_chunks_count` + +```text +u32 +``` + +Количество `UserBlockchainChunk` в текущем big block. + +Одна `blockchain_name` встречается максимум один раз. + +--- + +# 29. `total_user_records_count` + +```text +u32 +``` + +Общее количество исходных SHiNE-записей во всех chunks. + +--- + +# 30. Порядок chunks и records + +Для детерминированной сериализации chunks SHOULD сортироваться по `blockchain_name ASC`. + +Внутри chunk исходные записи MUST идти по возрастанию номера исходного SHiNE-блока. + +--- + +# 31. Формат `UserBlockchainChunk` + +```text +u32 chunk_size + +u8 blockchain_name_length +bytes[N] blockchain_name UTF-8 + +u32 records_count + +repeat records_count: + u32 record_size + bytes[M] raw_record_bytes + +u32 previous_big_block_ref +u32 previous_chunk_offset +u32 previous_chunk_size +``` + +--- + +# 32. `chunk_size` + +```text +u32 +``` + +Полный размер chunk от первого байта `chunk_size` до последнего байта `previous_chunk_size` включительно. + +Это позволяет: + +- быстро пропустить chunk; +- скачать его Range-запросом; +- сохранить его точные координаты для следующего backlink. + +--- + +# 33. `blockchain_name` внутри chunk + +Хранится только `blockchain_name`. + +Отдельный `login` не нужен. + +--- + +# 34. `records_count` + +```text +u32 +``` + +Если за период у `alice-001` появилось 7 записей: + +```text +records_count = 7 +``` + +Все 7 находятся в одном chunk. + +--- + +# 35. Raw records + +Каждая исходная запись сериализуется: + +```text +u32 record_size +bytes[record_size] raw_record_bytes +``` + +`raw_record_bytes` — оригинальные данные SHiNE из БД. + +Сервер не меняет их содержимое. + +--- + +# 36. Один backlink на весь chunk + +После всех raw records находится ровно один `PreviousBlockchainChunkRef`. + +Он относится ко всей пачке записей blockchain. + +Он не повторяется у отдельных записей. + +--- + +# 37. `PreviousBlockchainChunkRef` + +| Поле | Тип | Размер | +|---|---:|---:| +| previous_big_block_ref | u32 | 4 | +| previous_chunk_offset | u32 | 4 | +| previous_chunk_size | u32 | 4 | + +Итого 12 байт на весь chunk. + +--- + +# 38. `previous_big_block_ref` + +Индекс в `PreviousBigBlockReferences[]` текущего big block. + +Он указывает на тот предыдущий big block, где находится предыдущий chunk этой же `blockchain_name`. + +Это именно reference index, а не номер блока. + +--- + +# 39. `previous_chunk_offset` + +```text +u32 +``` + +Byte offset от начала предыдущего `SHINE-ARCHIVE` до первого байта предыдущего chunk этой blockchain. + +Первый байт chunk — поле `chunk_size`. + +--- + +# 40. `previous_chunk_size` + +```text +u32 +``` + +Полный размер предыдущего chunk. + +Поэтому клиент может Range-запросом забрать сразу всю предыдущую пачку записей пользователя. + +--- + +# 41. Первая публикация blockchain + +Если у данной `blockchain_name` нет предыдущего архивного chunk: + +```text +previous_big_block_ref = 0xFFFFFFFF +previous_chunk_offset = 0 +previous_chunk_size = 0 +``` + +--- + +# 42. Пример backlink-цепочки + +В `BigBlock #100` есть `alice-001` chunk из 4 записей. + +Его backlink: + +```text +previous_big_block_ref = 94 +previous_chunk_offset = 8123 +previous_chunk_size = 1770 +``` + +`ref[94]` содержит: + +```text +big_block_number = 95 +big_block_hash = ... +arweave_tx_id = ... +``` + +Клиент: + +1. получает TX big block #95; +2. проверяет hash; +3. делает Range на `8123 .. 8123+1770`; +4. получает весь предыдущий `alice-001` chunk; +5. читает его записи; +6. берёт следующий backlink. + +--- + +# 43. Почему TX ID не хранится в каждом chunk + +TX ID и hash предыдущих больших блоков уже один раз записаны в общей reference table. + +Поэтому в chunk достаточно компактного `u32 previous_big_block_ref`. + +--- + +# 44. Footer большого блока + +После последнего chunk: + +```text +u8 closer_login_length +bytes[N] closer_login UTF-8 + +bytes[32] block_hash +bytes[64] closer_signature +``` + +--- + +# 45. `closer_login` + +Login пользователя/сервера, который сформировал и закрыл big block. + +В v1.0: + +```text +closer_login MUST == creator_login +``` + +Login повторяется намеренно: + +- header даёт автора сразу; +- footer явно фиксирует того, кто закрыл блок; +- hash покрывает footer login; +- verifier проверяет совпадение. + +--- + +# 46. `block_hash` + +```text +bytes[32] +SHA-256 +``` + +Хэшируется всё от первого байта magic до последнего байта `closer_login` включительно. + +Не входят: + +```text +block_hash +closer_signature +``` + +Псевдокод: + +```text +hash_input = + fixed_header + + creator_login + + references + + all UserBlockchainChunks + + closer_login_length + + closer_login + +block_hash = SHA256(hash_input) +``` + +--- + +# 47. `closer_signature` + +```text +bytes[64] +Ed25519 +``` + +Подписывается: + +```text +ASCII("SHINE-ARCHIVE-V1") || block_hash +``` + +Для v1.0 используется root private key аккаунта `creator_login`. + +--- + +# 48. Проверка подписи + +Verifier: + +1. читает `creator_login`; +2. проверяет `creator_login == closer_login`; +3. получает root public key аккаунта из User PDA / исторического состояния Solana; +4. пересчитывает `block_hash`; +5. проверяет Ed25519 signature. + +Старые архивы должны оставаться проверяемыми после ротации root key, поэтому исторические PDA-состояния нужно сохранять/уметь получать. + +--- + +# 49. Собственный Arweave TX ID текущего блока + +Текущий файл не может заранее знать свой будущий TX ID. + +Поэтому собственный TX ID: + +- не входит в сам текущий файл; +- сохраняется после upload; +- записывается в Solana PDA; +- появляется как previous-block reference уже в следующем big block. + +--- + +# 50. User PDA block type `100` + +Archive head хранится прямо в существующем User PDA publisher-а. + +Формат: + +```text +u8 block_type = 100 +u8 block_version = 0 +bytes[32] archive_tx_id +bytes[32] archive_hash +``` + +Итого 66 байт. + +`archive_hash` = `block_hash` последнего успешно опубликованного big block. + +--- + +# 51. Обновление Solana + +Отдельная новая Solana instruction не требуется. + +Используется существующий `update_user_pda`. + +Нужно расширить существующие serializer/parser/codec так, чтобы block type 100: + +- читался; +- записывался; +- сохранялся обычными обновлениями; +- мог быть изменён архивным сервером обычным update User PDA. + +--- + +# 52. Полный порядок публикации + +```text +1. Существующая server-to-server sync независимо обновляет локальные blockchains. + +2. Scheduler запускает archive job. + +3. Проверяется отсутствие другого активного archive job. + +4. Читаются archive_chain_cursor. + +5. Читаются текущие локальные blockchain heads. + +6. Для каждой blockchain вычисляется frozen delta. + +7. Создаётся archive_publish_job. + +8. Создаются archive_publish_job_chain rows. + +9. Загружается история всех успешно finalized previous big blocks. + +10. Формируется fixed header. + +11. Записывается creator_login. + +12. Записывается FULL PreviousBigBlockReferences. + +13. Для каждой blockchain_name: + - читается frozen range; + - создаётся ровно один UserBlockchainChunk; + - внутрь кладутся все новые records; + - добавляется один PreviousBlockchainChunkRef; + - сохраняются new_chunk_offset/new_chunk_size. + +14. Записывается closer_login. + +15. Вычисляется SHA-256 block_hash. + +16. Создаётся Ed25519 closer_signature. + +17. Файл закрывается. + +18. Файл загружается в Arweave. + +19. TX ID сохраняется в archive_publish_job. + +20. Сервер ждёт требуемое число Arweave confirmations. + +21. Обычным update_user_pda обновляется block type 100: + archive_tx_id + archive_hash + +22. Сервер ждёт Solana finalized. + +23. В одной DB transaction обновляются archive_chain_cursor. + +24. Job получает CURSORS_COMMITTED. +``` + +--- + +# 53. Cursor commit + +Для каждой blockchain из job: + +```text +last_archived_source_block_number = to_source_block_number +last_archived_source_block_hash = last_source_block_hash + +last_archive_big_block_number = current big_block_number +last_archive_big_block_hash = current block_hash + +last_chunk_offset = new_chunk_offset +last_chunk_size = new_chunk_size +``` + +Следующая публикация сразу знает, куда должен вести backlink. + +--- + +# 54. Arweave service + +Старый `TestFreeAvatarArweaveService` больше не нужен как avatar-specific сервис. + +Его следует переделать/переименовать, например в: + +```text +ArweaveArchiveService +``` + +Он должен: + +- использовать Arweave JWK; +- подписывать и оплачивать upload; +- поддерживать большие/chunked uploads; +- возвращать TX ID; +- ждать подтверждения; +- опрашивать confirmations. + +Рекомендуемые настройки: + +```properties +archive.arweave.gateway=https://arweave.net +archive.arweave.walletJwkPath=/opt/shine/secrets/archive-wallet.json +archive.arweave.minConfirmations=1 +archive.arweave.confirmPollSeconds=30 +archive.arweave.confirmTimeoutMinutes=180 +``` + +--- + +# 55. Solana finalization + +После обычного `update_user_pda` сервер должен дождаться: + +```text +finalized +``` + +Только после этого разрешается сдвинуть локальные курсоры. + +--- + +# 56. Crash recovery + +## После `SNAPSHOT_CREATED` + +Возобновить тот же frozen job. + +## После `FILE_BUILT` + +Использовать тот же локальный файл. + +## После `ARWEAVE_UPLOADED` + +Не загружать второй раз. Продолжить по сохранённому TX ID. + +## После `ARWEAVE_CONFIRMED` + +Продолжить Solana update. + +## После `SOLANA_SUBMITTED` + +Сначала проверить transaction / текущий PDA. + +## После `SOLANA_FINALIZED`, но до cursor commit + +Проверить: + +```text +PDA.archive_tx_id == expected_tx +PDA.archive_hash == expected_hash +``` + +После этого безопасно выполнить cursor commit. + +--- + +# 57. Нет новых данных + +Если новых записей нет ни в одной blockchain: + +```text +не создавать пустой SHINE-ARCHIVE +не загружать Arweave +не обновлять Solana +``` + +--- + +# 58. FULL references как стартовая стратегия + +v1.0 сознательно хранит ВСЕ previous big blocks в каждом новом big block. + +Плюсы: + +- актуальный блок сразу даёт карту всей предыдущей истории; +- не нужно идти parent-by-parent; +- backlink может использовать компактный ref index; +- reader очень простой. + +При одном big block в сутки: + +```text +1 год: ~24.8 KiB +5 лет: ~121 KiB +20 лет: ~485 KiB +``` + +--- + +# 59. PARTIAL references + +`references_mode = 1` зарезервирован на будущее. + +v1.0 writer его не реализует. + +В будущем PARTIAL может означать: + +- только реально используемые previous blocks; +- последние N blocks; +- checkpoint references; +- смешанную стратегию. + +--- + +# 60. Чтение истории одной blockchain + +Имея актуальный chunk `alice-001`, клиент: + +1. читает все records chunk; +2. читает `PreviousBlockchainChunkRef`; +3. если ref = `0xFFFFFFFF`, история закончилась; +4. иначе берёт соответствующий `PreviousBigBlockReference`; +5. получает TX ID + hash previous big block; +6. Range-запросом читает `previous_chunk_offset/previous_chunk_size`; +7. получает сразу всю предыдущую пачку записей `alice-001`; +8. повторяет. + +Так можно читать историю конкретной blockchain, не скачивая чужие chunks. + +--- + +# 61. Дубликаты + +Если исходная SHiNE-запись уже есть у получателя, она пропускается/дедуплицируется стандартной логикой. + +--- + +# 62. Конфликты исходной blockchain + +Архивная ветка сама по себе не является пользовательским конфликтом. + +Конфликт возникает, когда одна `blockchain_name` имеет два несовместимых валидно подписанных продолжения одной предыдущей точки. + +Для v1 безопасно: + +```text +сохранить обе ветки +пометить blockchain CONFLICTED +остановить автоматическое продолжение +``` + +Механизм разрешения fork можно добавить позже. + +--- + +# 63. Авторство большого блока + +Автор указан дважды: + +## Header + +```text +creator_login +``` + +## Footer + +```text +closer_login +block_hash +closer_signature +``` + +Для v1.0: + +```text +creator_login == closer_login +``` + +Это означает: именно этот SHiNE-аккаунт сформировал и криптографически закрыл big block. + +--- + +# 64. Подпись сервера не заменяет подписи пользователей + +`closer_signature` доказывает авторство большого archive block. + +Каждая пользовательская raw record всё равно проверяется существующей криптографией SHiNE. + +--- + +# 65. Независимость от Arweave + +Arweave TX ID — location. + +SHA-256 — identity. + +Поэтому архивы можно перенести на другой storage и всё равно проверять. + +--- + +# 66. Block type 100 в Solana — текущая голова + +User PDA publisher-а хранит только: + +```text +current archive TX ID +current archive SHA-256 +``` + +Детальная история находится внутри big blocks. + +--- + +# 67. Startup preflight + +Перед запуском archive publisher сервер SHOULD проверить: + +```text +derivePublic(root_private) == UserPDA.root_public_key +``` + +и: + +```text +derivePublic(client_private) == UserPDA.client_key +``` + +При несовпадении archive publisher не запускается. + +--- + +# 68. Рекомендуемые настройки + +```properties +archive.publish.enabled=false +archive.publish.intervalMinutes=720 +archive.publish.initialDelayMinutes=15 + +archive.maxFileBytes=4000000000 + +archive.arweave.gateway=https://arweave.net +archive.arweave.walletJwkPath=/opt/shine/secrets/archive-wallet.json +archive.arweave.minConfirmations=1 +archive.arweave.confirmPollSeconds=30 +archive.arweave.confirmTimeoutMinutes=180 + +archive.solana.rootKeyPath=/opt/shine/secrets/root.key +archive.solana.clientKeyPath=/opt/shine/secrets/client.key +archive.solana.commitment=finalized +``` + +--- + +# 69. Требование к `AppConfig` + +Archive settings должны читаться из: + +- Java system properties; +- environment variables; +- `application.properties`. + +`getInt()` и `getBoolean()` должны использовать общую логику `getParam()`. + +--- + +# 70. Что переиспользуется из текущего сервера + +Существующие механизмы: + +```text +BlockchainStateDAO.listAll() +BlocksDAO.listRangeByNumber(...) +``` + +дают необходимые heads и raw block ranges. + +Используются существующие: + +```text +block_bytes +block_hash +block_signature +blockchain_name +block_number +``` + +Server-to-server sync в v1.0 не меняется. + +--- + +# 71. Блокировки + +Можно использовать существующий blockchain lock только на коротком этапе: + +```text +проверить cursor hash +прочитать frozen range +проверить конечный hash +сохранить/сериализовать frozen data +``` + +Lock не удерживается во время Arweave/Solana ожиданий. + +--- + +# 72. Реализационный checklist + +## Database + +- [ ] `archive_chain_cursor` +- [ ] `archive_publish_job` +- [ ] `archive_publish_job_chain` +- [ ] schema migration +- [ ] crash recovery + +## Archive writer + +- [ ] magic `SHINE-ARCHIVE` +- [ ] major/minor version +- [ ] fixed header +- [ ] creator login +- [ ] FULL previous big block table +- [ ] one chunk per `blockchain_name` +- [ ] many raw records inside one chunk +- [ ] one backlink per chunk +- [ ] closer login +- [ ] SHA-256 +- [ ] Ed25519 signature +- [ ] max file size < 4 GiB + +## Arweave + +- [ ] rename/refactor `TestFreeAvatarArweaveService` +- [ ] remove avatar-specific logic +- [ ] large/chunked upload +- [ ] confirmation polling + +## Solana + +- [ ] add PDA block type `100` +- [ ] update Rust codec +- [ ] update Java codec +- [ ] update JS codec/UI writer +- [ ] use ordinary `update_user_pda` +- [ ] server-side transaction writer +- [ ] root signature +- [ ] client fee payer +- [ ] wait for `finalized` + +## Scheduler + +- [ ] `archive.publish.enabled` +- [ ] `archive.publish.intervalMinutes` +- [ ] `archive.publish.initialDelayMinutes` +- [ ] no concurrent jobs +- [ ] split files > max size +- [ ] skip when no new blocks + +--- + +# 73. Итоговая бинарная схема v1.0 + +```text +SHINE-ARCHIVE +version_major u8 +version_minor u8 + +header_size u32 +big_block_number u32 +created_at_ms u64 + +creator_login_length u8 + +references_mode u8 +references_count u32 +reference_entry_size u16 +parent_reference_index u32 + +blockchain_chunks_count u32 +total_user_records_count u32 + +creator_login bytes + + +PreviousBigBlockReference[references_count]: + + big_block_number u32 + big_block_hash [32] + arweave_tx_id [32] + + +UserBlockchainChunk[blockchain_chunks_count]: + + chunk_size u32 + + blockchain_name_length u8 + blockchain_name bytes + + records_count u32 + + repeat records_count: + record_size u32 + raw_record_bytes + + previous_big_block_ref u32 + previous_chunk_offset u32 + previous_chunk_size u32 + + +FOOTER: + + closer_login_length u8 + closer_login bytes + + block_hash [32] + closer_signature [64] +``` + +--- + +# 74. Основная архитектура одним рисунком + +```text +SOLANA USER PDA + | + | block type 100 + | TX + HASH + v +BigBlock #100 + | + +-- Header + | creator = server-A + | + +-- References + | #0 -> BigBlock #0 / hash / TX + | #1 -> BigBlock #1 / hash / TX + | ... + | #99 -> BigBlock #99 / hash / TX + | + +-- alice-001 chunk + | records x4 + | | + | +--> BigBlock #95 / AliceChunk + | + +-- bob-001 chunk + | records x2 + | | + | +--> BigBlock #98 / BobChunk + | + +-- kate-002 chunk + | records x10 + | | + | +--> BigBlock #74 / KateChunk + | + +-- Footer + closer = server-A + SHA-256 + Ed25519 signature +``` + +--- + +# 75. Итоговые решения v1.0 + +- один большой блок = одна архивная публикация; +- в начале лежит полный список всех предыдущих больших блоков; +- каждый previous block reference содержит `number + SHA-256 + Arweave TX ID`; +- одна `blockchain_name` встречается в большом блоке максимум одним chunk; +- все новые записи этой blockchain объединяются в этот chunk; +- chunk имеет одну ссылку на предыдущий chunk этой же blockchain; +- backlink состоит из `previous big block ref + chunk offset + chunk size`; +- сервер сам создаёт backlink; +- пользовательские raw records не меняются; +- login сервера есть в header и footer; +- большой блок закрывается SHA-256 + Ed25519 signature сервера; +- Solana User PDA block `100` хранит текущий TX ID + archive hash; +- PDA обновляется обычным `update_user_pda`; +- Arweave используется как долговременное хранилище; +- публикация считается завершённой только после Arweave confirmation + Solana finalized + cursor commit. diff --git a/shine-UI/firebase-messaging-sw.js b/shine-UI/firebase-messaging-sw.js index 855678ee..47df1f1f 100644 --- a/shine-UI/firebase-messaging-sw.js +++ b/shine-UI/firebase-messaging-sw.js @@ -131,7 +131,7 @@ self.addEventListener('push', (event) => { const notifyPromise = shouldNotify ? self.registration.showNotification(notificationTitle, { - body: body || (fromLogin ? `Вам пришло сообщение от ${fromLogin}` : 'Вам пришло сообщение'), + body: body || (fromLogin ? `Новое сообщение от ${fromLogin}` : 'Новое сообщение'), tag: callId || (kind === 'test_push' ? 'shine-test-push' : 'shine-direct-message'), renotify: true, requireInteraction: kind === 'incoming_call', diff --git a/shine-UI/js/pages/advanced-settings-view.js b/shine-UI/js/pages/advanced-settings-view.js index 814b0fda..56319582 100644 --- a/shine-UI/js/pages/advanced-settings-view.js +++ b/shine-UI/js/pages/advanced-settings-view.js @@ -41,14 +41,14 @@ export function render({ navigate, chrome }) { const developerRow = createToggleRow({ id: 'advanced-developer-tools', title: 'Настройки разработчика', - hint: 'Показывать блок «Версии» и кнопку настроек разработчика в обычных настройках.', + hint: 'Показывать блок «Версии» и кнопку настроек разработчика в обычных настройках. По умолчанию выключено.', checked: isDeveloperToolsEnabled(), }); const filesRow = createToggleRow({ id: 'advanced-dm-files', title: 'Передача файлов в личных сообщениях', - hint: 'Разрешить отправку новых зашифрованных файлов через удержание кнопки эмодзи. Полученные ранее файлы останутся доступными.', + hint: 'Разрешить зашифрованные файлы любого размера, несколько файлов за сообщение и голосовые. Полученные ранее файлы останутся доступными.', checked: isDmFileTransferEnabled(), }); diff --git a/shine-UI/js/pages/chat-view.js b/shine-UI/js/pages/chat-view.js index 67297f0d..fa1429b2 100644 --- a/shine-UI/js/pages/chat-view.js +++ b/shine-UI/js/pages/chat-view.js @@ -32,9 +32,11 @@ import { showToast } from '../services/channels-ux.js'; import { buildDmFileTechBlock, buildDmReplyTechBlock, parseDmTechBlocks, sanitizeUserDmTextForSend } from '../services/dm-tech-blocks.js'; import { isDmFileTransferEnabled } from '../services/feature-settings.js'; import { + decryptDmFileToBlob, downloadAndDecryptDmFile, encryptAndUploadDmFile, formatDmFileSize, + DM_MAX_ATTACHMENTS_PER_MESSAGE, } from '../services/dm-file-service.js'; import { loadProfileSnapshot } from '../services/user-profile-params.js'; import { makeProfileLinksRoute, makeProfileRoute } from '../services/shine-routes.js'; @@ -56,6 +58,7 @@ const CONVERSATION_CLEAR_NOTICE_TEXT = 'История переписки очи function menuIconSvg(name) { const paths = { reply: '', + forward: '', copy: '', read: '', edit: '', @@ -293,16 +296,214 @@ function openDeleteChatConfirmModal({ contactName = '', relationType = 'none', o }); } +function buildForwardedDmText(message = {}) { + const parsed = parseDmTechBlocks(String(message?.text || '')); + const attachments = Array.isArray(parsed?.fileAttachments) ? parsed.fileAttachments : []; + const attachmentBlocks = attachments.map((attachment) => buildDmFileTechBlock(attachment)).join(''); + const visibleText = sanitizeUserDmTextForSend(String(parsed?.displayText || parsed?.visibleText || '').trim()); + if (attachmentBlocks) { + const fallback = visibleText || (attachments.length === 1 + ? `📎 ${String(attachments[0]?.name || 'file')}` + : `📎 Файлы: ${attachments.length}`); + return `${attachmentBlocks}${fallback}`; + } + return visibleText; +} + +function clipForwardPreview(text = '', maxLen = 130) { + const normalized = String(text || '').replace(/\s+/g, ' ').trim(); + if (normalized.length <= maxLen) return normalized; + return `${normalized.slice(0, Math.max(1, maxLen - 1))}…`; +} + +async function openForwardMessageDialog({ message, sourceChatId = '', onConfirm } = {}) { + const root = document.getElementById('modal-root'); + if (!root) return; + + const parsed = parseDmTechBlocks(String(message?.text || '')); + const preview = clipForwardPreview(parsed?.displayText || parsed?.visibleText || 'Вложение'); + root.innerHTML = ` + + `; + + const close = () => { root.innerHTML = ''; }; + root.querySelector('#chat-forward-close')?.addEventListener('click', close); + root.querySelector('#chat-forward-modal')?.addEventListener('click', (event) => { + if (event.target?.id === 'chat-forward-modal') close(); + }); + + const previewEl = root.querySelector('#chat-forward-preview'); + if (previewEl) { + previewEl.textContent = preview || (parsed?.fileAttachments?.length ? 'Вложение' : 'Сообщение'); + } + + const list = root.querySelector('#chat-forward-list'); + if (!list) return; + + try { + const payload = await authService.listContacts(); + if (!root.querySelector('#chat-forward-modal')) return; + const dialogs = Array.isArray(payload?.dialogs) ? payload.dialogs : []; + const byLogin = new Map(); + dialogs.forEach((dialog) => { + const peerLogin = normalizeDmChatId(dialog?.peerLogin); + if (!peerLogin) return; + byLogin.set(peerLogin.toLowerCase(), { + peerLogin, + firstName: String(dialog?.firstName || '').trim(), + lastName: String(dialog?.lastName || '').trim(), + lastMessageTimeMs: Number(dialog?.lastMessageTimeMs || 0), + }); + }); + Object.keys(state.chats || {}).forEach((chatKey) => { + const peerLogin = normalizeDmChatId(chatKey); + if (!peerLogin) return; + const key = peerLogin.toLowerCase(); + if (!byLogin.has(key)) byLogin.set(key, { peerLogin, firstName: '', lastName: '', lastMessageTimeMs: 0 }); + }); + const sourceLogin = normalizeDmChatId(sourceChatId); + if (sourceLogin && !byLogin.has(sourceLogin.toLowerCase())) { + byLogin.set(sourceLogin.toLowerCase(), { peerLogin: sourceLogin, firstName: '', lastName: '', lastMessageTimeMs: 0 }); + } + + const rows = Array.from(byLogin.values()).sort((a, b) => { + const timeDiff = Number(b.lastMessageTimeMs || 0) - Number(a.lastMessageTimeMs || 0); + if (timeDiff) return timeDiff; + return a.peerLogin.localeCompare(b.peerLogin, 'ru'); + }); + list.replaceChildren(); + if (!rows.length) { + const empty = document.createElement('div'); + empty.className = 'meta-muted dm-forward-loading'; + empty.textContent = 'Нет доступных чатов'; + list.append(empty); + return; + } + + rows.forEach((item) => { + const fullName = [item.firstName, item.lastName].filter(Boolean).join(' '); + const row = document.createElement('button'); + row.type = 'button'; + row.className = 'ui-button dm-forward-chat-row'; + + const avatar = renderUserAvatar({ + login: item.peerLogin, + firstName: item.firstName, + lastName: item.lastName, + size: 'md', + className: 'dm-forward-avatar', + title: item.peerLogin, + }); + const copy = document.createElement('span'); + copy.className = 'dm-forward-chat-copy'; + const name = document.createElement('strong'); + name.textContent = fullName || item.peerLogin; + const login = document.createElement('span'); + login.className = 'meta-muted'; + login.textContent = `@${item.peerLogin}`; + copy.append(name, login); + const arrow = document.createElement('span'); + arrow.className = 'dm-forward-chat-arrow'; + arrow.textContent = '›'; + row.append(avatar, copy, arrow); + + row.addEventListener('click', () => { + const card = root.querySelector('.dm-forward-card'); + if (!card) return; + card.innerHTML = ''; + + const confirmTitle = document.createElement('h3'); + confirmTitle.className = 'modal-title'; + confirmTitle.textContent = 'Переслать сюда?'; + const recipient = document.createElement('div'); + recipient.className = 'dm-forward-confirm-peer'; + recipient.append( + renderUserAvatar({ login: item.peerLogin, firstName: item.firstName, lastName: item.lastName, size: 'lg' }), + ); + const recipientCopy = document.createElement('div'); + const recipientName = document.createElement('strong'); + recipientName.textContent = fullName || item.peerLogin; + const recipientLogin = document.createElement('div'); + recipientLogin.className = 'meta-muted'; + recipientLogin.textContent = `@${item.peerLogin}`; + recipientCopy.append(recipientName, recipientLogin); + recipient.append(recipientCopy); + + const confirmPreview = document.createElement('div'); + confirmPreview.className = 'dm-forward-preview dm-forward-preview--confirm'; + confirmPreview.textContent = preview || (parsed?.fileAttachments?.length ? 'Вложение' : 'Сообщение'); + + const actions = document.createElement('div'); + actions.className = 'form-actions-grid dm-forward-confirm-actions'; + const back = document.createElement('button'); + back.type = 'button'; + back.className = 'secondary-btn'; + back.textContent = 'Назад'; + const confirm = document.createElement('button'); + confirm.type = 'button'; + confirm.className = 'primary-btn'; + confirm.textContent = 'Переслать'; + actions.append(back, confirm); + card.append(confirmTitle, recipient, confirmPreview, actions); + + back.addEventListener('click', () => { + close(); + void openForwardMessageDialog({ message, sourceChatId, onConfirm }); + }); + confirm.addEventListener('click', async () => { + if (confirm.disabled) return; + confirm.disabled = true; + back.disabled = true; + confirm.textContent = 'Отправляем…'; + try { + await onConfirm?.(item.peerLogin); + close(); + showToast(`Сообщение переслано в чат @${item.peerLogin}`, { timeoutMs: 1500 }); + } catch (error) { + confirm.disabled = false; + back.disabled = false; + confirm.textContent = 'Переслать'; + showToast(error?.message || 'Не удалось переслать сообщение', { kind: 'error', timeoutMs: 2400 }); + } + }); + }); + list.append(row); + }); + } catch (error) { + list.replaceChildren(); + const fail = document.createElement('div'); + fail.className = 'meta-muted dm-forward-loading'; + fail.textContent = `Не удалось загрузить чаты: ${error?.message || 'unknown'}`; + list.append(fail); + } +} + function openMessageActionsMenu({ anchorX = 0, anchorY = 0, messageText = '', infoText = '', canReply = false, + canForward = true, showReadAloud = true, canEdit = false, canDelete = false, onReply, + onForward, onReadAloud, onEdit, onDelete, @@ -315,6 +516,7 @@ function openMessageActionsMenu({
+ - +
- + - +
`; @@ -1181,6 +1539,10 @@ export function render({ navigate, route, chrome }) { const emojiToggle = form.querySelector('#chat-emoji-toggle'); const fileToggle = form.querySelector('#chat-file-toggle'); const fileInput = form.querySelector('#chat-file-input'); + const sendBtn = form.querySelector('.dm-send-btn'); + const voiceStatus = form.querySelector('#chat-voice-recording-status'); + const voiceTime = form.querySelector('#chat-voice-recording-time'); + const voiceHint = form.querySelector('#chat-voice-recording-hint'); const fileTransferEnabled = isDmFileTransferEnabled(); const editBanner = form.querySelector('#chat-edit-banner'); const editBannerText = form.querySelector('#chat-edit-banner-text'); @@ -1193,6 +1555,18 @@ export function render({ navigate, route, chrome }) { let emojiLongPressTimer = null; let emojiLongPressTriggered = false; let fileActionVisible = false; + let voiceRecorder = null; + let voiceStream = null; + let voiceChunks = []; + let voiceStartedAt = 0; + let voiceTimer = null; + let voiceLocked = false; + let voicePointerHeld = false; + let voiceReleaseRequested = false; + let voiceStartY = 0; + let voicePointerId = null; + let voiceBusy = false; + let suppressVoiceClick = false; const setHistoryLoadingState = (isLoading) => { historyLoader.hidden = !isLoading; @@ -1233,6 +1607,217 @@ export function render({ navigate, route, chrome }) { syncComposerLayout(); }; + + const sendIconMarkup = (mode) => { + if (mode === 'send') { + return ''; + } + if (mode === 'stop') { + return ''; + } + if (mode === 'busy') { + return ''; + } + return ''; + }; + + const currentVoiceDurationMs = () => voiceStartedAt > 0 ? Math.max(0, Date.now() - voiceStartedAt) : 0; + + const syncVoiceTimer = () => { + if (voiceTime) voiceTime.textContent = formatVoiceDuration(currentVoiceDurationMs()); + }; + + const clearVoiceTimer = () => { + if (voiceTimer != null) { + window.clearInterval(voiceTimer); + voiceTimer = null; + } + }; + + const stopVoiceStreamTracks = () => { + try { + voiceStream?.getTracks?.().forEach((track) => track.stop()); + } catch { + // ignore microphone cleanup failures + } + voiceStream = null; + }; + + const updateSendButtonMode = () => { + if (!sendBtn) return; + const recording = Boolean(voiceRecorder && voiceRecorder.state !== 'inactive'); + let mode = 'voice'; + if (voiceBusy) mode = 'busy'; + else if (recording && voiceLocked) mode = 'stop'; + else if (recording) mode = 'voice'; + else if (activeEdit || String(input?.value || '').trim()) mode = 'send'; + + sendBtn.dataset.mode = mode; + sendBtn.innerHTML = sendIconMarkup(mode); + sendBtn.classList.toggle('is-recording', recording); + sendBtn.classList.toggle('is-locked', recording && voiceLocked); + sendBtn.disabled = voiceBusy || (mode === 'voice' && !recording && !fileTransferEnabled); + if (mode === 'send') { + sendBtn.title = 'Отправить'; + sendBtn.setAttribute('aria-label', 'Отправить'); + } else if (mode === 'stop') { + sendBtn.title = 'Остановить и отправить голосовое'; + sendBtn.setAttribute('aria-label', 'Остановить и отправить голосовое'); + } else if (mode === 'busy') { + sendBtn.title = 'Шифрование и отправка голосового'; + sendBtn.setAttribute('aria-label', 'Шифрование и отправка голосового'); + } else { + sendBtn.title = 'Удерживайте для записи голосового'; + sendBtn.setAttribute('aria-label', 'Записать голосовое сообщение'); + } + form.classList.toggle('dm-chat-input--recording', recording); + if (voiceStatus) voiceStatus.hidden = !recording; + if (input) input.hidden = recording; + if (emojiToggle) emojiToggle.hidden = recording; + if (fileToggle && recording) fileToggle.hidden = true; + if (!recording && voiceHint) voiceHint.textContent = '↑ проведите вверх, чтобы закрепить'; + syncVoiceTimer(); + }; + + const selectVoiceMimeType = () => { + const candidates = [ + 'audio/webm;codecs=opus', + 'audio/ogg;codecs=opus', + 'audio/mp4', + 'audio/webm', + ]; + return candidates.find((mime) => globalThis.MediaRecorder?.isTypeSupported?.(mime)) || ''; + }; + + const voiceExtension = (mime = '') => { + const value = String(mime || '').toLowerCase(); + if (value.includes('ogg')) return 'ogg'; + if (value.includes('mp4') || value.includes('m4a')) return 'm4a'; + return 'webm'; + }; + + const stopVoiceRecording = ({ send = true } = {}) => { + voicePointerHeld = false; + voiceReleaseRequested = true; + if (!voiceRecorder || voiceRecorder.state === 'inactive') return; + voiceRecorder.__shineShouldSend = Boolean(send); + try { + voiceRecorder.stop(); + } catch { + stopVoiceStreamTracks(); + } + }; + + const startVoiceRecording = async ({ pointerY = 0, pointerId = null } = {}) => { + if (voiceBusy || (voiceRecorder && voiceRecorder.state !== 'inactive')) return; + if (!fileTransferEnabled) { + showToast('Передача файлов и голосовых отключена в дополнительных настройках.', { kind: 'error', timeoutMs: 1900 }); + return; + } + if (activeEdit) { + showToast('Сначала завершите редактирование сообщения.', { kind: 'error', timeoutMs: 1600 }); + return; + } + if (!globalThis.MediaRecorder || !navigator.mediaDevices?.getUserMedia) { + showToast('Этот браузер не поддерживает запись голосовых сообщений.', { kind: 'error', timeoutMs: 2400 }); + return; + } + + voicePointerHeld = true; + voiceReleaseRequested = false; + voiceLocked = false; + voiceStartY = Number(pointerY || 0); + voicePointerId = pointerId; + suppressVoiceClick = false; + + try { + closeEmojiPicker(); + setFileActionVisible(false); + voiceStream = await navigator.mediaDevices.getUserMedia({ + audio: { + echoCancellation: true, + noiseSuppression: true, + autoGainControl: true, + }, + video: false, + }); + const mimeType = selectVoiceMimeType(); + const recorder = mimeType + ? new MediaRecorder(voiceStream, { mimeType }) + : new MediaRecorder(voiceStream); + voiceRecorder = recorder; + voiceChunks = []; + recorder.__shineShouldSend = true; + recorder.addEventListener('dataavailable', (event) => { + if (event.data?.size > 0) voiceChunks.push(event.data); + }); + recorder.addEventListener('error', (event) => { + showToast(event?.error?.message || 'Ошибка записи микрофона', { kind: 'error', timeoutMs: 2200 }); + }); + recorder.addEventListener('stop', async () => { + const shouldSend = recorder.__shineShouldSend !== false; + const durationMs = currentVoiceDurationMs(); + const chunks = voiceChunks.slice(); + voiceChunks = []; + voiceRecorder = null; + voiceLocked = false; + voiceStartedAt = 0; + clearVoiceTimer(); + stopVoiceStreamTracks(); + voiceBusy = shouldSend && chunks.some((chunk) => chunk.size > 0); + updateSendButtonMode(); + + if (!voiceBusy) return; + try { + const mime = String(recorder.mimeType || mimeType || chunks[0]?.type || 'audio/webm'); + const blob = new Blob(chunks, { type: mime }); + const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); + const file = new File([blob], `voice-${timestamp}.${voiceExtension(mime)}`, { type: mime, lastModified: Date.now() }); + const attachment = await encryptAndUploadDmFile({ + file, + login: state.session.login, + sessionId: state.session.sessionId, + wsUrl: authService.serverUrl, + kind: 'voice', + durationMs, + onProgress: ({ processedBytes, totalBytes }) => { + if (!totalBytes) return; + const percent = Math.round((processedBytes / totalBytes) * 100); + if (voiceHint) voiceHint.textContent = `Шифрование и загрузка ${Math.min(100, percent)}%`; + }, + }); + await sendFileAttachmentMessage([attachment]); + showToast('Голосовое сообщение отправлено.', { timeoutMs: 1200 }); + } catch (error) { + showToast(error?.message || 'Не удалось отправить голосовое сообщение', { kind: 'error', timeoutMs: 2800 }); + } finally { + voiceBusy = false; + updateSendButtonMode(); + } + }, { once: true }); + + voiceStartedAt = Date.now(); + recorder.start(1000); + voiceTimer = window.setInterval(syncVoiceTimer, 250); + updateSendButtonMode(); + if (voiceReleaseRequested && !voiceLocked) stopVoiceRecording({ send: true }); + } catch (error) { + voicePointerHeld = false; + voiceReleaseRequested = false; + voiceLocked = false; + voiceRecorder = null; + voiceStartedAt = 0; + clearVoiceTimer(); + stopVoiceStreamTracks(); + updateSendButtonMode(); + const denied = error?.name === 'NotAllowedError' || error?.name === 'SecurityError'; + showToast( + denied ? 'Нужно разрешить SHiNE доступ к микрофону.' : (error?.message || 'Не удалось начать запись'), + { kind: 'error', timeoutMs: 2600 }, + ); + } + }; + // На сенсорных устройствах фокус в textarea поднимает клавиатуру поверх пикера — // поэтому при вставке эмодзи фокусируем поле только там, где есть «точный» указатель. const isFinePointer = window.matchMedia?.('(hover: hover) and (pointer: fine)')?.matches ?? true; @@ -1255,6 +1840,7 @@ export function render({ navigate, route, chrome }) { } autoResizeComposer(input); syncComposerLayout(); + updateSendButtonMode(); }; if (emojiSlot) { @@ -1298,6 +1884,7 @@ export function render({ navigate, route, chrome }) { syncComposerLayout(); focusInputToEnd(); } + updateSendButtonMode(); }; const cancelReplyMode = ({ restoreDraft = true } = {}) => { @@ -1309,6 +1896,7 @@ export function render({ navigate, route, chrome }) { syncComposerLayout(); focusInputToEnd(); } + updateSendButtonMode(); }; const startEditMode = (msg) => { @@ -1332,6 +1920,7 @@ export function render({ navigate, route, chrome }) { syncComposerLayout(); focusInputToEnd(); } + updateSendButtonMode(); }; const startReplyMode = (msg) => { @@ -1508,6 +2097,7 @@ export function render({ navigate, route, chrome }) { input.value = text; autoResizeComposer(input); syncComposerLayout(); + updateSendButtonMode(); focusInputToEnd(); } addChatMessage(chatId, `${activeEdit ? 'Ошибка изменения' : 'Ошибка отправки'}: ${e.message || 'unknown'}`); @@ -1524,17 +2114,24 @@ export function render({ navigate, route, chrome }) { } }; - const sendFileAttachmentMessage = async (attachment) => { + const sendFileAttachmentMessage = async (attachmentsInput) => { + const attachments = (Array.isArray(attachmentsInput) ? attachmentsInput : [attachmentsInput]).filter(Boolean); + if (!attachments.length) return; + if (attachments.length > DM_MAX_ATTACHMENTS_PER_MESSAGE) { + throw new Error(`За одно сообщение можно отправить максимум ${DM_MAX_ATTACHMENTS_PER_MESSAGE} файлов`); + } const replying = activeEdit ? null : activeReply; - const fileBlock = buildDmFileTechBlock(attachment); + const fileBlocks = attachments.map((attachment) => buildDmFileTechBlock(attachment)).join(''); const replyBlock = replying ? buildDmReplyTechBlock({ baseKey: replying.baseKey }) : ''; - const fallbackText = `📎 ${String(attachment?.name || 'file')}`; - const finalText = `${replyBlock}${fileBlock}${fallbackText}`; + const onlyVoice = attachments.length === 1 && attachments[0]?.kind === 'voice'; + const fallbackText = onlyVoice + ? '🎙 Голосовое сообщение' + : (attachments.length === 1 + ? `📎 ${String(attachments[0]?.name || 'file')}` + : `📎 Файлы: ${attachments.length}`); + const finalText = `${replyBlock}${fileBlocks}${fallbackText}`; - // Как и обычное исходящее сообщение, отправка файла сразу закрывает текущий - // визуальный раздел «Новые сообщения», не дожидаясь ответа сервера. hideUnreadSeparator({ rerender: false }); - const tempId = addOutgoingPendingMessage(chatId, finalText); renderChatLog({ scrollMode: 'latest', markAsRead: false }); scrollToLatestMessageSmart(log, { smoothIfNearBottom: true }); @@ -1567,25 +2164,26 @@ export function render({ navigate, route, chrome }) { addAppLogEntry({ level: 'info', - source: 'outgoing-dm-file', - message: `Файл отправлен для ${chatId}`, + source: onlyVoice ? 'outgoing-dm-voice' : 'outgoing-dm-file', + message: onlyVoice ? `Голосовое отправлено для ${chatId}` : `Вложения отправлены для ${chatId}`, details: { toLogin: chatId, - fileId: attachment?.id || '', - fileSize: Number(attachment?.size || 0), + attachmentCount: attachments.length, + fileIds: attachments.map((attachment) => attachment?.id || ''), + totalFileSize: attachments.reduce((sum, attachment) => sum + Number(attachment?.size || 0), 0), messageId: result?.outgoingKey || '', replyToBaseKey: replying?.baseKey || '', }, }); } catch (error) { - addChatMessage(chatId, `Ошибка отправки файла: ${error?.message || 'unknown'}`); + addChatMessage(chatId, `Ошибка отправки вложения: ${error?.message || 'unknown'}`); addAppLogEntry({ level: 'warn', - source: 'outgoing-dm-file', - message: 'Файл загружен, но DM с ключом не отправлен', + source: onlyVoice ? 'outgoing-dm-voice' : 'outgoing-dm-file', + message: 'Зашифрованные данные загружены, но DM с ключами не отправлен', details: { toLogin: chatId, - fileId: attachment?.id || '', + fileIds: attachments.map((attachment) => attachment?.id || ''), error: error?.message || 'unknown', }, }); @@ -1594,7 +2192,7 @@ export function render({ navigate, route, chrome }) { } }; - const handleSelectedFile = async (file) => { + const handleSelectedFiles = async (fileList) => { if (!fileTransferEnabled) { showToast('Передача файлов отключена в дополнительных настройках.', { kind: 'error', timeoutMs: 1800 }); return; @@ -1603,38 +2201,101 @@ export function render({ navigate, route, chrome }) { showToast('Сначала завершите редактирование сообщения.', { kind: 'error', timeoutMs: 1600 }); return; } - if (!file) return; + const allFiles = Array.from(fileList || []).filter(Boolean); + if (!allFiles.length) return; + if (allFiles.length > DM_MAX_ATTACHMENTS_PER_MESSAGE) { + showToast(`Выберите не больше ${DM_MAX_ATTACHMENTS_PER_MESSAGE} файлов за одно сообщение.`, { kind: 'error', timeoutMs: 2200 }); + return; + } const originalLabel = fileToggle?.textContent || '📎'; if (fileToggle) { fileToggle.disabled = true; fileToggle.textContent = '…'; - fileToggle.setAttribute('aria-label', 'Шифрование и загрузка файла'); + fileToggle.setAttribute('aria-label', 'Шифрование и загрузка файлов'); } - showToast(`Шифрую и загружаю «${String(file.name || 'file')}»…`, { timeoutMs: 1600 }); + const attachments = []; try { - const attachment = await encryptAndUploadDmFile({ - file, - login: state.session.login, - sessionId: state.session.sessionId, - wsUrl: authService.serverUrl, - }); - await sendFileAttachmentMessage(attachment); + for (let index = 0; index < allFiles.length; index += 1) { + const file = allFiles[index]; + showToast(`Файл ${index + 1}/${allFiles.length}: «${String(file.name || 'file')}»`, { timeoutMs: 1200 }); + const attachment = await encryptAndUploadDmFile({ + file, + login: state.session.login, + sessionId: state.session.sessionId, + wsUrl: authService.serverUrl, + onProgress: ({ processedBytes, totalBytes }) => { + if (!fileToggle || !totalBytes) return; + const percent = Math.min(100, Math.max(0, Math.round((processedBytes / totalBytes) * 100))); + fileToggle.title = `${index + 1}/${allFiles.length} · ${percent}%`; + }, + }); + attachments.push(attachment); + } + await sendFileAttachmentMessage(attachments); setFileActionVisible(false); - showToast('Файл отправлен.', { timeoutMs: 1200 }); + showToast(attachments.length === 1 ? 'Файл отправлен.' : `Отправлено файлов: ${attachments.length}.`, { timeoutMs: 1300 }); } catch (error) { - showToast(error?.message || 'Не удалось отправить файл', { kind: 'error', timeoutMs: 2600 }); + showToast(error?.message || 'Не удалось отправить файлы', { kind: 'error', timeoutMs: 2800 }); } finally { if (fileToggle) { fileToggle.disabled = false; fileToggle.textContent = originalLabel; - fileToggle.setAttribute('aria-label', 'Прикрепить файл'); + fileToggle.title = 'Прикрепить файлы'; + fileToggle.setAttribute('aria-label', 'Прикрепить файлы'); } if (fileInput) fileInput.value = ''; } }; + const forwardMessageToChat = async (msg, targetLogin) => { + const targetChatId = normalizeDmChatId(targetLogin); + if (!targetChatId) throw new Error('Не выбран чат для пересылки'); + const finalText = buildForwardedDmText(msg); + if (!String(finalText || '').trim()) throw new Error('В этом сообщении нечего пересылать'); + + const tempId = addOutgoingPendingMessage(targetChatId, finalText); + if (targetChatId === chatId) { + hideUnreadSeparator({ rerender: false }); + renderChatLog({ scrollMode: 'latest', markAsRead: false }); + scrollToLatestMessageSmart(log, { smoothIfNearBottom: true }); + } + + try { + const result = await authService.sendDirectMessage({ + login: state.session.login, + toLogin: targetChatId, + text: finalText, + storagePwd: state.session.storagePwdInMemory, + }); + markOutgoingSent(tempId, { + messageKey: result?.outgoingKey || '', + baseKey: result?.baseKey || result?.localBaseKey || '', + deliveryState: result?.deliveryState || 'accepted', + }); + if (targetChatId === chatId) { + renderChatLog({ scrollMode: 'latest', markAsRead: false }); + scrollToLatestMessageSmart(log, { smoothIfNearBottom: true }); + } + addAppLogEntry({ + level: 'info', + source: 'outgoing-dm-forward', + message: `Сообщение переслано для ${targetChatId}`, + details: { + toLogin: targetChatId, + sourceChatId: chatId, + messageId: result?.outgoingKey || '', + }, + }); + } catch (error) { + // Оставляем pending-сообщение в локальной истории так же, как обычная отправка, + // а пользователю возвращаем ошибку для повторной попытки. + if (targetChatId === chatId) renderChatLog({ scrollMode: 'latest', markAsRead: false }); + throw error; + } + }; + const handleOpenActions = (msg, event) => { const parsed = parseDmTechBlocks(String(msg?.text || '')); const readState = resolveEffectiveReadState(getChatMessages(chatId), msg); @@ -1646,8 +2307,9 @@ export function render({ navigate, route, chrome }) { ? `Прочитано: ${formatMessageTime(readState.readAtMs)}` : '', canReply: true, + canForward: true, showReadAloud: isTextToSpeechReady, - canEdit: msg?.from === 'out' && Number(msg?.messageType || 0) === 2 && !parsed?.callSummary && !parsed?.fileAttachment, + canEdit: msg?.from === 'out' && Number(msg?.messageType || 0) === 2 && !parsed?.callSummary && !(parsed?.fileAttachments?.length || parsed?.fileAttachment), canDelete: ( (msg?.from === 'out' && Number(msg?.messageType || 0) === 2) || (msg?.from === 'in' && Number(msg?.messageType || 0) === 1) @@ -1655,6 +2317,13 @@ export function render({ navigate, route, chrome }) { onReply: async () => { startReplyMode(msg); }, + onForward: async () => { + await openForwardMessageDialog({ + message: msg, + sourceChatId: chatId, + onConfirm: async (targetLogin) => forwardMessageToChat(msg, targetLogin), + }); + }, onReadAloud: async () => handleReadAloud(msg), onEdit: async () => { startEditMode(msg); @@ -1738,10 +2407,12 @@ export function render({ navigate, route, chrome }) { autoResizeComposer(input); syncComposerLayout(); + updateSendButtonMode(); input?.addEventListener('input', () => { rememberEmojiSelection(); autoResizeComposer(input); syncComposerLayout(); + updateSendButtonMode(); }); input?.addEventListener('select', rememberEmojiSelection); input?.addEventListener('keyup', rememberEmojiSelection); @@ -1803,20 +2474,84 @@ export function render({ navigate, route, chrome }) { fileInput?.click(); }); fileInput?.addEventListener('change', () => { - const file = fileInput.files?.[0] || null; - if (file) void handleSelectedFile(file); + const files = Array.from(fileInput.files || []); + if (files.length) void handleSelectedFiles(files); }); screen.addEventListener('pointerdown', (event) => { if (form.contains(event.target)) return; if (emojiPickerOpen) closeEmojiPicker(); if (fileActionVisible) setFileActionVisible(false); }); - form.querySelector('.dm-send-btn')?.addEventListener('pointerdown', (event) => { + + sendBtn?.addEventListener('pointerdown', (event) => { + const mode = String(sendBtn.dataset.mode || 'voice'); + if (mode === 'busy') { + event.preventDefault(); + return; + } + if (mode === 'send') { + try { + input?.focus({ preventScroll: true }); + } catch { + input?.focus(); + } + return; + } + if (mode === 'stop') { + event.preventDefault(); + suppressVoiceClick = true; + stopVoiceRecording({ send: true }); + return; + } event.preventDefault(); - try { - input?.focus({ preventScroll: true }); - } catch { - input?.focus(); + voicePointerHeld = true; + voiceReleaseRequested = false; + voiceStartY = Number(event.clientY || 0); + voicePointerId = event.pointerId; + try { sendBtn.setPointerCapture?.(event.pointerId); } catch { /* optional */ } + void startVoiceRecording({ pointerY: voiceStartY, pointerId: event.pointerId }); + }); + + sendBtn?.addEventListener('pointermove', (event) => { + if (!voicePointerHeld || voiceLocked || voicePointerId !== event.pointerId) return; + if (!voiceRecorder || voiceRecorder.state === 'inactive') return; + if (Number(event.clientY || 0) <= voiceStartY - 64) { + voiceLocked = true; + voiceReleaseRequested = false; + if (voiceHint) voiceHint.textContent = 'Запись закреплена · нажмите ■, чтобы отправить'; + updateSendButtonMode(); + try { navigator.vibrate?.(18); } catch { /* ignore */ } + } + }); + + const finishVoicePointerGesture = (event) => { + if (!voicePointerHeld && voicePointerId == null) return; + if (voicePointerId != null && event.pointerId !== voicePointerId) return; + voicePointerHeld = false; + voiceReleaseRequested = true; + try { sendBtn?.releasePointerCapture?.(event.pointerId); } catch { /* optional */ } + voicePointerId = null; + if (!voiceLocked) { + suppressVoiceClick = true; + stopVoiceRecording({ send: true }); + } + }; + sendBtn?.addEventListener('pointerup', finishVoicePointerGesture); + sendBtn?.addEventListener('pointercancel', finishVoicePointerGesture); + sendBtn?.addEventListener('contextmenu', (event) => { + if (String(sendBtn.dataset.mode || '') !== 'send') event.preventDefault(); + }); + sendBtn?.addEventListener('click', (event) => { + event.preventDefault(); + if (suppressVoiceClick) { + suppressVoiceClick = false; + return; + } + const mode = String(sendBtn.dataset.mode || 'voice'); + if (mode === 'send') { + form.requestSubmit(); + } else if (mode === 'stop') { + stopVoiceRecording({ send: true }); } }); @@ -1827,6 +2562,7 @@ export function render({ navigate, route, chrome }) { input.value = ''; autoResizeComposer(input); syncComposerLayout(); + updateSendButtonMode(); closeEmojiPicker(); await sendTextMessage(text); focusInputToEnd(); @@ -1879,6 +2615,9 @@ export function render({ navigate, route, chrome }) { hideUnreadSeparator({ rerender: false }); stopAllTwemojiAnimations(); clearEmojiLongPress(); + try { stopVoiceRecording({ send: false }); } catch { /* ignore */ } + clearVoiceTimer(); + stopVoiceStreamTracks(); window.removeEventListener('shine-chat-messages-updated', handleIncomingChatRefresh); window.removeEventListener('shine-dm-delivery-updated', handleDeliveryRefresh); boundScrollContainer?.removeEventListener('scroll', handleHistoryScroll); diff --git a/shine-UI/js/services/crypto-utils.js b/shine-UI/js/services/crypto-utils.js index b5bb051e..00355976 100644 --- a/shine-UI/js/services/crypto-utils.js +++ b/shine-UI/js/services/crypto-utils.js @@ -289,15 +289,19 @@ export async function importAesKeyRaw(keyBytes, usages = ['encrypt', 'decrypt']) return getSubtleApi().importKey('raw', keyBytes, { name: 'AES-GCM' }, false, usages); } -export async function encryptBytesAesGcm(plainBytes, keyBytes, ivBytes) { +export async function encryptBytesAesGcm(plainBytes, keyBytes, ivBytes, additionalData = null) { const key = await importAesKeyRaw(keyBytes, ['encrypt']); - const cipher = await getSubtleApi().encrypt({ name: 'AES-GCM', iv: ivBytes }, key, plainBytes); + const algorithm = { name: 'AES-GCM', iv: ivBytes }; + if (additionalData) algorithm.additionalData = additionalData; + const cipher = await getSubtleApi().encrypt(algorithm, key, plainBytes); return new Uint8Array(cipher); } -export async function decryptBytesAesGcm(cipherBytes, keyBytes, ivBytes) { +export async function decryptBytesAesGcm(cipherBytes, keyBytes, ivBytes, additionalData = null) { const key = await importAesKeyRaw(keyBytes, ['decrypt']); - const plain = await getSubtleApi().decrypt({ name: 'AES-GCM', iv: ivBytes }, key, cipherBytes); + const algorithm = { name: 'AES-GCM', iv: ivBytes }; + if (additionalData) algorithm.additionalData = additionalData; + const plain = await getSubtleApi().decrypt(algorithm, key, cipherBytes); return new Uint8Array(plain); } diff --git a/shine-UI/js/services/dm-file-service.js b/shine-UI/js/services/dm-file-service.js index 39202df2..11448176 100644 --- a/shine-UI/js/services/dm-file-service.js +++ b/shine-UI/js/services/dm-file-service.js @@ -8,10 +8,28 @@ import { randomBytes, sha256Bytes, signBase64, + utf8Bytes, } from './crypto-utils.js'; import { loadSessionMaterial } from './key-vault.js'; +import { + TORRENT_V2_BLOCK_BYTES, + TORRENT_V2_PIECE_BYTES, + TorrentV2PieceAccumulator, + buildTorrentV2MetainfoBytes, + computeTorrentV2InfoHash, + computeTorrentV2PieceRoot, +} from './torrent-v2-service.js'; -export const DM_FILE_MAX_SOURCE_BYTES = 50 * 1024 * 1024; +export const DM_FILE_CHUNK_BYTES = TORRENT_V2_PIECE_BYTES; +export const DM_FILE_MANIFEST_PAGE_CHUNKS = 256; +export const DM_MAX_ATTACHMENTS_PER_MESSAGE = 10; + +const MANIFEST_VERSION = 2; +const MANIFEST_SCHEME = 'SHINE-DM-CHUNKED-AES-256-GCM'; +const ROOT_IV_TOKEN = 0xffffffffffffffffn; +const PAGE_IV_BASE = 0x8000000000000000n; +const encoder = new TextEncoder(); +const decoder = new TextDecoder(); function normalizeFileName(value = '') { const cleaned = String(value || 'file') @@ -52,22 +70,63 @@ async function readErrorMessage(response) { } } -export function formatDmFileSize(bytes = 0) { - const value = Math.max(0, Number(bytes || 0)); - if (value < 1024) return `${value} Б`; - if (value < 1024 * 1024) return `${(value / 1024).toFixed(1)} КБ`; - return `${(value / (1024 * 1024)).toFixed(value >= 10 * 1024 * 1024 ? 0 : 1)} МБ`; +function objectUrl(serverBase, id) { + return `${String(serverBase || '').replace(/\/$/, '')}/dm-files/${encodeURIComponent(String(id || ''))}`; } -export async function encryptAndUploadDmFile({ file, login, sessionId, wsUrl } = {}) { - if (!file || typeof file.arrayBuffer !== 'function') throw new Error('Файл не выбран'); +function objectBaseFromUrl(url = '') { + const parsed = new URL(String(url || ''), window.location.href); + return parsed.origin; +} + +function makeIv(prefix4, token) { + if (!(prefix4 instanceof Uint8Array) || prefix4.byteLength !== 4) { + throw new Error('Некорректный IV prefix файла'); + } + const iv = new Uint8Array(12); + iv.set(prefix4, 0); + new DataView(iv.buffer).setBigUint64(4, BigInt(token), false); + return iv; +} + +function chunkIv(prefix4, index) { + return makeIv(prefix4, BigInt(index)); +} + +function pageIv(prefix4, index) { + return makeIv(prefix4, PAGE_IV_BASE + BigInt(index)); +} + +function rootIv(prefix4) { + return makeIv(prefix4, ROOT_IV_TOKEN); +} + +function makeAad(prefix4, domain, index = 0) { + return utf8Bytes(`SHINE-DM-FILE-V2:${bytesToBase64Url(prefix4)}:${domain}:${index}`); +} + +function chunkAad(prefix4, index) { + return makeAad(prefix4, 'chunk', index); +} + +function pageAad(prefix4, index) { + return makeAad(prefix4, 'page', index); +} + +function rootAad(prefix4) { + return makeAad(prefix4, 'root', 0); +} + +function assertSafeIndex(index, label = 'index') { + const n = Number(index); + if (!Number.isSafeInteger(n) || n < 0) throw new Error(`Некорректный ${label}`); + return n; +} + +async function createUploadContext({ login, sessionId, wsUrl }) { const cleanLogin = String(login || '').trim(); const cleanSessionId = String(sessionId || '').trim(); if (!cleanLogin || !cleanSessionId) throw new Error('Нет активной пользовательской сессии'); - if (Number(file.size || 0) > DM_FILE_MAX_SOURCE_BYTES) { - throw new Error(`Файл слишком большой. Текущий лимит — ${formatDmFileSize(DM_FILE_MAX_SOURCE_BYTES)}.`); - } - const sessionMaterial = await loadSessionMaterial(cleanLogin); if (!sessionMaterial?.sessionPrivPkcs8) { throw new Error('На устройстве нет сохранённого session key для загрузки файла'); @@ -75,98 +134,455 @@ export async function encryptAndUploadDmFile({ file, login, sessionId, wsUrl } = if (sessionMaterial.sessionId && String(sessionMaterial.sessionId) !== cleanSessionId) { throw new Error('Сохранённый session key относится к другой сессии'); } + return { + sessionId: cleanSessionId, + serverBase: wsUrlToHttpBase(wsUrl), + privateKey: await importPkcs8Ed25519(sessionMaterial.sessionPrivPkcs8), + }; +} - const sourceBytes = new Uint8Array(await file.arrayBuffer()); - const fileKey = randomBytes(32); - const iv = randomBytes(12); - let encryptedBytes; +async function uploadEncryptedObject(bytes, context) { + const payload = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes || 0); + const fileId = bytesToBase58(await sha256Bytes(payload)); + const url = objectUrl(context.serverBase, fileId); + + // Быстрая дедупликация: если сервер уже подтверждает content-addressed объект, + // не отправляем его байты повторно. HEAD на сервере сам перепроверяет SHA-256 файла. try { - encryptedBytes = await encryptBytesAesGcm(sourceBytes, fileKey, iv); - } finally { - sourceBytes.fill(0); + const existing = await fetch(url, { method: 'HEAD', cache: 'no-store' }); + if (existing.ok) { + const storedSize = Number(existing.headers.get('Content-Length') || -1); + const etag = String(existing.headers.get('ETag') || '').replace(/^"|"$/g, ''); + if (storedSize === payload.byteLength && (!etag || etag === fileId)) { + return { id: fileId, url, encryptedSize: payload.byteLength, alreadyExists: true }; + } + } + } catch { + // Старый сервер или временная ошибка HEAD не должны ломать загрузку: PUT остаётся источником истины. } + const timeMs = Date.now(); + const signatureB64 = await signBase64(context.privateKey, uploadPreimage({ + sessionId: context.sessionId, + fileId, + encryptedSize: payload.byteLength, + timeMs, + })); + let response; try { - const fileHash = await sha256Bytes(encryptedBytes); - const fileId = bytesToBase58(fileHash); - const serverBase = wsUrlToHttpBase(wsUrl); - const fileUrl = `${serverBase}/dm-files/${fileId}`; - const timeMs = Date.now(); - const privateKey = await importPkcs8Ed25519(sessionMaterial.sessionPrivPkcs8); - const signatureB64 = await signBase64(privateKey, uploadPreimage({ - sessionId: cleanSessionId, - fileId, - encryptedSize: encryptedBytes.byteLength, - timeMs, - })); + response = await fetch(url, { + method: 'PUT', + headers: { + 'Content-Type': 'application/octet-stream', + 'X-Shine-Session-Id': context.sessionId, + 'X-Shine-Time-Ms': String(timeMs), + 'X-Shine-Content-Length': String(payload.byteLength), + 'X-Shine-Signature': signatureB64, + }, + body: payload, + cache: 'no-store', + }); + } catch (error) { + throw new Error(`Не удалось загрузить часть файла на сервер отправителя: ${error?.message || 'network error'}`); + } + if (!response.ok) { + const detail = await readErrorMessage(response); + throw new Error(detail || `Сервер отклонил часть файла (HTTP ${response.status})`); + } + let alreadyExists = false; + try { + const payloadJson = await response.clone().json(); + alreadyExists = Boolean(payloadJson?.alreadyExists); + } catch { + // Ответ старого сервера может не содержать JSON-флаг; успешного HTTP достаточно. + } + return { id: fileId, url, encryptedSize: payload.byteLength, alreadyExists }; +} - let response; - try { - response = await fetch(fileUrl, { - method: 'PUT', - headers: { - 'Content-Type': 'application/octet-stream', - 'X-Shine-Session-Id': cleanSessionId, - 'X-Shine-Time-Ms': String(timeMs), - 'X-Shine-Content-Length': String(encryptedBytes.byteLength), - 'X-Shine-Signature': signatureB64, - }, - body: encryptedBytes, - cache: 'no-store', - }); - } catch (error) { - throw new Error(`Не удалось загрузить файл на сервер отправителя: ${error?.message || 'network error'}`); - } +async function fetchVerifiedObject(url, expectedId) { + let response; + try { + response = await fetch(url, { method: 'GET', cache: 'no-store' }); + } catch (error) { + throw new Error(`Не удалось скачать зашифрованные данные: ${error?.message || 'network error'}`); + } + if (!response.ok) { + throw new Error(response.status === 404 ? 'Часть файла больше не найдена на сервере отправителя' : `Ошибка скачивания файла (HTTP ${response.status})`); + } + const bytes = new Uint8Array(await response.arrayBuffer()); + const actualId = bytesToBase58(await sha256Bytes(bytes)); + if (actualId !== String(expectedId || '')) { + bytes.fill(0); + throw new Error('SHA-256 зашифрованной части не совпал: данные повреждены или подменены'); + } + return bytes; +} - if (!response.ok) { - const detail = await readErrorMessage(response); - throw new Error(detail || `Сервер отклонил файл (HTTP ${response.status})`); - } +function encodeJson(value) { + return encoder.encode(JSON.stringify(value)); +} +function decodeJson(bytes) { + return JSON.parse(decoder.decode(bytes)); +} + +async function encryptAndUploadManifestPage({ descriptors, pageIndex, fileKey, ivPrefix, uploadContext }) { + const plain = encodeJson({ v: MANIFEST_VERSION, page: pageIndex, chunks: descriptors }); + const iv = pageIv(ivPrefix, pageIndex); + const aad = pageAad(ivPrefix, pageIndex); + let encrypted; + try { + encrypted = await encryptBytesAesGcm(plain, fileKey, iv, aad); + const stored = await uploadEncryptedObject(encrypted, uploadContext); return { - version: 1, - id: fileId, - url: fileUrl, - keyB64Url: bytesToBase64Url(fileKey), - ivB64Url: bytesToBase64Url(iv), - name: normalizeFileName(file.name), - mime: String(file.type || 'application/octet-stream').trim().slice(0, 160) || 'application/octet-stream', - size: Number(file.size || 0), - encryptedSize: encryptedBytes.byteLength, + id: stored.id, + count: descriptors.length, + encryptedSize: stored.encryptedSize, }; } finally { - fileKey.fill(0); + plain.fill(0); iv.fill(0); - encryptedBytes?.fill(0); + aad.fill(0); + encrypted?.fill(0); } } -export async function downloadAndDecryptDmFile(attachment = {}) { +export function formatDmFileSize(bytes = 0) { + const value = Math.max(0, Number(bytes || 0)); + if (value < 1024) return `${value} Б`; + if (value < 1024 * 1024) return `${(value / 1024).toFixed(1)} КБ`; + if (value < 1024 * 1024 * 1024) return `${(value / (1024 * 1024)).toFixed(value >= 10 * 1024 * 1024 ? 0 : 1)} МБ`; + return `${(value / (1024 * 1024 * 1024)).toFixed(value >= 10 * 1024 * 1024 * 1024 ? 1 : 2)} ГБ`; +} + +/** + * V2 uploader: no whole-file size limit. Only one 1 MiB plaintext piece is held in + * memory at a time. Every piece is independently AES-256-GCM encrypted and stored + * as Base58(SHA-256(ciphertext)). + */ +export async function encryptAndUploadDmFile({ + file, + login, + sessionId, + wsUrl, + kind = 'file', + durationMs = 0, + onProgress = null, +} = {}) { + if (!file || typeof file.slice !== 'function') throw new Error('Файл не выбран'); + const uploadContext = await createUploadContext({ login, sessionId, wsUrl }); + const fileKey = randomBytes(32); + const ivPrefix = randomBytes(4); + const fileSize = Math.max(0, Number(file.size || 0)); + const chunkCount = Math.ceil(fileSize / DM_FILE_CHUNK_BYTES); + const pageRefs = []; + let pageDescriptors = []; + let pageIndex = 0; + const pieceAccumulator = new TorrentV2PieceAccumulator(); + let singlePieceRoot = null; + let completedBytes = 0; + + try { + for (let index = 0; index < chunkCount; index += 1) { + const start = index * DM_FILE_CHUNK_BYTES; + const end = Math.min(fileSize, start + DM_FILE_CHUNK_BYTES); + const plain = new Uint8Array(await file.slice(start, end).arrayBuffer()); + const iv = chunkIv(ivPrefix, index); + const aad = chunkAad(ivPrefix, index); + let encrypted = null; + try { + const pieceRoot = await computeTorrentV2PieceRoot(plain, { + padToPieceLength: fileSize > TORRENT_V2_PIECE_BYTES, + }); + if (!pieceRoot) throw new Error('Не удалось вычислить torrent-v2 hash части'); + if (chunkCount <= 1) singlePieceRoot = pieceRoot.slice(); + else await pieceAccumulator.addPieceRoot(pieceRoot); + + encrypted = await encryptBytesAesGcm(plain, fileKey, iv, aad); + const stored = await uploadEncryptedObject(encrypted, uploadContext); + pageDescriptors.push({ + i: index, + id: stored.id, + ps: plain.byteLength, + es: encrypted.byteLength, + ph: bytesToBase64Url(pieceRoot), + }); + completedBytes += plain.byteLength; + onProgress?.({ + phase: 'chunks', + chunkIndex: index, + chunkCount, + processedBytes: completedBytes, + totalBytes: fileSize, + }); + } finally { + plain.fill(0); + iv.fill(0); + aad.fill(0); + encrypted?.fill(0); + } + + if (pageDescriptors.length >= DM_FILE_MANIFEST_PAGE_CHUNKS || index === chunkCount - 1) { + const pageRef = await encryptAndUploadManifestPage({ + descriptors: pageDescriptors, + pageIndex, + fileKey, + ivPrefix, + uploadContext, + }); + pageRefs.push(pageRef); + pageDescriptors = []; + pageIndex += 1; + } + } + + const piecesRoot = fileSize <= 0 + ? null + : (chunkCount <= 1 ? singlePieceRoot : await pieceAccumulator.finalize()); + const torrentInfo = await computeTorrentV2InfoHash({ + name: normalizeFileName(file.name), + size: fileSize, + piecesRoot, + }); + + const rootManifest = { + v: MANIFEST_VERSION, + scheme: MANIFEST_SCHEME, + chunkSize: DM_FILE_CHUNK_BYTES, + fileSize, + chunkCount, + pages: pageRefs, + torrent: { + metaVersion: 2, + blockLength: TORRENT_V2_BLOCK_BYTES, + pieceLength: TORRENT_V2_PIECE_BYTES, + piecesRoot: piecesRoot ? bytesToBase64Url(piecesRoot) : '', + infoHash: torrentInfo.infoHashB64Url, + }, + }; + const rootPlain = encodeJson(rootManifest); + const iv = rootIv(ivPrefix); + const aad = rootAad(ivPrefix); + let rootEncrypted = null; + try { + rootEncrypted = await encryptBytesAesGcm(rootPlain, fileKey, iv, aad); + const storedRoot = await uploadEncryptedObject(rootEncrypted, uploadContext); + onProgress?.({ phase: 'manifest', processedBytes: fileSize, totalBytes: fileSize, chunkCount }); + return { + version: 2, + id: storedRoot.id, + url: storedRoot.url, + keyB64Url: bytesToBase64Url(fileKey), + ivPrefixB64Url: bytesToBase64Url(ivPrefix), + name: normalizeFileName(file.name), + mime: String(file.type || 'application/octet-stream').trim().slice(0, 160) || 'application/octet-stream', + size: fileSize, + encryptedSize: pageRefs.reduce((sum, item) => sum + Number(item.encryptedSize || 0), 0) + storedRoot.encryptedSize, + chunkSize: DM_FILE_CHUNK_BYTES, + chunkCount, + torrentV2InfoHashB64Url: torrentInfo.infoHashB64Url, + torrentV2PiecesRootB64Url: piecesRoot ? bytesToBase64Url(piecesRoot) : '', + kind: String(kind || 'file') === 'voice' ? 'voice' : 'file', + durationMs: Math.max(0, Math.floor(Number(durationMs || 0))), + }; + } finally { + rootPlain.fill(0); + iv.fill(0); + aad.fill(0); + rootEncrypted?.fill(0); + } + } finally { + fileKey.fill(0); + ivPrefix.fill(0); + singlePieceRoot?.fill(0); + } +} + +async function loadV2RootManifest(attachment) { + const id = String(attachment?.id || '').trim(); + const url = String(attachment?.url || '').trim(); + const key = base64UrlToBytes(String(attachment?.keyB64Url || '')); + const ivPrefix = base64UrlToBytes(String(attachment?.ivPrefixB64Url || '')); + if (!id || !url || key.byteLength !== 32 || ivPrefix.byteLength !== 4) { + key.fill(0); + ivPrefix.fill(0); + throw new Error('В сообщении не хватает данных для расшифровки chunked-файла'); + } + const encrypted = await fetchVerifiedObject(url, id); + const iv = rootIv(ivPrefix); + const aad = rootAad(ivPrefix); + let plain; + try { + plain = await decryptBytesAesGcm(encrypted, key, iv, aad); + const manifest = decodeJson(plain); + if (Number(manifest?.v) !== MANIFEST_VERSION || manifest?.scheme !== MANIFEST_SCHEME) { + throw new Error('Неизвестная версия манифеста файла'); + } + if (Number(manifest.chunkSize) !== DM_FILE_CHUNK_BYTES) throw new Error('Неожиданный размер chunk в манифесте'); + if (Number(manifest.fileSize) !== Number(attachment?.size || 0)) throw new Error('Размер файла не совпал с манифестом'); + if (Number(manifest.chunkCount) !== Math.ceil(Number(manifest.fileSize || 0) / DM_FILE_CHUNK_BYTES)) { + throw new Error('Некорректное число частей в манифесте'); + } + + const piecesRoot = manifest?.torrent?.piecesRoot ? base64UrlToBytes(manifest.torrent.piecesRoot) : null; + const recomputedInfo = await computeTorrentV2InfoHash({ + name: normalizeFileName(attachment?.name || 'file'), + size: Number(manifest.fileSize || 0), + piecesRoot, + }); + if (recomputedInfo.infoHashB64Url !== String(manifest?.torrent?.infoHash || '')) { + piecesRoot?.fill(0); + throw new Error('BitTorrent v2 infohash манифеста не совпал'); + } + piecesRoot?.fill(0); + return { manifest, key, ivPrefix, serverBase: objectBaseFromUrl(url) }; + } catch (error) { + key.fill(0); + ivPrefix.fill(0); + throw error; + } finally { + encrypted.fill(0); + iv.fill(0); + aad.fill(0); + plain?.fill(0); + } +} + +async function loadV2ManifestPage({ pageRef, pageIndex, key, ivPrefix, serverBase }) { + const id = String(pageRef?.id || '').trim(); + if (!id) throw new Error('В манифесте отсутствует идентификатор страницы'); + const encrypted = await fetchVerifiedObject(objectUrl(serverBase, id), id); + const iv = pageIv(ivPrefix, pageIndex); + const aad = pageAad(ivPrefix, pageIndex); + let plain; + try { + plain = await decryptBytesAesGcm(encrypted, key, iv, aad); + const page = decodeJson(plain); + if (Number(page?.v) !== MANIFEST_VERSION || Number(page?.page) !== pageIndex || !Array.isArray(page?.chunks)) { + throw new Error('Повреждена страница манифеста'); + } + if (Number(pageRef?.count || 0) !== page.chunks.length) throw new Error('Размер страницы манифеста не совпал'); + return page.chunks; + } finally { + encrypted.fill(0); + iv.fill(0); + aad.fill(0); + plain?.fill(0); + } +} + +async function *iterateV2Chunks(context) { + let expectedIndex = 0; + for (let pageIndex = 0; pageIndex < context.manifest.pages.length; pageIndex += 1) { + const descriptors = await loadV2ManifestPage({ + pageRef: context.manifest.pages[pageIndex], + pageIndex, + key: context.key, + ivPrefix: context.ivPrefix, + serverBase: context.serverBase, + }); + for (const descriptor of descriptors) { + if (assertSafeIndex(descriptor?.i, 'индекс chunk') !== expectedIndex) { + throw new Error('Нарушен порядок частей файла'); + } + expectedIndex += 1; + yield descriptor; + } + } + if (expectedIndex !== Number(context.manifest.chunkCount || 0)) { + throw new Error('Манифест содержит неполный список частей'); + } +} + +async function decryptV2Chunk(context, descriptor) { + const index = assertSafeIndex(descriptor?.i, 'индекс chunk'); + const id = String(descriptor?.id || '').trim(); + if (!id) throw new Error('У части файла отсутствует hash'); + const encrypted = await fetchVerifiedObject(objectUrl(context.serverBase, id), id); + const iv = chunkIv(context.ivPrefix, index); + const aad = chunkAad(context.ivPrefix, index); + let plain; + try { + plain = await decryptBytesAesGcm(encrypted, context.key, iv, aad); + } catch { + throw new Error(`Не удалось расшифровать часть ${index + 1}`); + } finally { + encrypted.fill(0); + iv.fill(0); + aad.fill(0); + } + if (plain.byteLength !== Number(descriptor?.ps || 0)) { + plain.fill(0); + throw new Error(`Размер расшифрованной части ${index + 1} не совпал`); + } + const expectedPieceHash = String(descriptor?.ph || '').trim(); + const pieceRoot = await computeTorrentV2PieceRoot(plain, { + padToPieceLength: Number(context.manifest.fileSize || 0) > TORRENT_V2_PIECE_BYTES, + }); + if (!pieceRoot || bytesToBase64Url(pieceRoot) !== expectedPieceHash) { + plain.fill(0); + pieceRoot?.fill(0); + throw new Error(`BitTorrent v2 SHA-256 части ${index + 1} не совпал`); + } + return { plain, pieceRoot }; +} + +async function verifyCompletedTorrentRoot(context, accumulator, singlePieceRoot) { + const expectedRoot = String(context.manifest?.torrent?.piecesRoot || ''); + if (!expectedRoot && Number(context.manifest.fileSize || 0) === 0) return; + const actualRoot = Number(context.manifest.chunkCount || 0) <= 1 + ? singlePieceRoot + : await accumulator.finalize(); + if (!actualRoot || bytesToBase64Url(actualRoot) !== expectedRoot) { + actualRoot?.fill(0); + throw new Error('Итоговый BitTorrent v2 pieces root не совпал'); + } + actualRoot.fill(0); +} + +async function decryptV2ToSink(attachment, sink, { onProgress = null } = {}) { + const context = await loadV2RootManifest(attachment); + const accumulator = new TorrentV2PieceAccumulator(); + let singlePieceRoot = null; + let processedBytes = 0; + try { + for await (const descriptor of iterateV2Chunks(context)) { + const { plain, pieceRoot } = await decryptV2Chunk(context, descriptor); + try { + if (Number(context.manifest.chunkCount || 0) <= 1) singlePieceRoot = pieceRoot.slice(); + else await accumulator.addPieceRoot(pieceRoot); + await sink(plain, descriptor); + processedBytes += plain.byteLength; + onProgress?.({ + processedBytes, + totalBytes: Number(context.manifest.fileSize || 0), + chunkIndex: Number(descriptor.i), + chunkCount: Number(context.manifest.chunkCount || 0), + }); + } finally { + plain.fill(0); + pieceRoot.fill(0); + } + } + if (processedBytes !== Number(context.manifest.fileSize || 0)) { + throw new Error('Итоговый размер расшифрованного файла не совпал'); + } + await verifyCompletedTorrentRoot(context, accumulator, singlePieceRoot); + return context.manifest; + } finally { + context.key.fill(0); + context.ivPrefix.fill(0); + singlePieceRoot?.fill(0); + } +} + +async function downloadV1(attachment = {}) { const fileId = String(attachment?.id || '').trim(); const fileUrl = String(attachment?.url || '').trim(); const keyB64Url = String(attachment?.keyB64Url || '').trim(); const ivB64Url = String(attachment?.ivB64Url || '').trim(); - if (!fileId || !fileUrl || !keyB64Url || !ivB64Url) { - throw new Error('В сообщении не хватает данных для расшифровки файла'); - } - - let response; - try { - response = await fetch(fileUrl, { method: 'GET', cache: 'no-store' }); - } catch (error) { - throw new Error(`Не удалось скачать зашифрованный файл: ${error?.message || 'network error'}`); - } - if (!response.ok) { - throw new Error(response.status === 404 ? 'Файл больше не найден на сервере отправителя' : `Ошибка скачивания файла (HTTP ${response.status})`); - } - - const encryptedBytes = new Uint8Array(await response.arrayBuffer()); - const actualId = bytesToBase58(await sha256Bytes(encryptedBytes)); - if (actualId !== fileId) { - encryptedBytes.fill(0); - throw new Error('SHA-256 файла не совпал: зашифрованный файл повреждён или подменён'); - } - + if (!fileId || !fileUrl || !keyB64Url || !ivB64Url) throw new Error('В сообщении не хватает данных для расшифровки файла'); + const encryptedBytes = await fetchVerifiedObject(fileUrl, fileId); const keyBytes = base64UrlToBytes(keyB64Url); const ivBytes = base64UrlToBytes(ivB64Url); let plainBytes; @@ -179,26 +595,128 @@ export async function downloadAndDecryptDmFile(attachment = {}) { keyBytes.fill(0); ivBytes.fill(0); } - - const expectedSize = Number(attachment?.size || 0); - if (expectedSize >= 0 && plainBytes.byteLength !== expectedSize) { + if (plainBytes.byteLength !== Number(attachment?.size || 0)) { plainBytes.fill(0); throw new Error('Размер расшифрованного файла не совпал с сообщением'); } + return plainBytes; +} - const blob = new Blob([plainBytes], { - type: String(attachment?.mime || 'application/octet-stream') || 'application/octet-stream', - }); - plainBytes.fill(0); +function suggestedPickerTypes(attachment) { + const mime = String(attachment?.mime || '').trim(); + if (!mime || mime === 'application/octet-stream') return undefined; + const name = normalizeFileName(attachment?.name || 'file'); + const dot = name.lastIndexOf('.'); + const extension = dot >= 0 ? name.slice(dot) : ''; + return [{ + description: 'Файл SHiNE', + accept: { [mime]: extension ? [extension] : [] }, + }]; +} - const objectUrl = URL.createObjectURL(blob); +function saveBlob(blob, name) { + const objectUrlValue = URL.createObjectURL(blob); const anchor = document.createElement('a'); - anchor.href = objectUrl; - anchor.download = normalizeFileName(attachment?.name || 'file'); + anchor.href = objectUrlValue; + anchor.download = normalizeFileName(name || 'file'); anchor.rel = 'noopener'; anchor.style.display = 'none'; document.body.append(anchor); anchor.click(); anchor.remove(); - window.setTimeout(() => URL.revokeObjectURL(objectUrl), 30_000); + window.setTimeout(() => URL.revokeObjectURL(objectUrlValue), 30_000); +} + +export async function decryptDmFileToBlob(attachment = {}, options = {}) { + if (Number(attachment?.version || 1) < 2) { + const plain = await downloadV1(attachment); + const blob = new Blob([plain], { type: String(attachment?.mime || 'application/octet-stream') }); + plain.fill(0); + return blob; + } + const parts = []; + await decryptV2ToSink(attachment, async (plain) => { + parts.push(plain.slice().buffer); + }, options); + return new Blob(parts, { type: String(attachment?.mime || 'application/octet-stream') || 'application/octet-stream' }); +} + +export async function downloadAndDecryptDmFile(attachment = {}, { onProgress = null } = {}) { + if (Number(attachment?.version || 1) < 2) { + const plain = await downloadV1(attachment); + const blob = new Blob([plain], { type: String(attachment?.mime || 'application/octet-stream') }); + plain.fill(0); + saveBlob(blob, attachment?.name); + return { streamed: false, size: blob.size }; + } + + let writable = null; + if (typeof window.showSaveFilePicker === 'function') { + try { + const handle = await window.showSaveFilePicker({ + suggestedName: normalizeFileName(attachment?.name || 'file'), + types: suggestedPickerTypes(attachment), + }); + writable = await handle.createWritable(); + } catch (error) { + if (error?.name === 'AbortError') return { cancelled: true }; + // Some browsers expose the API but reject certain MIME/type descriptors. + try { + const handle = await window.showSaveFilePicker({ suggestedName: normalizeFileName(attachment?.name || 'file') }); + writable = await handle.createWritable(); + } catch (secondError) { + if (secondError?.name === 'AbortError') return { cancelled: true }; + } + } + } + + if (writable) { + try { + await decryptV2ToSink(attachment, async (plain) => { + await writable.write(plain); + }, { onProgress }); + await writable.close(); + writable = null; + return { streamed: true, size: Number(attachment?.size || 0) }; + } catch (error) { + try { await writable?.abort?.(); } catch { /* ignore */ } + throw error; + } + } + + // Cross-browser fallback: still chunk-download/decrypt, but the final Blob is held + // in memory because Firefox/Safari do not yet expose a writable download stream. + const blob = await decryptDmFileToBlob(attachment, { onProgress }); + saveBlob(blob, attachment?.name); + return { streamed: false, size: blob.size }; +} + +export async function buildDmFileTorrentV2(attachment = {}) { + if (Number(attachment?.version || 1) < 2) throw new Error('Torrent-v2 metadata есть только у файлов SHiNE v2'); + const context = await loadV2RootManifest(attachment); + const pieceLayer = []; + try { + for await (const descriptor of iterateV2Chunks(context)) { + if (Number(context.manifest.fileSize || 0) > TORRENT_V2_PIECE_BYTES) { + const hash = base64UrlToBytes(String(descriptor?.ph || '')); + if (hash.byteLength !== 32) throw new Error('Повреждён torrent piece layer'); + pieceLayer.push(hash); + } + } + const piecesRoot = context.manifest?.torrent?.piecesRoot + ? base64UrlToBytes(context.manifest.torrent.piecesRoot) + : null; + const bytes = buildTorrentV2MetainfoBytes({ + name: normalizeFileName(attachment?.name || 'file'), + size: Number(attachment?.size || 0), + piecesRoot, + pieceLayer, + }); + piecesRoot?.fill(0); + pieceLayer.forEach((hash) => hash.fill(0)); + return new Blob([bytes], { type: 'application/x-bittorrent' }); + } finally { + context.key.fill(0); + context.ivPrefix.fill(0); + } } diff --git a/shine-UI/js/services/dm-tech-blocks.js b/shine-UI/js/services/dm-tech-blocks.js index 9c906aef..a14c0cc9 100644 --- a/shine-UI/js/services/dm-tech-blocks.js +++ b/shine-UI/js/services/dm-tech-blocks.js @@ -9,6 +9,7 @@ function defaultParsed(rawText = '') { replyRef: null, callSummary: null, fileAttachment: null, + fileAttachments: [], }; } @@ -97,17 +98,46 @@ function decodeFileField(value = '') { } function normalizeFileAttachment(fields = {}) { + const version = Number(fields.v || 0); const id = String(fields.id || '').trim(); const url = decodeFileField(fields.url || '').trim(); const keyB64Url = String(fields.key || '').trim(); - const ivB64Url = String(fields.iv || '').trim(); const name = decodeFileField(fields.name || '').trim() || 'file'; const mime = decodeFileField(fields.mime || '').trim() || 'application/octet-stream'; const size = Number(fields.size || 0); const encryptedSize = Number(fields.encsize || 0); - if (!id || !url || !keyB64Url || !ivB64Url || !Number.isFinite(size) || size < 0) return null; + if (!id || !url || !keyB64Url || !Number.isFinite(size) || size < 0) return null; + + if (version >= 2) { + const ivPrefixB64Url = String(fields.ivp || '').trim(); + if (!ivPrefixB64Url) return null; + const chunkSize = Number(fields.chunk || 0); + const chunkCount = Number(fields.chunks || 0); + const kind = String(fields.kind || 'file').trim().toLowerCase() === 'voice' ? 'voice' : 'file'; + const durationMs = Math.max(0, Math.floor(Number(fields.dur || 0))); + return { + version, + id, + url, + keyB64Url, + ivPrefixB64Url, + name, + mime, + size, + encryptedSize: Number.isFinite(encryptedSize) && encryptedSize > 0 ? encryptedSize : 0, + chunkSize: Number.isFinite(chunkSize) && chunkSize > 0 ? chunkSize : 0, + chunkCount: Number.isFinite(chunkCount) && chunkCount >= 0 ? Math.floor(chunkCount) : 0, + torrentV2InfoHashB64Url: String(fields.th || '').trim(), + torrentV2PiecesRootB64Url: String(fields.pr || '').trim(), + kind, + durationMs, + }; + } + + const ivB64Url = String(fields.iv || '').trim(); + if (!ivB64Url) return null; return { - version: Number(fields.v || 0), + version: version || 1, id, url, keyB64Url, @@ -116,20 +146,37 @@ function normalizeFileAttachment(fields = {}) { mime, size, encryptedSize: Number.isFinite(encryptedSize) && encryptedSize > 0 ? encryptedSize : 0, + kind: 'file', + durationMs: 0, }; } export function buildDmFileTechBlock(attachment = {}) { + const version = Math.max(1, Math.floor(Number(attachment?.version || 1))); const id = String(attachment?.id || '').trim(); const url = String(attachment?.url || '').trim(); const keyB64Url = String(attachment?.keyB64Url || '').trim(); - const ivB64Url = String(attachment?.ivB64Url || '').trim(); const size = Math.max(0, Math.floor(Number(attachment?.size || 0))); const encryptedSize = Math.max(0, Math.floor(Number(attachment?.encryptedSize || 0))); - if (!id || !url || !keyB64Url || !ivB64Url) throw new Error('Не хватает данных для технического блока файла'); + if (!id || !url || !keyB64Url) throw new Error('Не хватает данных для технического блока файла'); const name = encodeURIComponent(String(attachment?.name || 'file')); const mime = encodeURIComponent(String(attachment?.mime || 'application/octet-stream')); const encodedUrl = encodeURIComponent(url); + + if (version >= 2) { + const ivPrefix = String(attachment?.ivPrefixB64Url || '').trim(); + if (!ivPrefix) throw new Error('Не хватает IV prefix для chunked-файла'); + const chunkSize = Math.max(0, Math.floor(Number(attachment?.chunkSize || 0))); + const chunkCount = Math.max(0, Math.floor(Number(attachment?.chunkCount || 0))); + const torrentHash = String(attachment?.torrentV2InfoHashB64Url || '').trim(); + const piecesRoot = String(attachment?.torrentV2PiecesRootB64Url || '').trim(); + const kind = String(attachment?.kind || 'file') === 'voice' ? 'voice' : 'file'; + const durationMs = Math.max(0, Math.floor(Number(attachment?.durationMs || 0))); + return ``; + } + + const ivB64Url = String(attachment?.ivB64Url || '').trim(); + if (!ivB64Url) throw new Error('Не хватает IV для файла v1'); return ``; } @@ -142,6 +189,7 @@ export function parseDmTechBlocks(rawText = '') { let replyRef = null; let callSummary = null; let fileAttachment = null; + const fileAttachments = []; while (hasTechPrefix(text, cursor)) { const end = text.indexOf('>', cursor); @@ -189,8 +237,12 @@ export function parseDmTechBlocks(rawText = '') { reason: String(fields.reason || '').trim().toLowerCase(), }; } - } else if (kind === 'file' && !fileAttachment) { - fileAttachment = normalizeFileAttachment(fields); + } else if (kind === 'file') { + const attachment = normalizeFileAttachment(fields); + if (attachment) { + fileAttachments.push(attachment); + if (!fileAttachment) fileAttachment = attachment; + } } cursor = end + 1; @@ -207,5 +259,6 @@ export function parseDmTechBlocks(rawText = '') { replyRef, callSummary, fileAttachment, + fileAttachments, }; } diff --git a/shine-UI/js/services/feature-settings.js b/shine-UI/js/services/feature-settings.js index 0b2598f8..8fa93346 100644 --- a/shine-UI/js/services/feature-settings.js +++ b/shine-UI/js/services/feature-settings.js @@ -27,7 +27,7 @@ function writeBoolean(key, enabled) { } export function isDeveloperToolsEnabled() { - return readBoolean(STORAGE_KEYS.developerTools, true); + return readBoolean(STORAGE_KEYS.developerTools, false); } export function setDeveloperToolsEnabled(enabled) { diff --git a/shine-UI/js/services/torrent-v2-service.js b/shine-UI/js/services/torrent-v2-service.js new file mode 100644 index 00000000..2dc27ee7 --- /dev/null +++ b/shine-UI/js/services/torrent-v2-service.js @@ -0,0 +1,234 @@ +import { bytesToBase64Url, sha256Bytes } from './crypto-utils.js'; + +export const TORRENT_V2_BLOCK_BYTES = 16 * 1024; +export const TORRENT_V2_PIECE_BYTES = 1024 * 1024; + +const ZERO_HASH = new Uint8Array(32); +const encoder = new TextEncoder(); + +function concatBytes(parts = []) { + const arrays = parts.map((part) => part instanceof Uint8Array ? part : new Uint8Array(part || 0)); + const total = arrays.reduce((sum, part) => sum + part.byteLength, 0); + const out = new Uint8Array(total); + let offset = 0; + for (const part of arrays) { + out.set(part, offset); + offset += part.byteLength; + } + return out; +} + +async function hashPair(left, right) { + return sha256Bytes(concatBytes([left, right])); +} + +function nextPowerOfTwo(value) { + let n = 1; + while (n < value) n *= 2; + return n; +} + +async function reduceMerkleLevel(nodes) { + if (nodes.length <= 1) return nodes; + const next = []; + for (let i = 0; i < nodes.length; i += 2) { + next.push(hashPair(nodes[i], nodes[i + 1])); + } + return Promise.all(next); +} + +/** + * BEP 52 pieces root / piece-layer hash for one piece. + * Leaves are SHA-256 hashes of 16 KiB blocks. Padding leaves are 32 zero bytes, + * exactly as required by BitTorrent v2. + */ +export async function computeTorrentV2PieceRoot(pieceBytes, { padToPieceLength = false } = {}) { + const bytes = pieceBytes instanceof Uint8Array ? pieceBytes : new Uint8Array(pieceBytes || 0); + if (!bytes.byteLength) return null; + + const blockCount = Math.ceil(bytes.byteLength / TORRENT_V2_BLOCK_BYTES); + const leafPromises = []; + for (let offset = 0; offset < bytes.byteLength; offset += TORRENT_V2_BLOCK_BYTES) { + leafPromises.push(sha256Bytes(bytes.subarray(offset, Math.min(bytes.byteLength, offset + TORRENT_V2_BLOCK_BYTES)))); + } + let level = await Promise.all(leafPromises); + const targetLeaves = padToPieceLength + ? (TORRENT_V2_PIECE_BYTES / TORRENT_V2_BLOCK_BYTES) + : nextPowerOfTwo(Math.max(1, blockCount)); + while (level.length < targetLeaves) level.push(ZERO_HASH.slice()); + while (level.length > 1) level = await reduceMerkleLevel(level); + return level[0]; +} + +let zeroPieceRootPromise = null; +export function getTorrentV2ZeroPieceRoot() { + if (!zeroPieceRootPromise) { + zeroPieceRootPromise = (async () => { + let level = Array.from( + { length: TORRENT_V2_PIECE_BYTES / TORRENT_V2_BLOCK_BYTES }, + () => ZERO_HASH.slice(), + ); + while (level.length > 1) level = await reduceMerkleLevel(level); + return level[0]; + })(); + } + return zeroPieceRootPromise; +} + +/** + * Streaming Merkle accumulator for the BEP 52 piece layer. It keeps only O(log n) + * hashes in memory and pads the right side with the standard zero-piece subtree. + */ +export class TorrentV2PieceAccumulator { + constructor() { + this.stack = []; + this.count = 0; + } + + async #addSubtree(hash, level) { + let current = hash; + let currentLevel = level; + while (this.stack[currentLevel]) { + current = await hashPair(this.stack[currentLevel], current); + this.stack[currentLevel] = null; + currentLevel += 1; + } + this.stack[currentLevel] = current; + } + + async addPieceRoot(hash) { + if (!(hash instanceof Uint8Array) || hash.byteLength !== 32) { + throw new Error('Некорректный torrent-v2 piece hash'); + } + await this.#addSubtree(hash, 0); + this.count += 1; + } + + async finalize() { + if (this.count <= 0) return null; + let target = 1; + while (target < this.count) target *= 2; + let remaining = target - this.count; + let count = this.count; + const zeroRoots = [await getTorrentV2ZeroPieceRoot()]; + const ensureZeroLevel = async (level) => { + while (zeroRoots.length <= level) { + const previous = zeroRoots[zeroRoots.length - 1]; + zeroRoots.push(await hashPair(previous, previous)); + } + return zeroRoots[level]; + }; + + while (remaining > 0) { + let maxByRemaining = Math.floor(Math.log2(remaining)); + let alignmentLevel = 0; + let aligned = count; + while (aligned > 0 && aligned % 2 === 0) { + alignmentLevel += 1; + aligned /= 2; + } + const level = Math.min(maxByRemaining, alignmentLevel); + const blockLeaves = 2 ** level; + await this.#addSubtree(await ensureZeroLevel(level), level); + count += blockLeaves; + remaining -= blockLeaves; + } + + const root = this.stack.findLast?.((value) => value) || [...this.stack].reverse().find((value) => value) || null; + return root ? root.slice() : null; + } +} + +function encodeBString(bytes) { + const data = bytes instanceof Uint8Array ? bytes : encoder.encode(String(bytes ?? '')); + return concatBytes([encoder.encode(`${data.byteLength}:`), data]); +} + +function compareByteArrays(left, right) { + const limit = Math.min(left.byteLength, right.byteLength); + for (let i = 0; i < limit; i += 1) { + if (left[i] !== right[i]) return left[i] - right[i]; + } + return left.byteLength - right.byteLength; +} + +function encodeBValue(value) { + if (value instanceof Uint8Array) return encodeBString(value); + if (typeof value === 'string') return encodeBString(encoder.encode(value)); + if (typeof value === 'number' || typeof value === 'bigint') { + const integer = typeof value === 'bigint' ? value : BigInt(Math.trunc(value)); + return encoder.encode(`i${integer.toString()}e`); + } + if (Array.isArray(value)) { + return concatBytes([encoder.encode('l'), ...value.map(encodeBValue), encoder.encode('e')]); + } + if (value && typeof value === 'object') { + const entries = Object.entries(value).map(([key, item]) => ({ + keyBytes: encoder.encode(key), + value: item, + })).sort((a, b) => compareByteArrays(a.keyBytes, b.keyBytes)); + const parts = [encoder.encode('d')]; + for (const entry of entries) { + parts.push(encodeBString(entry.keyBytes), encodeBValue(entry.value)); + } + parts.push(encoder.encode('e')); + return concatBytes(parts); + } + throw new Error('Неподдерживаемое значение bencode'); +} + +export function buildTorrentV2InfoBytes({ name, size, piecesRoot, pieceLength = TORRENT_V2_PIECE_BYTES } = {}) { + const cleanName = String(name || 'file'); + const fileData = { length: Math.max(0, Math.trunc(Number(size || 0))) }; + if (fileData.length > 0) { + if (!(piecesRoot instanceof Uint8Array) || piecesRoot.byteLength !== 32) { + throw new Error('Для непустого файла нужен 32-байтный pieces root'); + } + fileData['pieces root'] = piecesRoot; + } + return encodeBValue({ + 'file tree': { + [cleanName]: { + '': fileData, + }, + }, + 'meta version': 2, + name: cleanName, + 'piece length': Math.trunc(pieceLength), + }); +} + +export async function computeTorrentV2InfoHash({ name, size, piecesRoot } = {}) { + const infoBytes = buildTorrentV2InfoBytes({ name, size, piecesRoot }); + const hash = await sha256Bytes(infoBytes); + return { + infoBytes, + infoHash: hash, + infoHashB64Url: bytesToBase64Url(hash), + }; +} + +/** Builds a tracker-less BitTorrent v2 metainfo file. */ +export function buildTorrentV2MetainfoBytes({ name, size, piecesRoot, pieceLayer = [] } = {}) { + const infoBytes = buildTorrentV2InfoBytes({ name, size, piecesRoot }); + const outer = [encoder.encode('d'), encodeBString(encoder.encode('info')), infoBytes]; + if (Number(size || 0) > TORRENT_V2_PIECE_BYTES) { + if (!(piecesRoot instanceof Uint8Array) || piecesRoot.byteLength !== 32) { + throw new Error('Некорректный pieces root'); + } + const hashes = (Array.isArray(pieceLayer) ? pieceLayer : []).map((hash) => { + if (!(hash instanceof Uint8Array) || hash.byteLength !== 32) throw new Error('Некорректный piece layer'); + return hash; + }); + const layerBytes = concatBytes(hashes); + outer.push( + encodeBString(encoder.encode('piece layers')), + encoder.encode('d'), + encodeBString(piecesRoot), + encodeBString(layerBytes), + encoder.encode('e'), + ); + } + outer.push(encoder.encode('e')); + return concatBytes(outer); +} diff --git a/shine-UI/styles/features/chat.css b/shine-UI/styles/features/chat.css index 0c9ad973..cc8b3c94 100644 --- a/shine-UI/styles/features/chat.css +++ b/shine-UI/styles/features/chat.css @@ -1373,3 +1373,303 @@ right: 0; bottom: 104px; } + +/* ===== DM file transfer v2 + voice notes ===== */ +.dm-attachment-stack { + display: grid; + gap: 8px; + min-width: 0; +} + +.dm-send-icon-btn svg { + width: 22px; + height: 22px; + display: block; + margin: auto; +} + +.dm-send-icon-btn.is-recording { + transform: scale(1.04); + box-shadow: 0 0 0 5px rgba(255, 104, 126, 0.08), 0 0 24px rgba(255, 104, 126, 0.18); +} + +.dm-send-icon-btn.is-recording:not(.is-locked) { + color: rgba(255, 190, 202, 0.98); +} + +.dm-send-icon-btn.is-locked { + color: rgba(255, 218, 226, 0.98); + background: rgba(164, 45, 70, 0.28); + border-color: rgba(255, 150, 171, 0.32); +} + +.dm-send-busy-dot { + display: inline-grid; + place-items: center; + width: 100%; + height: 100%; + font-size: 20px; + line-height: 1; +} + +.dm-voice-recording-status { + grid-column: 1; + min-height: 42px; + display: grid; + grid-template-columns: auto auto minmax(0, 1fr); + align-items: center; + gap: 8px; + padding: 0 12px; + border: 1px solid rgba(255, 255, 255, 0.10); + border-radius: 14px; + background: rgba(255, 255, 255, 0.035); + overflow: hidden; +} + +.dm-voice-recording-status[hidden] { + display: none; +} + +.dm-voice-recording-dot { + width: 8px; + height: 8px; + border-radius: 50%; + background: rgba(255, 104, 126, 0.96); + box-shadow: 0 0 12px rgba(255, 104, 126, 0.55); + animation: dmVoicePulse 1s ease-in-out infinite; +} + +@keyframes dmVoicePulse { + 0%, 100% { opacity: 0.55; transform: scale(0.82); } + 50% { opacity: 1; transform: scale(1); } +} + +.dm-voice-recording-time { + font-variant-numeric: tabular-nums; + font-size: 13px; + font-weight: 720; + color: rgba(255, 230, 235, 0.96); +} + +.dm-voice-recording-hint { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + text-align: right; + font-size: 11px; + color: rgba(210, 222, 241, 0.66); +} + +.dm-chat-input--recording .dm-actions-col { + align-self: center; +} + +.dm-voice-card { + min-width: min(280px, 72vw); + max-width: min(330px, 76vw); + display: grid; + grid-template-columns: 42px minmax(0, 1fr); + align-items: center; + gap: 10px; +} + +.dm-voice-card__play { + width: 42px; + height: 42px; + min-width: 42px; + padding: 0; + border-radius: 50%; + display: grid; + place-items: center; + font-size: 15px; + line-height: 1; + background: rgba(92, 190, 255, 0.12); + border: 1px solid rgba(159, 211, 255, 0.22); +} + +.dm-voice-card__play:disabled { + opacity: 0.65; + cursor: progress; +} + +.dm-voice-card__body { + min-width: 0; + display: grid; + gap: 6px; +} + +.dm-voice-card__track { + position: relative; + width: 100%; + height: 18px; + min-height: 18px; + padding: 0; + border: 0; + background: transparent; + overflow: hidden; +} + +.dm-voice-card__track::before { + content: ''; + position: absolute; + left: 0; + right: 0; + top: 8px; + height: 2px; + border-radius: 999px; + background: rgba(220, 234, 255, 0.20); +} + +.dm-voice-card__fill { + position: absolute; + left: 0; + top: 7px; + width: 0; + height: 4px; + border-radius: 999px; + background: currentColor; + box-shadow: 0 0 8px rgba(92, 190, 255, 0.35); + pointer-events: none; +} + +.dm-voice-card__meta { + display: flex; + justify-content: space-between; + gap: 10px; + font-size: 11px; + color: rgba(205, 219, 242, 0.68); + font-variant-numeric: tabular-nums; +} + +@media (max-width: 390px) { + .dm-voice-card { + min-width: min(252px, 76vw); + } + + .dm-voice-recording-hint { + max-width: 150px; + } +} + +/* Пересылка DM: выбор чата и подтверждение остаются внутри одного feature-owned modal. */ +.dm-forward-modal { + align-items: center; + padding: 18px; +} + +.dm-forward-card { + width: min(92vw, 430px); + max-height: min(78vh, 680px); + overflow: hidden; + gap: 12px; + border: 1px solid rgba(104, 193, 255, 0.25); + border-radius: 24px; + background: linear-gradient(155deg, rgba(20, 31, 49, 0.98), rgba(7, 13, 24, 0.98)); + box-shadow: 0 30px 80px rgba(0, 0, 0, 0.52), 0 0 34px rgba(56, 165, 255, 0.12); + backdrop-filter: blur(24px) saturate(130%); + -webkit-backdrop-filter: blur(24px) saturate(130%); +} + +.dm-forward-head, +.dm-forward-confirm-peer { + display: flex; + align-items: center; + gap: 12px; +} + +.dm-forward-head { + justify-content: space-between; +} + +.dm-forward-close { + width: 36px; + height: 36px; + border-radius: 50%; + font-size: 25px; + line-height: 1; + color: rgba(255, 255, 255, 0.78); + background: rgba(255, 255, 255, 0.05); +} + +.dm-forward-preview { + padding: 11px 13px; + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 14px; + color: rgba(242, 247, 255, 0.76); + background: rgba(255, 255, 255, 0.035); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.dm-forward-preview--confirm { + white-space: normal; + max-height: 128px; + overflow: auto; +} + +.dm-forward-list { + display: flex; + flex-direction: column; + gap: 4px; + min-height: 120px; + max-height: min(54vh, 460px); + overflow-y: auto; + padding-right: 2px; +} + +.dm-forward-chat-row { + width: 100%; + display: grid; + grid-template-columns: auto 1fr auto; + align-items: center; + gap: 11px; + min-height: 62px; + padding: 8px 10px; + border: 0; + border-radius: 15px; + text-align: left; + color: var(--text); + background: transparent; +} + +.dm-forward-chat-row:hover, +.dm-forward-chat-row:focus-visible { + outline: none; + background: rgba(67, 166, 255, 0.11); +} + +.dm-forward-chat-copy { + min-width: 0; + display: flex; + flex-direction: column; + gap: 2px; +} + +.dm-forward-chat-copy strong, +.dm-forward-chat-copy span { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.dm-forward-chat-arrow { + font-size: 28px; + color: rgba(130, 205, 255, 0.7); +} + +.dm-forward-loading { + padding: 20px 8px; + text-align: center; +} + +.dm-forward-confirm-peer { + padding: 12px; + border-radius: 16px; + background: rgba(67, 166, 255, 0.08); +} + +.dm-forward-confirm-actions { + margin-top: 2px; +}