diff --git a/Dev_Docs/API/09_Operations_Index.md b/Dev_Docs/API/09_Operations_Index.md index 93702fa..1241063 100644 --- a/Dev_Docs/API/09_Operations_Index.md +++ b/Dev_Docs/API/09_Operations_Index.md @@ -61,5 +61,6 @@ ## Важные замечания - `ReceiveOutcomingMessage` сейчас зарегистрирован как алиас того же handler/request-класса, что и `SendMessagePair`. +- HTTP endpoints для DM-файлов (`HEAD/GET /f/` и `POST /upload`) не являются WebSocket `op`, поэтому в таблицу выше не входят; они описаны в `12_Direct_Messages_Push_Calls_API.md`. - Классы `Net_MarkChannelMessagesSeen_*` существуют в коде, но операция `MarkChannelMessagesSeen` не зарегистрирована в `JsonHandlerRegistry`, поэтому в публичный список API не входит. - HTTP debug endpoints из `src/main/java/server/debug/` не входят в этот индекс WebSocket `op`; они описаны отдельно в `13_HTTP_Debug_API.md`. diff --git a/Dev_Docs/API/12_Direct_Messages_Push_Calls_API.md b/Dev_Docs/API/12_Direct_Messages_Push_Calls_API.md index a67c961..d7f2bc2 100644 --- a/Dev_Docs/API/12_Direct_Messages_Push_Calls_API.md +++ b/Dev_Docs/API/12_Direct_Messages_Push_Calls_API.md @@ -1,8 +1,8 @@ -# API для разработчиков: DM, push и сигналы звонков +# API для разработчиков: DM, файлы, push и сигналы звонков -Документ описывает WebSocket-операции для подписанных личных сообщений, WebPush и realtime-сигналов звонков. +Документ описывает публичные операции и endpoints, связанные с личными сообщениями, файлами для DM, WebPush и сигналами звонков. -Логика личных сообщений дополнительно описана в `Dev_Docs/Personal_Messages/README.md`; этот файл фиксирует именно публичные `op`, поля запросов и поля ответов. +Подробная логика DM и бинарного формата: `Dev_Docs/Personal_Messages/README.md`. ## 1. `UpsertPushToken` @@ -40,11 +40,9 @@ } ``` ---- - ## 2. `SendTestWebPush` -Требует авторизации. Если `login` передан, он должен совпадать с логином текущей сессии. +Требует авторизации. ### Запрос @@ -61,65 +59,18 @@ } ``` -### Успешный ответ +## 3. `SendMessagePair` и `ReceiveOutcomingMessage` -```json -{ - "op": "SendTestWebPush", - "requestId": "push-test-001", - "status": 200, - "ok": true, - "payload": { - "targetLogin": "alice", - "attemptedSessions": 1, - "sessionsWithPushConfig": 1, - "delivered": 1, - "failed": 0, - "sentAtMs": 1774700000123 - } -} -``` +`ReceiveOutcomingMessage` — алиас `SendMessagePair`. ---- +### Назначение -## 3. `SendDirectMessage` +Передаёт пару signed DM-блоков: -Отправляет один подписанный DM-пакет. +- `incomingBlobB64` — блок `type=1` или `type=3` +- `outgoingBlobB64` — блок `type=2` или `type=4` -### Запрос - -```json -{ - "op": "SendDirectMessage", - "requestId": "dm-001", - "payload": { - "blobB64": "BASE64_SIGNED_DM_PACKET" - } -} -``` - -### Успешный ответ - -```json -{ - "op": "SendDirectMessage", - "requestId": "dm-001", - "status": 200, - "ok": true, - "payload": { - "messageId": "dm-1", - "deliveredWsSessions": 1, - "deliveredWebPushSessions": 0, - "sessionNotFound": false - } -} -``` - ---- - -## 4. `SendMessagePair` и `ReceiveOutcomingMessage` - -`ReceiveOutcomingMessage` сейчас является алиасом `SendMessagePair` и использует тот же request/handler. +Для контентных сообщений `type=1/2` внутри base64 лежит новый бинарный формат `SHiNE_DM`. ### Запрос @@ -143,55 +94,28 @@ "status": 200, "ok": true, "payload": { - "baseKey": "base-key", - "incomingKey": "incoming-key", - "outgoingKey": "outgoing-key", + "baseKey": "from|to|time|nonce", + "incomingKey": "from|to|time|nonce|1", + "outgoingKey": "from|to|time|nonce|2", "deliveredWsSessions": 1, "deliveredWebPushSessions": 0 } } ``` ---- +### Ошибки -## 5. `ReceiveIncomingMessage` +- `400 / BAD_FIELDS` — пустой `incomingBlobB64` или `outgoingBlobB64` +- `400 / BAD_BLOCK_FORMAT` — base64 или бинарный контейнер повреждён +- `400 / BAD_CONTENT_FORMAT` — для контентного сообщения пришёл не `SHiNE_DM` +- `400 / TOO_MANY_ATTACHMENTS` — больше 12 вложений +- `400 / ATTACHMENT_NOT_FOUND` — сообщение ссылается на blob, которого нет на сервере +- `404 / USER_NOT_FOUND` — один из логинов не найден +- `460 / BAD_SIGNATURE` — подпись блока не прошла проверку -Принимает входящий подписанный DM-блок. +## 4. `AckSessionDelivery` -### Запрос - -```json -{ - "op": "ReceiveIncomingMessage", - "requestId": "dm-in-001", - "payload": { - "incomingBlobB64": "BASE64_INCOMING_SIGNED_BLOCK" - } -} -``` - -### Успешный ответ - -```json -{ - "op": "ReceiveIncomingMessage", - "requestId": "dm-in-001", - "status": 200, - "ok": true, - "payload": { - "messageKey": "incoming-key", - "baseKey": "base-key", - "deliveredWsSessions": 1, - "deliveredWebPushSessions": 0 - } -} -``` - ---- - -## 6. `AckSessionDelivery` - -Требует авторизации. Подтверждает доставку сообщения в текущую сессию. +Требует авторизации. Подтверждает доставку в текущую сессию. ### Запрос @@ -200,107 +124,99 @@ "op": "AckSessionDelivery", "requestId": "ack-001", "payload": { - "messageKey": "incoming-key" + "messageKey": "from|to|time|nonce|1" } } ``` +## 5. Событие `SignedMessageArrived` + +Сервер присылает его по WebSocket в активные сессии адресата. + +### Payload события + +```json +{ + "messageKey": "from|to|time|nonce|1", + "baseKey": "from|to|time|nonce", + "fromLogin": "alice", + "toLogin": "bob", + "targetLogin": "bob", + "messageType": 1, + "timeMs": 1774700000123, + "nonce": 123456789, + "blobB64": "BASE64_SIGNED_BLOCK", + "backlog": false +} +``` + +Если это новая ревизия того же письма, `messageKey` остаётся тем же, а `revisionTimeMs` меняется внутри бинарного блока. + +## 6. HTTP `HEAD /f/` + +Проверка, есть ли ciphertext-файл на сервере. + +### Ответы + +- `200` — файл существует +- `404` — файла нет + +## 7. HTTP `GET /f/` + +Отдаёт ciphertext-файл. + +### Особенности + +- `Content-Type: application/octet-stream` +- файл сейчас доступен публично +- имя файла на диске и в URL — `base64url(SHA-256(ciphertext))` + +## 8. HTTP `POST /upload?hash=&size=` + +Загружает ciphertext-файл для будущего DM. + +### Тело запроса + +Raw bytes ciphertext-файла. + +### Поведение сервера + +- пересчитывает `SHA-256` +- сверяет размер +- сохраняет blob в папку `f/`, если его ещё не было +- если blob уже есть, не перезаписывает его +- создаёт или обновляет запись в `dm_files` + ### Успешный ответ ```json { - "op": "AckSessionDelivery", - "requestId": "ack-001", - "status": 200, "ok": true, - "payload": { - "messageKey": "incoming-key" - } + "hash": "base64url_sha256", + "size": 245120, + "alreadyExists": false } ``` ---- +### Ошибки -## 7. `CallInviteBroadcast` +- `400 / bad hash` +- `400 / bad size` +- `400 / SIZE_MISMATCH` +- `400 / HASH_MISMATCH` +- `400 / UPLOAD_TOO_LARGE` +- `500 / upload_failed` -Требует авторизации. Отправляет приглашение к звонку на активные сессии пользователя `toLogin`. +## 9. `CallInviteBroadcast` -### Запрос +Требует авторизации. Шлёт приглашение к звонку в активные сессии `toLogin`. -```json -{ - "op": "CallInviteBroadcast", - "requestId": "call-invite-001", - "payload": { - "toLogin": "bob", - "callId": "call-1", - "type": 100 - } -} -``` +## 10. `CallSignalToSession` -### Успешный ответ +Требует авторизации. Шлёт сигнал звонка в конкретную сессию. -```json -{ - "op": "CallInviteBroadcast", - "requestId": "call-invite-001", - "status": 200, - "ok": true, - "payload": { - "callId": "call-1", - "deliveredWsSessions": 1, - "deliveredFcmSessions": 0, - "deliveredWebPushSessions": 0 - } -} -``` +## 11. Замечания ---- - -## 8. `CallSignalToSession` - -Требует авторизации. Отправляет сигнал звонка в конкретную сессию получателя. - -### Запрос - -```json -{ - "op": "CallSignalToSession", - "requestId": "call-signal-001", - "payload": { - "toLogin": "bob", - "targetSessionId": "SESSION_ID", - "callId": "call-1", - "type": 101, - "data": "{\"sdp\":\"...\"}" - } -} -``` - -### Успешный ответ - -```json -{ - "op": "CallSignalToSession", - "requestId": "call-signal-001", - "status": 200, - "ok": true, - "payload": { - "delivered": true - } -} -``` - -Если целевая сессия не найдена или доставка не удалась, сервер может вернуть `404`. - -## Типовые ошибки - -- `422 / NOT_AUTHENTICATED` — требуется авторизация. -- `400 / BAD_FIELDS` — не заполнены обязательные поля. -- `404 / USER_NOT_FOUND` — пользователь не найден. -- `404 / SESSION_NOT_FOUND` — сессия не найдена. -- `422 / BAD_SIGNATURE` — подпись DM не прошла проверку. -- `422 / BAD_DEVICE_KEY` — некорректный device key отправителя. -- `422 / BAD_TIME_WINDOW` — время подписанного сообщения вне допустимого окна. -- `422 / REPLAY` — повторное сообщение заблокировано. +- Для нового DM-файла сценарий такой: `HEAD /f/` → при `404` `POST /upload` → затем `SendMessagePair`. +- Сервер хранит только последнюю версию контентного сообщения по `messageKey`. +- Удаление сообщения реализуется новой ревизией с пустым телом и нулём вложений. diff --git a/Dev_Docs/Pending_Features/2026-06-17_1735_dm_files_and_revisions.md b/Dev_Docs/Pending_Features/2026-06-17_1735_dm_files_and_revisions.md new file mode 100644 index 0000000..0341911 --- /dev/null +++ b/Dev_Docs/Pending_Features/2026-06-17_1735_dm_files_and_revisions.md @@ -0,0 +1,19 @@ +# DM-вложения, upload и ревизии сообщений + +- краткое описание фичи: + Добавлен новый формат контентных DM `SHiNE_DM`, HTTP upload/download ciphertext-файлов, серверный `upsert` последней версии сообщения и UI-скачивание/расшифровка вложений. + +- что проверять: + 1. Отправка обычного текста без вложений. + 2. Отправка сообщения с 1-2 вложениями. + 3. Повторная отправка сообщения с уже загруженным файлом без повторной записи blob. + 4. Скачивание вложения из UI и корректная расшифровка файла. + 5. Доставка backlog после переподключения сессии. + 6. Обновление существующего сообщения той же парой `timeMs+nonce` и большим `revisionTimeMs`. + 7. Удаление сообщения пустой ревизией (`attachments=0`, `encryptedBodyLen=0`) и исчезновение из UI. + +- ожидаемый результат: + Сообщения `type=1/2` приходят в формате `SHiNE_DM`, файлы доступны по `/f/`, UI показывает вложения кнопками скачивания, сервер хранит только последнюю ревизию по `messageKey`, а пустая ревизия убирает сообщение из интерфейса. + +- статус: + pending diff --git a/Dev_Docs/Personal_Messages/README.md b/Dev_Docs/Personal_Messages/README.md index 84812aa..7aec00a 100644 --- a/Dev_Docs/Personal_Messages/README.md +++ b/Dev_Docs/Personal_Messages/README.md @@ -1,88 +1,150 @@ # Личные сообщения (DM): как это устроено -## Коротко (для быстрого понимания) +## Коротко -Личные сообщения в SHiNE сейчас работают как пара **подписанных клиентом блоков** в формате `SHiNE_dm2`: +Личные сообщения в SHiNE теперь работают в двух слоях: -- тип `1` — входящее сообщение для собеседника; -- тип `2` — исходящая копия того же сообщения для автора. +- контентные сообщения `type=1/2` идут в новом бинарном формате `SHiNE_DM`; +- read-receipt `type=3/4` пока остаются на legacy-формате `SHiNE_dm2`, чтобы не ломать текущую механику подтверждения прочтения. -Оба блока отправляются вместе одной операцией (`SendMessagePair` / `ReceiveOutcomingMessage`) и либо сохраняются оба, либо не сохраняются вовсе. -Дальше сервер доставляет их по активным сессиям целевого логина событием `SignedMessageArrived`, а клиент подтверждает доставку на конкретную сессию через `AckSessionDelivery`. +Одно логическое сообщение по-прежнему отправляется парой блоков: -Подтверждение прочтения также идёт парой блоков: +- `type=1` — входящее сообщение для получателя; +- `type=2` — исходящая копия для отправителя. -- тип `3` — «прочитано» для исходящего сообщения автора; -- тип `4` — зеркальная копия для второй стороны. - -UI чата строится на этих типах: текстовые сообщения (1/2), read-receipt (3/4), непрочитанные, галочки и история. - ---- - -## Подробно - -## 1) Общая схема потока - -1. Клиент формирует текст сообщения и строит **2 подписанных блока** (`type=1` и `type=2`) с одинаковыми `fromLogin/toLogin/timeMs/nonce`. -2. Клиент отправляет оба блока в одном RPC: `SendMessagePair` (алиас: `ReceiveOutcomingMessage`). -3. Сервер: - - парсит оба блока; - - валидирует пару; - - проверяет существование `from/to` пользователей и подписи; - - атомарно сохраняет пару в `signed_messages_v2`. -4. Сервер доставляет блоки в активные сессии целевого логина событием `SignedMessageArrived`. -5. Клиент, получив событие, кладёт сообщение в локальный чат и отправляет `AckSessionDelivery(messageKey)`. -6. При открытии чата клиент отправляет read-receipt (пара `type=3/4`) для непрочитанных входящих. - -## 2) Формат signed DM-блока (`SHiNE_dm2`) - -Префикс: `SHiNE_dm2` (ASCII). - -Далее поля (big-endian): - -1. `toLoginLen` (`u8`) + `toLogin` (ASCII, 1..60); -2. `fromLoginLen` (`u8`) + `fromLogin` (ASCII, 1..60); -3. `timeMs` (`u64`); -4. `nonce` (`u32`); -5. `messageType` (`u16`); -6. `payloadLen` (`u16`); -7. `payloadBytes` (`1..4096`); -8. `signature` (`64 bytes`, Ed25519). - -Ограничения: - -- полный пакет: до `8192` байт; -- `messageType` сейчас допустим только `1..4`. - -## 3) Типы DM-сообщений - -- `1` (`TYPE_INCOMING_TEXT`) — входящий текст для получателя. -- `2` (`TYPE_OUTGOING_COPY`) — исходящая копия в истории автора. -- `3` (`TYPE_READ_INCOMING`) — read-receipt (входящий тип для пары квитанции). -- `4` (`TYPE_READ_OUTGOING_COPY`) — зеркальная копия read-receipt. - -Правило пары: - -- первый блок должен быть нечётным (`1` или `3`); -- второй должен быть ровно `+1` (`2` или `4`); -- ключевые поля пары совпадают: `toLogin/fromLogin/timeMs/nonce`. - -## 4) Ключи сообщений +Ключ сообщения остаётся прежним: - `baseKey = from|to|timeMs|nonce` - `messageKey = baseKey|messageType` -Эти ключи используются: +Теперь `timeMs + nonce` задаются один раз на всё логическое сообщение и не меняются при редактировании. +Для новой версии того же письма используется то же `messageKey`, но большее `revisionTimeMs`. +Сервер хранит только последнюю версию записи по этому `messageKey` через `upsert`. -- для дедупликации; -- для связи read-receipt с исходным сообщением; -- для ACK доставки по сессии. +--- -## 5) RPC и события +## 1) Общая схема потока -## `SendMessagePair` (алиас `ReceiveOutcomingMessage`) +1. Клиент при необходимости сначала шифрует вложенные файлы локально. +2. Клиент считает `SHA-256(ciphertext)` и размер ciphertext. +3. Клиент проверяет наличие blob через `HEAD /f/`. +4. Если файла нет, клиент загружает ciphertext через `POST /upload?hash=&size=`. +5. После загрузки всех файлов клиент строит пару signed DM-блоков `type=1/2`. +6. Сервер валидирует подписи, формат, наличие blob-файлов и делает `upsert` пары. +7. В одной транзакции сервер: + - удаляет старые файловые связи этого сообщения; + - корректирует `ref_count`; + - записывает новую версию `signed_messages_v2`; + - создаёт новые файловые связи; + - сбрасывает pending-доставку по сессиям. +8. Сервер рассылает обновлённые блоки в активные сессии через `SignedMessageArrived`. +9. Клиент обновляет существующий пузырь по `messageKey`; если тело и вложения пустые — сообщение удаляется из UI. -Запрос: +--- + +## 2) Формат signed DM-блока для контентных сообщений (`SHiNE_DM`) + +Префикс: `SHiNE_DM` (ASCII). + +Далее поля, big-endian: + +1. `formatVersionMajor` (`u8`) = `1` +2. `formatVersionMinor` (`u8`) = `0` +3. `toLoginLen` (`u8`) + `toLogin` (ASCII, `1..60`) +4. `fromLoginLen` (`u8`) + `fromLogin` (ASCII, `1..60`) +5. `timeMs` (`u64`) +6. `nonce` (`u32`) +7. `messageType` (`u16`) — только `1` или `2` +8. `revisionTimeMs` (`u64`) +9. `attachmentsCount` (`u8`) — `0..12` +10. `attachments[]`: + - `encFileHashSHA256` (`32 bytes`) + - `encFileSize` (`u64`) +11. `encryptedBodyLen` (`u32`) — сервер сейчас ограничивает до `16384` +12. `encryptedBody` (`bytes`) +13. `signature` (`64 bytes`, Ed25519) + +### Важные правила + +- `messageType` не входит в ID логического письма, он только различает сторону пары. +- ID логического письма = `fromLogin + toLogin + timeMs + nonce`. +- У оригинала `revisionTimeMs = 0`. +- Для редактирования и удаления `timeMs`/`nonce` не меняются, меняется только `revisionTimeMs`. +- Чем больше `revisionTimeMs`, тем новее версия. + +--- + +## 3) Legacy read-receipt (`SHiNE_dm2`) + +Пока только блоки `type=3/4` остаются в старом формате `SHiNE_dm2`: + +1. `toLoginLen` (`u8`) + `toLogin` +2. `fromLoginLen` (`u8`) + `fromLogin` +3. `timeMs` (`u64`) +4. `nonce` (`u32`) +5. `messageType` (`u16`) — `3` или `4` +6. `payloadLen` (`u16`) +7. `payloadBytes` +8. `signature` + +Это временный совместимый слой. Контентные сообщения `1/2` в `SHiNE_dm2` больше не считаются актуальным форматом. + +--- + +## 4) Внешний контейнер вложений + +Внешняя часть сообщения содержит только технические ссылки на blob-файлы: + +- `attachmentsCount` +- список `encFileHashSHA256 + encFileSize` + +Во внешней части **нет**: + +- имени файла; +- MIME; +- пароля/ключа; +- nonce/iv; +- `origHash`. + +Это позволяет серверу хранить и отдавать blob, не зная человеческих метаданных вложения. + +--- + +## 5) Внутреннее содержимое `encryptedBody` + +Сейчас `encryptedBody` содержит текстовый контейнер сообщения, который UI интерпретирует как текст + встроенные метки файлов. + +Формат маркера: + +```text +<> +``` + +Где: + +- `type` = `photo` / `video` / `audio` / `file` +- `fileName` — настоящее имя файла, без символов `|`, `:`, `>`, переводов строки +- `origSize` — размер исходного файла +- `origHashB64u` — `SHA-256` исходного файла в `base64url` +- `encHashB64u` — `SHA-256` ciphertext-файла в `base64url` +- `encSize` — размер ciphertext-файла +- `keyB64u` — симметричный ключ расшифровки файла +- `nonceB64u` — nonce/iv для расшифровки файла + +UI: + +- показывает обычный текст без маркеров; +- заменяет маркеры карточками скачивания; +- скачивает ciphertext по `/f/`; +- локально расшифровывает файл и отдаёт пользователю оригинал. + +--- + +## 6) RPC и события + +### `SendMessagePair` / `ReceiveOutcomingMessage` + +Запрос не меняется: сервер по-прежнему принимает пару `incomingBlobB64` + `outgoingBlobB64`. ```json { @@ -113,157 +175,190 @@ UI чата строится на этих типах: текстовые соо } ``` -## `SignedMessageArrived` (server event) +### `SignedMessageArrived` -Событие в сессию получателя содержит: +Событие в сессию всё ещё содержит: -- `messageKey`, `baseKey`; -- `fromLogin`, `toLogin`, `targetLogin`; -- `messageType`, `timeMs`, `nonce`; -- `blobB64`; -- `backlog` (признак догрузки из очереди). +- `messageKey`, `baseKey` +- `fromLogin`, `toLogin`, `targetLogin` +- `messageType`, `timeMs`, `nonce` +- `blobB64` +- `backlog` -## `AckSessionDelivery` +Новая версия того же письма приходит с тем же `messageKey`, но с более новым `revisionTimeMs` внутри бинарного блока. -Запрос: +### `AckSessionDelivery` -```json -{ - "op": "AckSessionDelivery", - "requestId": "ack-1", - "payload": { - "messageKey": "from|to|time|nonce|1" - } -} -``` - -Ответ: `status=200`, echo `messageKey`. - -## 6) Хранение на сервере (SQLite) - -Основные таблицы: - -1. `signed_messages_v2` — сами DM-блоки типов `1/2/3/4`: - - `message_key` (PK), - - `base_key`, - - `target_login`, - - `from_login`, `to_login`, - - `time_ms`, `nonce`, `message_type`, - - `raw_block`, - - `source_api`, `origin_session_id`, - - `receipt_ref_base_key`, `receipt_ref_type`. -2. `signed_message_session_delivery` — доставка по сессиям: - - составной PK `(message_key, session_id)`, - - `delivered` (0/1), - - `delivered_at_ms`, `created_at_ms`. - -Примечание: историческая таблица `signed_direct_messages_history` в БД присутствует как legacy-слой, но текущий рабочий поток DM v2 опирается на `signed_messages_v2` + `signed_message_session_delivery`. - -## 7) Доставка и backlog - -- При сохранении пары сервер пытается сразу доставить в онлайн-сессии. -- Для офлайн/недоступных сессий остаётся pending-запись доставки в таблице `signed_message_session_delivery`. -- При подключении сессии сервер автоматически вызывает `dispatchPendingForSession`: - - для новой сессии регистрирует все существующие сообщения адресата как «недоставленные»; - - отправляет **все** pending через WebSocket событием `SignedMessageArrived(backlog=true)`; - - лимита на количество сообщений нет — передаётся вся история без ограничений. -- Клиент дедублирует входящие через `knownMessageKeys`: если `messageKey` уже есть локально — игнорирует. -- После получения клиент отправляет `AckSessionDelivery`, чтобы отметить `delivered=1` в таблице доставки. - -## 8) Read-receipt логика - -Когда клиент открывает чат: - -1. ищет входящие `messageType=1` без `readReceiptSent`; -2. для каждого отправляет read-receipt как пару `type=3/4`; -3. после успешной отправки помечает `readReceiptSent`. - -Сервер для read-receipt хранит ссылку на исходное сообщение: - -- `receipt_ref_base_key`; -- `receipt_ref_type`. - -Есть уникальность, чтобы не плодить дубликаты receipt на один и тот же `baseKey` для одного `target_login`. - -## 9) Логика UI-клиента - -### Хранилище сообщений - -- In-memory: `state.chats[chatId]` — массив сообщений по каждому диалогу. -- Персистентно: IndexedDB база `shine-ui-messages-v1`, object store `messages`, ключ `messageKey`. -- `chatId` для `type=1` — `fromLogin`, для `type=2` — `toLogin`. - -### Жизненный цикл при старте/подключении - -1. `hydrateMessagesFromStore()` — читает все сообщения из IndexedDB в `state.chats` (до WebSocket-соединения). -2. После установки WebSocket-сессии сервер присылает backlog (`SignedMessageArrived(backlog=true)`) для всех недоставленных сообщений. -3. Клиент дедублирует через `knownMessageKeys` — уже имеющиеся в IndexedDB игнорируются. -4. Новые сообщения в реальном времени приходят теми же WebSocket-событиями. - -### Очистка при выходе и смене пользователя - -- При любом логауте (`terminateCurrentSession`) IndexedDB с сообщениями **удаляется полностью**. -- При входе нового пользователя через QR — IndexedDB удаляется явно до вызова `terminateCurrentSession`. -- При входе нового пользователя через логин/пароль — IndexedDB удаляется в `registration-keys-view.js` прямо перед `authorizeSession()`. -- Это гарантирует: при любом способе входа старые сообщения предыдущего пользователя не попадут к следующему. - -### UI-поведение - -- непрочитанные считаются по `from='in' && unread=true`; -- доставка/прочтение исходящих: - - `firstTick` — сообщение принято сервером, - - `secondTick` — пришло подтверждение прочтения; -- при открытии диалога UI автопрокручивает ленту в самый низ; -- после отправки нового сообщения UI сразу прокручивает ленту вниз. - -## 10) Синхронизация личных сообщений между серверами - -Когда пользователи зарегистрированы на разных серверах SHiNE, серверы должны синхронизировать DM между собой. - -### Общий принцип - -- Сервер A получает DM-блок, адресованный пользователю на сервере B. -- Сервер A пересылает этот блок серверу B (межсерверный relay). -- Сервер B сохраняет блок и доставляет его в активные сессии получателя. -- Серверы, между которыми идёт синхронизация, задаются списком `sync_servers` в PDA пользователя-сервера. - -### Что синхронизируется - -- Все DM-блоки типов `1/2` (текстовые сообщения) и `3/4` (read-receipt). -- Синхронизация двусторонняя: оба сервера должны уметь принимать и пересылать блоки. - -### Идемпотентность - -- Блоки имеют уникальный `message_key` (`from|to|timeMs|nonce|type`). -- Повторная доставка одного и того же блока безопасна — дедупликация происходит по `message_key`. - -### Статус реализации - -Межсерверная синхронизация DM **пока не реализована**. Текущая версия работает только в рамках одного сервера. Это задача для следующего этапа. +Формат не меняется. --- -## 11) Инварианты (обязательно соблюдать при доработках) +## 7) HTTP endpoints для файлов -1. Пара блоков (1/2 или 3/4) должна оставаться атомарной. -2. `messageKey`/`baseKey` формат должен быть совместим с текущей логикой дедупликации и receipt. -3. Доставка должна оставаться **по сессиям** с явным `AckSessionDelivery`. -4. Read-receipt не должен отправляться многократно на один и тот же `baseKey`. -5. Любые изменения DM-логики в коде должны сразу отражаться в этом документе. +### `HEAD /f/` -## 12) Ключевые файлы реализации +Проверяет наличие ciphertext-файла. + +- `200` — файл есть; +- `404` — файла нет. + +### `GET /f/` + +Отдаёт ciphertext-файл как `application/octet-stream`. + +Сейчас доступ публичный, без проверки логина. + +### `POST /upload?hash=&size=` + +Принимает raw bytes ciphertext-файла. + +Сервер: + +- пересчитывает `SHA-256`; +- проверяет размер; +- сохраняет blob в папку `f/` под именем ``; +- если файл уже существует, повторно не пишет его на диск; +- регистрирует строку в `dm_files`. + +--- + +## 8) Хранение на сервере (SQLite) + +### `signed_messages_v2` + +Основная таблица текущих DM: + +- `message_key` (PK) +- `base_key` +- `target_login` +- `from_login`, `to_login` +- `time_ms`, `nonce` +- `message_type` +- `revision_time_ms` +- `raw_block` +- `created_at_ms` +- `source_api`, `origin_session_id` +- `receipt_ref_base_key`, `receipt_ref_type` + +Для контентных сообщений сервер делает `upsert` по `message_key`, поэтому в таблице всегда лежит только последняя версия конкретной стороны пары. + +### `signed_message_session_delivery` + +Хранит pending/ack по сессиям: + +- `(message_key, session_id)` — PK +- `delivered` +- `delivered_at_ms` +- `created_at_ms` + +При новой ревизии того же сообщения сервер сбрасывает доставку этого `message_key` обратно в `delivered=0`. + +### `dm_files` + +- `file_hash_sha256` (`BLOB`, PK) +- `file_size` +- `ref_count` + +### `dm_message_file_links` + +- `message_key` +- `login` +- `file_hash_sha256` + +По этой таблице сервер понимает, какие файловые ссылки нужно снять при редактировании/удалении сообщения. + +`ref_count` считается по числу логических message-side ссылок: + +- у одного письма с вложением обычно две ссылки: + - получатель (`type=1`) + - отправитель (`type=2`) + +Файлы с `ref_count = 0` на диске не удаляются автоматически. + +--- + +## 9) Доставка, редактирование и удаление + +### Новое сообщение + +- `revisionTimeMs = 0` +- создаётся пара `1/2` +- сервер делает upsert, создаёт файловые связи и доставляет событие + +### Редактирование + +- используется тот же `timeMs + nonce` +- отправляется новая пара `1/2` +- `revisionTimeMs` больше +- может измениться и `encryptedBody`, и список вложений + +### Удаление + +- тот же `timeMs + nonce` +- новая ревизия с большим `revisionTimeMs` +- `attachmentsCount = 0` +- `encryptedBodyLen = 0` + +UI такое сообщение полностью убирает из чата. + +--- + +## 10) Логика UI-клиента + +### Хранилище сообщений + +- in-memory: `state.chats[chatId]` +- IndexedDB: `shine-ui-messages-v1`, store `messages`, key = `messageKey` + +Так как `messageKey` теперь стабилен для всех ревизий одного message-side, клиент делает не append, а update той же записи. + +### Поведение UI + +- входящая новая ревизия с тем же `messageKey` обновляет существующий пузырь; +- пустая ревизия (`attachments=0`, `encryptedBodyLen=0`) удаляет пузырь из IndexedDB и из in-memory; +- вложения показываются кнопками скачивания; +- ciphertext скачивается с HTTP, затем расшифровывается локально в браузере. + +--- + +## 11) Межсерверная синхронизация + +Межсерверный relay DM пока не реализован. + +Когда он появится, серверы должны будут: + +- синхронизировать только актуальную ревизию `message_key`; +- применять правило "больший `revisionTimeMs` побеждает"; +- одинаково пересчитывать файловые связи и `ref_count`. + +--- + +## 12) Инварианты + +1. Пара `1/2` должна применяться атомарно. +2. `baseKey/messageKey` формат не меняется. +3. Для одного `messageKey` в `signed_messages_v2` хранится только последняя версия. +4. Все изменения DM и файловых связей применяются одной транзакцией. +5. Для контентных сообщений обязательна предварительная загрузка blob-файлов. +6. Для одного сообщения разрешено не больше `12` вложений. +7. UI не показывает удалённые сообщения. + +--- + +## 13) Ключевые файлы реализации - UI: - `shine-UI/js/services/auth-service.js` - - `shine-UI/js/app.js` + - `shine-UI/js/services/crypto-utils.js` - `shine-UI/js/state.js` + - `shine-UI/js/app.js` - `shine-UI/js/pages/chat-view.js` -- Сервер: - - `shine-server-net-protocol/.../messages/SignedMessageBlock.java` - - `shine-server-net-protocol/.../messages/SignedMessagesCore.java` - - `shine-server-net-protocol/.../messages/Net_SendMessagePair_Handler.java` - - `shine-server-net-protocol/.../messages/SignedMessagesRealtime.java` - - `shine-server-net-protocol/.../messages/Net_AckSessionDelivery_Handler.java` -- БД: - - `shine-server-db/src/main/java/shine/db/DatabaseInitializer.java` - - `shine-server-db/src/main/java/shine/db/dao/SignedMessagesV2DAO.java` +- Server: + - `SHiNE-server/shine-server-net-protocol/.../messages/SignedMessageBlock.java` + - `SHiNE-server/shine-server-net-protocol/.../messages/SignedMessagesCore.java` + - `SHiNE-server/shine-server-net-protocol/.../messages/Net_SendMessagePair_Handler.java` + - `SHiNE-server/shine-server-net-protocol/.../messages/SignedMessagesRealtime.java` + - `SHiNE-server/shine-server-net-protocol/.../messages/DmFileStorage.java` + - `SHiNE-server/shine-server-db/.../dao/SignedMessagesV2DAO.java` + - `SHiNE-server/src/main/java/server/files/DmFilesServlet.java` + - `SHiNE-server/src/main/java/server/files/DmUploadServlet.java` diff --git a/SHiNE-server/shine-server-db/src/main/java/shine/db/DatabaseInitializer.java b/SHiNE-server/shine-server-db/src/main/java/shine/db/DatabaseInitializer.java index a3f210c..efac569 100644 --- a/SHiNE-server/shine-server-db/src/main/java/shine/db/DatabaseInitializer.java +++ b/SHiNE-server/shine-server-db/src/main/java/shine/db/DatabaseInitializer.java @@ -618,6 +618,7 @@ public final class DatabaseInitializer { time_ms INTEGER NOT NULL, nonce INTEGER NOT NULL, message_type INTEGER NOT NULL, + revision_time_ms INTEGER NOT NULL DEFAULT 0, raw_block BLOB NOT NULL, created_at_ms INTEGER NOT NULL, source_api TEXT NOT NULL, @@ -639,6 +640,36 @@ public final class DatabaseInitializer { ON signed_messages_v2 (base_key, message_type); """); + st.executeUpdate(""" + CREATE TABLE IF NOT EXISTS dm_files ( + file_hash_sha256 BLOB NOT NULL PRIMARY KEY, + file_size INTEGER NOT NULL, + ref_count INTEGER NOT NULL DEFAULT 0 + ); + """); + + st.executeUpdate(""" + CREATE TABLE IF NOT EXISTS dm_message_file_links ( + message_key TEXT NOT NULL, + login TEXT NOT NULL, + file_hash_sha256 BLOB NOT NULL, + PRIMARY KEY (message_key, login, file_hash_sha256), + FOREIGN KEY (message_key) REFERENCES signed_messages_v2(message_key), + FOREIGN KEY (login) REFERENCES solana_users(login), + FOREIGN KEY (file_hash_sha256) REFERENCES dm_files(file_hash_sha256) + ); + """); + + st.executeUpdate(""" + CREATE INDEX IF NOT EXISTS idx_dm_message_file_links_login + ON dm_message_file_links (login, file_hash_sha256); + """); + + st.executeUpdate(""" + CREATE INDEX IF NOT EXISTS idx_dm_message_file_links_message + ON dm_message_file_links (message_key); + """); + st.executeUpdate(""" CREATE UNIQUE INDEX IF NOT EXISTS uq_signed_messages_v2_receipt_incoming ON signed_messages_v2 (target_login, receipt_ref_base_key) diff --git a/SHiNE-server/shine-server-db/src/main/java/shine/db/SqliteDbController.java b/SHiNE-server/shine-server-db/src/main/java/shine/db/SqliteDbController.java index 35bda33..32ec640 100644 --- a/SHiNE-server/shine-server-db/src/main/java/shine/db/SqliteDbController.java +++ b/SHiNE-server/shine-server-db/src/main/java/shine/db/SqliteDbController.java @@ -14,7 +14,7 @@ import java.sql.Statement; public final class SqliteDbController { private static volatile SqliteDbController instance; - private static final int LATEST_SCHEMA_VERSION = 5; + private static final int LATEST_SCHEMA_VERSION = 6; private final String jdbcUrl; @@ -88,6 +88,7 @@ public final class SqliteDbController { case 3 -> migrateToV3(); case 4 -> migrateToV4(); case 5 -> migrateToV5(); + case 6 -> migrateToV6(); default -> throw new RuntimeException("Unknown DB migration target version: " + targetVersion); } } @@ -209,6 +210,26 @@ public final class SqliteDbController { } } + private void migrateToV6() { + try (Connection c = DriverManager.getConnection(jdbcUrl); + Statement st = c.createStatement()) { + c.setAutoCommit(false); + try { + ensureSignedMessagesRevisionColumn(c, st); + ensureDmFileTables(st); + setSchemaVersion(c, 6); + c.commit(); + } catch (Exception e) { + try { c.rollback(); } catch (Exception ignored) {} + throw new RuntimeException("DB migration to v6 failed", e); + } finally { + try { c.setAutoCommit(true); } catch (Exception ignored) {} + } + } catch (SQLException e) { + throw new RuntimeException("DB migration to v6 failed", e); + } + } + private static void ensureChat200StateTables(Statement st) throws SQLException { st.executeUpdate(""" CREATE TABLE IF NOT EXISTS chat200_state ( @@ -329,6 +350,45 @@ public final class SqliteDbController { """); } + private static void ensureSignedMessagesRevisionColumn(Connection c, Statement st) throws SQLException { + if (!tableExists(c, "signed_messages_v2")) return; + if (!columnExists(c, "signed_messages_v2", "revision_time_ms")) { + st.executeUpdate("ALTER TABLE signed_messages_v2 ADD COLUMN revision_time_ms INTEGER NOT NULL DEFAULT 0"); + } + } + + private static void ensureDmFileTables(Statement st) throws SQLException { + st.executeUpdate(""" + CREATE TABLE IF NOT EXISTS dm_files ( + file_hash_sha256 BLOB NOT NULL PRIMARY KEY, + file_size INTEGER NOT NULL, + ref_count INTEGER NOT NULL DEFAULT 0 + ); + """); + + st.executeUpdate(""" + CREATE TABLE IF NOT EXISTS dm_message_file_links ( + message_key TEXT NOT NULL, + login TEXT NOT NULL, + file_hash_sha256 BLOB NOT NULL, + PRIMARY KEY (message_key, login, file_hash_sha256), + FOREIGN KEY (message_key) REFERENCES signed_messages_v2(message_key), + FOREIGN KEY (login) REFERENCES solana_users(login), + FOREIGN KEY (file_hash_sha256) REFERENCES dm_files(file_hash_sha256) + ); + """); + + st.executeUpdate(""" + CREATE INDEX IF NOT EXISTS idx_dm_message_file_links_login + ON dm_message_file_links (login, file_hash_sha256); + """); + + st.executeUpdate(""" + CREATE INDEX IF NOT EXISTS idx_dm_message_file_links_message + ON dm_message_file_links (message_key); + """); + } + private static boolean columnExists(Connection c, String tableName, String columnName) throws SQLException { try (Statement probe = c.createStatement(); ResultSet rs = probe.executeQuery("PRAGMA table_info(" + tableName + ")")) { diff --git a/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/SignedMessagesV2DAO.java b/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/SignedMessagesV2DAO.java index f0a46e6..595916f 100644 --- a/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/SignedMessagesV2DAO.java +++ b/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/SignedMessagesV2DAO.java @@ -1,6 +1,7 @@ package shine.db.dao; import shine.db.SqliteDbController; +import shine.db.entities.DmFileRef; import shine.db.entities.SignedMessageV2Entry; import java.sql.Connection; @@ -8,7 +9,10 @@ import java.sql.PreparedStatement; import java.sql.ResultSet; import java.sql.SQLException; import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashSet; import java.util.List; +import java.util.Set; public final class SignedMessagesV2DAO { private static volatile SignedMessagesV2DAO instance; @@ -30,35 +34,19 @@ public final class SignedMessagesV2DAO { String sql = """ INSERT OR IGNORE INTO signed_messages_v2 ( message_key, base_key, target_login, from_login, to_login, - time_ms, nonce, message_type, raw_block, created_at_ms, + time_ms, nonce, message_type, revision_time_ms, raw_block, created_at_ms, source_api, origin_session_id, receipt_ref_base_key, receipt_ref_type - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) """; try (PreparedStatement ps = c.prepareStatement(sql)) { - ps.setString(1, e.getMessageKey()); - ps.setString(2, e.getBaseKey()); - ps.setString(3, e.getTargetLogin()); - ps.setString(4, e.getFromLogin()); - ps.setString(5, e.getToLogin()); - ps.setLong(6, e.getTimeMs()); - ps.setLong(7, e.getNonce()); - ps.setInt(8, e.getMessageType()); - ps.setBytes(9, e.getRawBlock()); - ps.setLong(10, e.getCreatedAtMs()); - ps.setString(11, e.getSourceApi()); - ps.setString(12, e.getOriginSessionId()); - ps.setString(13, e.getReceiptRefBaseKey()); - if (e.getReceiptRefType() == null) ps.setObject(14, null); - else ps.setInt(14, e.getReceiptRefType()); + bindSignedMessage(ps, e); return ps.executeUpdate() > 0; } } } /** - * Атомарная вставка пары блоков: либо вставляются оба, либо не вставляется ни один. - * Возвращает true только если обе записи добавлены в БД. - * Если хотя бы одна запись уже существует (или конфликтует по уникальности), возвращает false. + * Атомарная вставка пары блоков legacy/read-receipt: либо вставляются оба, либо не вставляется ни один. */ public boolean insertPairBothOrNothing(SignedMessageV2Entry first, SignedMessageV2Entry second) throws Exception { try (Connection c = db.getConnection()) { @@ -85,37 +73,97 @@ public final class SignedMessagesV2DAO { } } - private int insertStrict(Connection c, SignedMessageV2Entry e) throws SQLException { - String sql = """ - INSERT INTO signed_messages_v2 ( - message_key, base_key, target_login, from_login, to_login, - time_ms, nonce, message_type, raw_block, created_at_ms, - source_api, origin_session_id, receipt_ref_base_key, receipt_ref_type - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - """; - try (PreparedStatement ps = c.prepareStatement(sql)) { - ps.setString(1, e.getMessageKey()); - ps.setString(2, e.getBaseKey()); - ps.setString(3, e.getTargetLogin()); - ps.setString(4, e.getFromLogin()); - ps.setString(5, e.getToLogin()); - ps.setLong(6, e.getTimeMs()); - ps.setLong(7, e.getNonce()); - ps.setInt(8, e.getMessageType()); - ps.setBytes(9, e.getRawBlock()); - ps.setLong(10, e.getCreatedAtMs()); - ps.setString(11, e.getSourceApi()); - ps.setString(12, e.getOriginSessionId()); - ps.setString(13, e.getReceiptRefBaseKey()); - if (e.getReceiptRefType() == null) ps.setObject(14, null); - else ps.setInt(14, e.getReceiptRefType()); - return ps.executeUpdate(); + /** + * Атомарный upsert пары контентных DM с полной заменой файловых связей. + * Возвращает true, только если ревизия применена. Более старая или идентичная ревизия игнорируется. + */ + public boolean upsertContentPairReplaceFiles( + SignedMessageV2Entry incoming, + List incomingFiles, + SignedMessageV2Entry outgoing, + List outgoingFiles + ) throws Exception { + try (Connection c = db.getConnection()) { + boolean prevAutoCommit = c.getAutoCommit(); + c.setAutoCommit(false); + try { + if (!allFilesExist(c, incomingFiles) || !allFilesExist(c, outgoingFiles)) { + c.rollback(); + return false; + } + + Long currentIncomingRevision = getRevisionTimeMs(c, incoming.getMessageKey()); + Long currentOutgoingRevision = getRevisionTimeMs(c, outgoing.getMessageKey()); + long currentRevision = Math.max( + currentIncomingRevision != null ? currentIncomingRevision : Long.MIN_VALUE, + currentOutgoingRevision != null ? currentOutgoingRevision : Long.MIN_VALUE + ); + long nextRevision = incoming.getRevisionTimeMs(); + + if (currentRevision != Long.MIN_VALUE && nextRevision < currentRevision) { + c.rollback(); + return false; + } + if (currentRevision != Long.MIN_VALUE + && nextRevision == currentRevision + && hasSameRawBlock(c, incoming) + && hasSameRawBlock(c, outgoing)) { + c.rollback(); + return false; + } + + replaceFileLinks(c, incoming.getMessageKey(), incoming.getTargetLogin(), incomingFiles); + replaceFileLinks(c, outgoing.getMessageKey(), outgoing.getTargetLogin(), outgoingFiles); + + upsertMessage(c, incoming); + upsertMessage(c, outgoing); + + resetDeliveryRows(c, incoming.getMessageKey()); + resetDeliveryRows(c, outgoing.getMessageKey()); + + c.commit(); + return true; + } catch (Exception ex) { + try { c.rollback(); } catch (Exception ignored) {} + throw ex; + } finally { + c.setAutoCommit(prevAutoCommit); + } } } - private boolean isConstraintViolation(SQLException ex) { - String msg = String.valueOf(ex.getMessage()).toLowerCase(); - return msg.contains("constraint") || msg.contains("unique") || msg.contains("primary key"); + public boolean fileExists(byte[] fileHash, long fileSize) throws Exception { + try (Connection c = db.getConnection()) { + return fileExists(c, fileHash, fileSize); + } + } + + public boolean fileExistsByHash(byte[] fileHash) throws Exception { + try (Connection c = db.getConnection()) { + String sql = "SELECT 1 FROM dm_files WHERE file_hash_sha256 = ? LIMIT 1"; + try (PreparedStatement ps = c.prepareStatement(sql)) { + ps.setBytes(1, fileHash); + try (ResultSet rs = ps.executeQuery()) { + return rs.next(); + } + } + } + } + + public void registerFileIfAbsent(byte[] fileHash, long fileSize) throws Exception { + try (Connection c = db.getConnection()) { + String sql = """ + INSERT INTO dm_files (file_hash_sha256, file_size, ref_count) + VALUES (?, ?, 0) + ON CONFLICT(file_hash_sha256) DO UPDATE SET + file_size = excluded.file_size + """; + try (PreparedStatement ps = c.prepareStatement(sql)) { + ps.setBytes(1, fileHash); + ps.setLong(2, fileSize); + ps.executeUpdate(); + } + } } public SignedMessageV2Entry getByMessageKey(String messageKey) throws Exception { @@ -123,7 +171,7 @@ public final class SignedMessagesV2DAO { String sql = """ SELECT message_key, base_key, target_login, from_login, to_login, - time_ms, nonce, message_type, raw_block, created_at_ms, + time_ms, nonce, message_type, revision_time_ms, raw_block, created_at_ms, source_api, origin_session_id, receipt_ref_base_key, receipt_ref_type FROM signed_messages_v2 WHERE message_key = ? @@ -203,13 +251,13 @@ public final class SignedMessagesV2DAO { String sql = """ SELECT m.message_key, m.base_key, m.target_login, m.from_login, m.to_login, - m.time_ms, m.nonce, m.message_type, m.raw_block, m.created_at_ms, + m.time_ms, m.nonce, m.message_type, m.revision_time_ms, m.raw_block, m.created_at_ms, m.source_api, m.origin_session_id, m.receipt_ref_base_key, m.receipt_ref_type FROM signed_messages_v2 m JOIN signed_message_session_delivery d ON d.message_key = m.message_key WHERE d.session_id = ? AND d.delivered = 0 - ORDER BY m.time_ms ASC, m.created_at_ms ASC + ORDER BY m.time_ms ASC, m.revision_time_ms ASC, m.created_at_ms ASC """; List out = new ArrayList<>(); try (PreparedStatement ps = c.prepareStatement(sql)) { @@ -222,6 +270,206 @@ public final class SignedMessagesV2DAO { } } + private void upsertMessage(Connection c, SignedMessageV2Entry e) throws SQLException { + String sql = """ + INSERT INTO signed_messages_v2 ( + message_key, base_key, target_login, from_login, to_login, + time_ms, nonce, message_type, revision_time_ms, raw_block, created_at_ms, + source_api, origin_session_id, receipt_ref_base_key, receipt_ref_type + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(message_key) DO UPDATE SET + base_key = excluded.base_key, + target_login = excluded.target_login, + from_login = excluded.from_login, + to_login = excluded.to_login, + time_ms = excluded.time_ms, + nonce = excluded.nonce, + message_type = excluded.message_type, + revision_time_ms = excluded.revision_time_ms, + raw_block = excluded.raw_block, + created_at_ms = excluded.created_at_ms, + source_api = excluded.source_api, + origin_session_id = excluded.origin_session_id, + receipt_ref_base_key = excluded.receipt_ref_base_key, + receipt_ref_type = excluded.receipt_ref_type + """; + try (PreparedStatement ps = c.prepareStatement(sql)) { + bindSignedMessage(ps, e); + ps.executeUpdate(); + } + } + + private void replaceFileLinks(Connection c, String messageKey, String login, List nextFiles) throws SQLException { + List oldHashes = listLinkedFileHashes(c, messageKey, login); + for (byte[] oldHash : oldHashes) { + adjustRefCount(c, oldHash, -1); + } + + try (PreparedStatement ps = c.prepareStatement(""" + DELETE FROM dm_message_file_links + WHERE message_key = ? AND login = ? COLLATE NOCASE + """)) { + ps.setString(1, messageKey); + ps.setString(2, login); + ps.executeUpdate(); + } + + if (nextFiles == null || nextFiles.isEmpty()) return; + + Set dedup = new HashSet<>(); + for (DmFileRef ref : nextFiles) { + if (ref == null || ref.getFileHash() == null) continue; + String dedupKey = Arrays.toString(ref.getFileHash()); + if (!dedup.add(dedupKey)) continue; + + try (PreparedStatement ps = c.prepareStatement(""" + INSERT OR IGNORE INTO dm_message_file_links ( + message_key, login, file_hash_sha256 + ) VALUES (?, ?, ?) + """)) { + ps.setString(1, messageKey); + ps.setString(2, login); + ps.setBytes(3, ref.getFileHash()); + int inserted = ps.executeUpdate(); + if (inserted > 0) { + adjustRefCount(c, ref.getFileHash(), 1); + } + } + } + } + + private List listLinkedFileHashes(Connection c, String messageKey, String login) throws SQLException { + String sql = """ + SELECT file_hash_sha256 + FROM dm_message_file_links + WHERE message_key = ? AND login = ? COLLATE NOCASE + """; + List out = new ArrayList<>(); + try (PreparedStatement ps = c.prepareStatement(sql)) { + ps.setString(1, messageKey); + ps.setString(2, login); + try (ResultSet rs = ps.executeQuery()) { + while (rs.next()) { + out.add(rs.getBytes(1)); + } + } + } + return out; + } + + private void adjustRefCount(Connection c, byte[] fileHash, int delta) throws SQLException { + String sql = """ + UPDATE dm_files + SET ref_count = CASE + WHEN ref_count + ? < 0 THEN 0 + ELSE ref_count + ? + END + WHERE file_hash_sha256 = ? + """; + try (PreparedStatement ps = c.prepareStatement(sql)) { + ps.setInt(1, delta); + ps.setInt(2, delta); + ps.setBytes(3, fileHash); + ps.executeUpdate(); + } + } + + private boolean allFilesExist(Connection c, List refs) throws SQLException { + if (refs == null) return true; + for (DmFileRef ref : refs) { + if (ref == null || ref.getFileHash() == null) return false; + if (!fileExists(c, ref.getFileHash(), ref.getFileSize())) return false; + } + return true; + } + + private boolean fileExists(Connection c, byte[] fileHash, long fileSize) throws SQLException { + String sql = """ + SELECT 1 + FROM dm_files + WHERE file_hash_sha256 = ? AND file_size = ? + LIMIT 1 + """; + try (PreparedStatement ps = c.prepareStatement(sql)) { + ps.setBytes(1, fileHash); + ps.setLong(2, fileSize); + try (ResultSet rs = ps.executeQuery()) { + return rs.next(); + } + } + } + + private Long getRevisionTimeMs(Connection c, String messageKey) throws SQLException { + String sql = "SELECT revision_time_ms FROM signed_messages_v2 WHERE message_key = ? LIMIT 1"; + try (PreparedStatement ps = c.prepareStatement(sql)) { + ps.setString(1, messageKey); + try (ResultSet rs = ps.executeQuery()) { + if (!rs.next()) return null; + return rs.getLong(1); + } + } + } + + private boolean hasSameRawBlock(Connection c, SignedMessageV2Entry entry) throws SQLException { + String sql = "SELECT raw_block FROM signed_messages_v2 WHERE message_key = ? LIMIT 1"; + try (PreparedStatement ps = c.prepareStatement(sql)) { + ps.setString(1, entry.getMessageKey()); + try (ResultSet rs = ps.executeQuery()) { + if (!rs.next()) return false; + return Arrays.equals(rs.getBytes(1), entry.getRawBlock()); + } + } + } + + private void resetDeliveryRows(Connection c, String messageKey) throws SQLException { + try (PreparedStatement ps = c.prepareStatement(""" + UPDATE signed_message_session_delivery + SET delivered = 0, delivered_at_ms = NULL + WHERE message_key = ? + """)) { + ps.setString(1, messageKey); + ps.executeUpdate(); + } + } + + private int insertStrict(Connection c, SignedMessageV2Entry e) throws SQLException { + String sql = """ + INSERT INTO signed_messages_v2 ( + message_key, base_key, target_login, from_login, to_login, + time_ms, nonce, message_type, revision_time_ms, raw_block, created_at_ms, + source_api, origin_session_id, receipt_ref_base_key, receipt_ref_type + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """; + try (PreparedStatement ps = c.prepareStatement(sql)) { + bindSignedMessage(ps, e); + return ps.executeUpdate(); + } + } + + private void bindSignedMessage(PreparedStatement ps, SignedMessageV2Entry e) throws SQLException { + ps.setString(1, e.getMessageKey()); + ps.setString(2, e.getBaseKey()); + ps.setString(3, e.getTargetLogin()); + ps.setString(4, e.getFromLogin()); + ps.setString(5, e.getToLogin()); + ps.setLong(6, e.getTimeMs()); + ps.setLong(7, e.getNonce()); + ps.setInt(8, e.getMessageType()); + ps.setLong(9, e.getRevisionTimeMs()); + ps.setBytes(10, e.getRawBlock()); + ps.setLong(11, e.getCreatedAtMs()); + ps.setString(12, e.getSourceApi()); + ps.setString(13, e.getOriginSessionId()); + ps.setString(14, e.getReceiptRefBaseKey()); + if (e.getReceiptRefType() == null) ps.setObject(15, null); + else ps.setInt(15, e.getReceiptRefType()); + } + + private boolean isConstraintViolation(SQLException ex) { + String msg = String.valueOf(ex.getMessage()).toLowerCase(); + return msg.contains("constraint") || msg.contains("unique") || msg.contains("primary key"); + } + private SignedMessageV2Entry mapRow(ResultSet rs) throws Exception { SignedMessageV2Entry e = new SignedMessageV2Entry(); e.setMessageKey(rs.getString("message_key")); @@ -232,6 +480,7 @@ public final class SignedMessagesV2DAO { e.setTimeMs(rs.getLong("time_ms")); e.setNonce(rs.getLong("nonce")); e.setMessageType(rs.getInt("message_type")); + e.setRevisionTimeMs(rs.getLong("revision_time_ms")); e.setRawBlock(rs.getBytes("raw_block")); e.setCreatedAtMs(rs.getLong("created_at_ms")); e.setSourceApi(rs.getString("source_api")); diff --git a/SHiNE-server/shine-server-db/src/main/java/shine/db/entities/DmFileRef.java b/SHiNE-server/shine-server-db/src/main/java/shine/db/entities/DmFileRef.java new file mode 100644 index 0000000..557d2de --- /dev/null +++ b/SHiNE-server/shine-server-db/src/main/java/shine/db/entities/DmFileRef.java @@ -0,0 +1,19 @@ +package shine.db.entities; + +public class DmFileRef { + private byte[] fileHash; + private long fileSize; + + public DmFileRef() { + } + + public DmFileRef(byte[] fileHash, long fileSize) { + this.fileHash = fileHash; + this.fileSize = fileSize; + } + + public byte[] getFileHash() { return fileHash; } + public void setFileHash(byte[] fileHash) { this.fileHash = fileHash; } + public long getFileSize() { return fileSize; } + public void setFileSize(long fileSize) { this.fileSize = fileSize; } +} diff --git a/SHiNE-server/shine-server-db/src/main/java/shine/db/entities/SignedMessageV2Entry.java b/SHiNE-server/shine-server-db/src/main/java/shine/db/entities/SignedMessageV2Entry.java index 5ded20b..59e86dc 100644 --- a/SHiNE-server/shine-server-db/src/main/java/shine/db/entities/SignedMessageV2Entry.java +++ b/SHiNE-server/shine-server-db/src/main/java/shine/db/entities/SignedMessageV2Entry.java @@ -9,6 +9,7 @@ public class SignedMessageV2Entry { private long timeMs; private long nonce; private int messageType; + private long revisionTimeMs; private byte[] rawBlock; private long createdAtMs; private String sourceApi; @@ -32,6 +33,8 @@ public class SignedMessageV2Entry { public void setNonce(long nonce) { this.nonce = nonce; } public int getMessageType() { return messageType; } public void setMessageType(int messageType) { this.messageType = messageType; } + public long getRevisionTimeMs() { return revisionTimeMs; } + public void setRevisionTimeMs(long revisionTimeMs) { this.revisionTimeMs = revisionTimeMs; } public byte[] getRawBlock() { return rawBlock; } public void setRawBlock(byte[] rawBlock) { this.rawBlock = rawBlock; } public long getCreatedAtMs() { return createdAtMs; } diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/DmFileStorage.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/DmFileStorage.java new file mode 100644 index 0000000..192f714 --- /dev/null +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/DmFileStorage.java @@ -0,0 +1,122 @@ +package server.logic.ws_protocol.JSON.messages; + +import utils.config.AppConfig; + +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.security.MessageDigest; +import java.util.Base64; + +public final class DmFileStorage { + private static final String CFG_FILES_DIR = "dm.files.dir"; + private static final String CFG_MAX_UPLOAD_BYTES = "dm.upload.maxBytes"; + private static final long DEFAULT_MAX_UPLOAD_BYTES = 100L * 1024L * 1024L; + + private DmFileStorage() { + } + + public static Path rootDir() { + String configured = AppConfig.getInstance().getStringOrEmpty(CFG_FILES_DIR).trim(); + if (configured.isEmpty()) configured = "f"; + return Path.of(configured).toAbsolutePath().normalize(); + } + + public static void ensureRootDir() throws IOException { + Files.createDirectories(rootDir()); + } + + public static long maxUploadBytes() { + String raw = AppConfig.getInstance().getStringOrEmpty(CFG_MAX_UPLOAD_BYTES).trim(); + if (raw.isEmpty()) return DEFAULT_MAX_UPLOAD_BYTES; + try { + long parsed = Long.parseLong(raw); + return parsed > 0 ? parsed : DEFAULT_MAX_UPLOAD_BYTES; + } catch (Exception ignored) { + return DEFAULT_MAX_UPLOAD_BYTES; + } + } + + public static Path resolvePathByHashB64Url(String hashB64Url) { + return rootDir().resolve(hashB64Url).normalize(); + } + + public static String hashToBase64Url(byte[] hashBytes) { + return Base64.getUrlEncoder().withoutPadding().encodeToString(hashBytes); + } + + public static byte[] base64UrlToHash(String value) { + try { + byte[] decoded = Base64.getUrlDecoder().decode(value); + if (decoded.length != 32) { + throw new IllegalArgumentException("BAD_HASH_LEN"); + } + return decoded; + } catch (IllegalArgumentException ex) { + throw new IllegalArgumentException("BAD_HASH"); + } + } + + public static StoreResult storeCiphertext(InputStream in, String expectedHashB64Url, long expectedSize) throws Exception { + if (expectedSize < 0) throw new IllegalArgumentException("BAD_SIZE"); + ensureRootDir(); + + byte[] expectedHash = base64UrlToHash(expectedHashB64Url); + Path target = resolvePathByHashB64Url(expectedHashB64Url); + if (Files.exists(target)) { + long existingSize = Files.size(target); + return new StoreResult(expectedHashB64Url, existingSize, true); + } + + long maxBytes = maxUploadBytes(); + Path tmp = Files.createTempFile(rootDir(), "upload-", ".tmp"); + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + long written = 0; + try (OutputStream out = Files.newOutputStream(tmp)) { + byte[] buf = new byte[8192]; + while (true) { + int read = in.read(buf); + if (read < 0) break; + if (read == 0) continue; + written += read; + if (written > maxBytes) { + throw new IllegalArgumentException("UPLOAD_TOO_LARGE"); + } + digest.update(buf, 0, read); + out.write(buf, 0, read); + } + } catch (Exception ex) { + try { Files.deleteIfExists(tmp); } catch (Exception ignored) {} + throw ex; + } + + if (written != expectedSize) { + Files.deleteIfExists(tmp); + throw new IllegalArgumentException("SIZE_MISMATCH"); + } + + byte[] actualHash = digest.digest(); + String actualHashB64Url = hashToBase64Url(actualHash); + if (!MessageDigest.isEqual(expectedHash, actualHash)) { + Files.deleteIfExists(tmp); + throw new IllegalArgumentException("HASH_MISMATCH"); + } + + try { + Files.move(tmp, target, StandardCopyOption.ATOMIC_MOVE); + return new StoreResult(actualHashB64Url, written, false); + } catch (IOException moveError) { + Files.deleteIfExists(tmp); + if (Files.exists(target)) { + return new StoreResult(actualHashB64Url, Files.size(target), true); + } + throw moveError; + } + } + + public record StoreResult(String hashB64Url, long sizeBytes, boolean alreadyExists) { + } +} diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/Net_SendMessagePair_Handler.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/Net_SendMessagePair_Handler.java index eb11a3b..d68d669 100644 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/Net_SendMessagePair_Handler.java +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/Net_SendMessagePair_Handler.java @@ -8,9 +8,12 @@ import server.logic.ws_protocol.JSON.messages.entyties.Net_SendMessagePair_Reque import server.logic.ws_protocol.JSON.messages.entyties.Net_SendMessagePair_Response; import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory; import server.logic.ws_protocol.WireCodes; +import shine.db.entities.DmFileRef; import shine.db.dao.SignedMessagesV2DAO; import shine.db.entities.SignedMessageV2Entry; +import java.util.List; + public class Net_SendMessagePair_Handler implements JsonMessageHandler { @Override public Net_Response handle(Net_Request baseRequest, ConnectionContext ctx) throws Exception { @@ -32,9 +35,13 @@ public class Net_SendMessagePair_Handler implements JsonMessageHandler { try { SignedMessagesCore.verifyUsersAndSignature(incoming); SignedMessagesCore.verifyUsersAndSignature(outgoing); + SignedMessagesCore.ensureAllFilesExist(incoming); + SignedMessagesCore.ensureAllFilesExist(outgoing); } catch (IllegalArgumentException ex) { String code = ex.getMessage(); - int status = "USER_NOT_FOUND".equals(code) ? 404 : WireCodes.Status.UNVERIFIED; + int status = "USER_NOT_FOUND".equals(code) + ? 404 + : ("ATTACHMENT_NOT_FOUND".equals(code) ? WireCodes.Status.BAD_REQUEST : WireCodes.Status.UNVERIFIED); return NetExceptionResponseFactory.error(req, status, code, "Сообщение не прошло проверку"); } @@ -49,11 +56,20 @@ public class Net_SendMessagePair_Handler implements JsonMessageHandler { return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, ex.getMessage(), "Некорректный payload подтверждения"); } - boolean pairInserted = SignedMessagesV2DAO.getInstance().insertPairBothOrNothing(incomingEntry, outgoingEntry); + boolean pairInserted; + if (incoming.isContentType()) { + List incomingFiles = SignedMessagesCore.attachmentRefs(incoming); + List outgoingFiles = SignedMessagesCore.attachmentRefs(outgoing); + pairInserted = SignedMessagesV2DAO.getInstance().upsertContentPairReplaceFiles( + incomingEntry, incomingFiles, outgoingEntry, outgoingFiles + ); + } else { + pairInserted = SignedMessagesV2DAO.getInstance().insertPairBothOrNothing(incomingEntry, outgoingEntry); + } SignedMessagesRealtime.DeliveryCounters inCounters = new SignedMessagesRealtime.DeliveryCounters(); if (pairInserted) { - inCounters = SignedMessagesRealtime.deliverToTargetSessions(incomingEntry, null); + inCounters = SignedMessagesRealtime.deliverToTargetSessions(incomingEntry, incoming); } String excludeSessionId = null; @@ -62,7 +78,7 @@ public class Net_SendMessagePair_Handler implements JsonMessageHandler { } SignedMessagesRealtime.DeliveryCounters outCounters = new SignedMessagesRealtime.DeliveryCounters(); if (pairInserted) { - outCounters = SignedMessagesRealtime.deliverToTargetSessions(outgoingEntry, excludeSessionId); + outCounters = SignedMessagesRealtime.deliverToTargetSessions(outgoingEntry, outgoing, excludeSessionId); } Net_SendMessagePair_Response resp = new Net_SendMessagePair_Response(); diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/SignedMessageBlock.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/SignedMessageBlock.java index 87967a4..dad1e42 100644 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/SignedMessageBlock.java +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/SignedMessageBlock.java @@ -1,26 +1,39 @@ package server.logic.ws_protocol.JSON.messages; +import shine.db.entities.DmFileRef; + import java.nio.ByteBuffer; import java.nio.ByteOrder; import java.nio.charset.StandardCharsets; +import java.util.ArrayList; import java.util.Arrays; +import java.util.Collections; +import java.util.List; final class SignedMessageBlock { - static final byte[] PREFIX = "SHiNE_dm2".getBytes(StandardCharsets.US_ASCII); + static final byte[] LEGACY_PREFIX = "SHiNE_dm2".getBytes(StandardCharsets.US_ASCII); + static final byte[] V1_PREFIX = "SHiNE_DM".getBytes(StandardCharsets.US_ASCII); static final int TYPE_INCOMING_TEXT = 1; static final int TYPE_OUTGOING_COPY = 2; static final int TYPE_READ_INCOMING = 3; static final int TYPE_READ_OUTGOING_COPY = 4; + static final int MAX_ATTACHMENTS = 12; final String toLogin; final String fromLogin; final long timeMs; final long nonce; final int messageType; + final long revisionTimeMs; + final int formatVersionMajor; + final int formatVersionMinor; final byte[] payloadBytes; + final byte[] encryptedBodyBytes; + final List attachments; final byte[] signedBody; final byte[] signature64; final byte[] rawPacket; + final boolean legacyFormat; private SignedMessageBlock( String toLogin, @@ -28,36 +41,54 @@ final class SignedMessageBlock { long timeMs, long nonce, int messageType, + long revisionTimeMs, + int formatVersionMajor, + int formatVersionMinor, byte[] payloadBytes, + byte[] encryptedBodyBytes, + List attachments, byte[] signedBody, byte[] signature64, - byte[] rawPacket + byte[] rawPacket, + boolean legacyFormat ) { this.toLogin = toLogin; this.fromLogin = fromLogin; this.timeMs = timeMs; this.nonce = nonce; this.messageType = messageType; + this.revisionTimeMs = revisionTimeMs; + this.formatVersionMajor = formatVersionMajor; + this.formatVersionMinor = formatVersionMinor; this.payloadBytes = payloadBytes; + this.encryptedBodyBytes = encryptedBodyBytes; + this.attachments = attachments; this.signedBody = signedBody; this.signature64 = signature64; this.rawPacket = rawPacket; + this.legacyFormat = legacyFormat; } - static SignedMessageBlock parse(byte[] raw, int maxPayloadBytes) { - if (raw == null || raw.length < PREFIX.length + 1 + 1 + 8 + 4 + 2 + 2 + 64) { + static SignedMessageBlock parse(byte[] raw, int maxEncryptedBodyBytes) { + if (raw == null || raw.length < 64) { throw new IllegalArgumentException("BAD_LEN"); } - if (raw.length > 8192) { - throw new IllegalArgumentException("PAYLOAD_TOO_LARGE"); - } - ByteBuffer bb = ByteBuffer.wrap(raw).order(ByteOrder.BIG_ENDIAN); - byte[] prefix = new byte[PREFIX.length]; - bb.get(prefix); - if (!Arrays.equals(prefix, PREFIX)) { - throw new IllegalArgumentException("BAD_PREFIX"); + if (startsWith(raw, LEGACY_PREFIX)) { + return parseLegacy(raw, maxEncryptedBodyBytes); } + if (startsWith(raw, V1_PREFIX)) { + return parseV1(raw, maxEncryptedBodyBytes); + } + throw new IllegalArgumentException("BAD_PREFIX"); + } + + private static SignedMessageBlock parseLegacy(byte[] raw, int maxPayloadBytes) { + if (raw.length < LEGACY_PREFIX.length + 1 + 1 + 8 + 4 + 2 + 2 + 64) { + throw new IllegalArgumentException("BAD_LEN"); + } + ByteBuffer bb = ByteBuffer.wrap(raw).order(ByteOrder.BIG_ENDIAN); + bb.position(LEGACY_PREFIX.length); String toLogin = readAscii(bb, 1, 60, "BAD_TO_LOGIN"); String fromLogin = readAscii(bb, 1, 60, "BAD_FROM_LOGIN"); @@ -67,9 +98,7 @@ final class SignedMessageBlock { long nonce = Integer.toUnsignedLong(bb.getInt()); int messageType = Short.toUnsignedInt(bb.getShort()); - if (messageType < TYPE_INCOMING_TEXT || messageType > TYPE_READ_OUTGOING_COPY) { - throw new IllegalArgumentException("BAD_MESSAGE_TYPE"); - } + ensureMessageType(messageType); int payloadLen = Short.toUnsignedInt(bb.getShort()); if (payloadLen < 1 || payloadLen > maxPayloadBytes) { @@ -86,7 +115,95 @@ final class SignedMessageBlock { byte[] signedBody = Arrays.copyOf(raw, raw.length - 64); return new SignedMessageBlock( - toLogin, fromLogin, timeMs, nonce, messageType, payload, signedBody, signature64, raw + toLogin, + fromLogin, + timeMs, + nonce, + messageType, + 0L, + 2, + 0, + payload, + payload, + List.of(), + signedBody, + signature64, + raw, + true + ); + } + + private static SignedMessageBlock parseV1(byte[] raw, int maxEncryptedBodyBytes) { + if (raw.length < V1_PREFIX.length + 2 + 1 + 1 + 8 + 4 + 2 + 8 + 1 + 4 + 64) { + throw new IllegalArgumentException("BAD_LEN"); + } + ByteBuffer bb = ByteBuffer.wrap(raw).order(ByteOrder.BIG_ENDIAN); + bb.position(V1_PREFIX.length); + + int major = Byte.toUnsignedInt(bb.get()); + int minor = Byte.toUnsignedInt(bb.get()); + if (major != 1 || minor != 0) { + throw new IllegalArgumentException("BAD_FORMAT_VERSION"); + } + + String toLogin = readAscii(bb, 1, 60, "BAD_TO_LOGIN"); + String fromLogin = readAscii(bb, 1, 60, "BAD_FROM_LOGIN"); + long timeMs = bb.getLong(); + if (timeMs < 0) throw new IllegalArgumentException("BAD_TIME"); + long nonce = Integer.toUnsignedLong(bb.getInt()); + int messageType = Short.toUnsignedInt(bb.getShort()); + ensureMessageType(messageType); + long revisionTimeMs = bb.getLong(); + if (revisionTimeMs < 0) throw new IllegalArgumentException("BAD_REVISION_TIME"); + + int attachmentsCount = Byte.toUnsignedInt(bb.get()); + if (attachmentsCount > MAX_ATTACHMENTS) { + throw new IllegalArgumentException("TOO_MANY_ATTACHMENTS"); + } + List attachments = new ArrayList<>(attachmentsCount); + for (int i = 0; i < attachmentsCount; i++) { + if (bb.remaining() < 32 + 8 + 4 + 64) { + throw new IllegalArgumentException("BAD_LEN"); + } + byte[] hash = new byte[32]; + bb.get(hash); + long size = bb.getLong(); + if (size < 0) throw new IllegalArgumentException("BAD_ATTACHMENT_SIZE"); + attachments.add(new DmFileRef(hash, size)); + } + + if (bb.remaining() < 4 + 64) { + throw new IllegalArgumentException("BAD_LEN"); + } + long encryptedBodyLen = Integer.toUnsignedLong(bb.getInt()); + if (encryptedBodyLen > maxEncryptedBodyBytes) { + throw new IllegalArgumentException("BAD_MESSAGE_LEN"); + } + if (bb.remaining() != encryptedBodyLen + 64) { + throw new IllegalArgumentException("BAD_LEN"); + } + byte[] encryptedBody = new byte[(int) encryptedBodyLen]; + bb.get(encryptedBody); + byte[] signature64 = new byte[64]; + bb.get(signature64); + byte[] signedBody = Arrays.copyOf(raw, raw.length - 64); + + return new SignedMessageBlock( + toLogin, + fromLogin, + timeMs, + nonce, + messageType, + revisionTimeMs, + major, + minor, + encryptedBody, + encryptedBody, + Collections.unmodifiableList(attachments), + signedBody, + signature64, + raw, + false ); } @@ -98,10 +215,36 @@ final class SignedMessageBlock { return messageType == TYPE_OUTGOING_COPY || messageType == TYPE_READ_OUTGOING_COPY; } + boolean isContentType() { + return messageType == TYPE_INCOMING_TEXT || messageType == TYPE_OUTGOING_COPY; + } + + boolean isReadReceiptType() { + return messageType == TYPE_READ_INCOMING || messageType == TYPE_READ_OUTGOING_COPY; + } + + boolean isDeletedContent() { + return isContentType() && !legacyFormat && attachments.isEmpty() && encryptedBodyBytes.length == 0; + } + String targetLogin() { return isIncomingType() ? toLogin : fromLogin; } + private static void ensureMessageType(int messageType) { + if (messageType < TYPE_INCOMING_TEXT || messageType > TYPE_READ_OUTGOING_COPY) { + throw new IllegalArgumentException("BAD_MESSAGE_TYPE"); + } + } + + private static boolean startsWith(byte[] raw, byte[] prefix) { + if (raw.length < prefix.length) return false; + for (int i = 0; i < prefix.length; i++) { + if (raw[i] != prefix[i]) return false; + } + return true; + } + private static String readAscii(ByteBuffer bb, int minLen, int maxLen, String code) { if (!bb.hasRemaining()) throw new IllegalArgumentException(code); int len = Byte.toUnsignedInt(bb.get()); diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/SignedMessagesCore.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/SignedMessagesCore.java index 4909fc0..fbe3deb 100644 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/SignedMessagesCore.java +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/SignedMessagesCore.java @@ -2,21 +2,27 @@ package server.logic.ws_protocol.JSON.messages; import shine.db.dao.SignedMessagesV2DAO; import shine.db.dao.SolanaUsersDAO; +import shine.db.entities.DmFileRef; import shine.db.entities.SignedMessageV2Entry; import shine.db.entities.SolanaUserEntry; import utils.crypto.Ed25519Util; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; import java.util.Base64; +import java.util.HashSet; +import java.util.List; +import java.util.Set; final class SignedMessagesCore { - private static final int MAX_PAYLOAD_BYTES = 4096; + private static final int MAX_ENCRYPTED_BODY_BYTES = 16384; private SignedMessagesCore() {} static SignedMessageBlock parseFromB64(String blobB64) { try { byte[] raw = Base64.getDecoder().decode(blobB64.trim()); - return SignedMessageBlock.parse(raw, MAX_PAYLOAD_BYTES); + return SignedMessageBlock.parse(raw, MAX_ENCRYPTED_BODY_BYTES); } catch (IllegalArgumentException e) { throw new IllegalArgumentException("BAD_BLOCK_FORMAT"); } @@ -42,7 +48,7 @@ final class SignedMessagesCore { if (incoming.timeMs != outgoing.timeMs) throw new IllegalArgumentException("BAD_PAIR_KEYS"); if (incoming.nonce != outgoing.nonce) throw new IllegalArgumentException("BAD_PAIR_KEYS"); - if (incoming.messageType == SignedMessageBlock.TYPE_READ_INCOMING) { + if (incoming.isReadReceiptType()) { ReadReceiptPayload inRef = ReadReceiptPayload.parse(incoming.payloadBytes); ReadReceiptPayload outRef = ReadReceiptPayload.parse(outgoing.payloadBytes); if (!inRef.refToLogin.equalsIgnoreCase(outRef.refToLogin) @@ -52,6 +58,63 @@ final class SignedMessagesCore { || inRef.refType != outRef.refType) { throw new IllegalArgumentException("BAD_RECEIPT_REF"); } + return; + } + + if (incoming.legacyFormat || outgoing.legacyFormat) { + throw new IllegalArgumentException("BAD_CONTENT_FORMAT"); + } + if (incoming.revisionTimeMs != outgoing.revisionTimeMs) { + throw new IllegalArgumentException("BAD_REVISION_TIME"); + } + if (incoming.formatVersionMajor != outgoing.formatVersionMajor + || incoming.formatVersionMinor != outgoing.formatVersionMinor) { + throw new IllegalArgumentException("BAD_FORMAT_VERSION"); + } + if (incoming.attachments.size() != outgoing.attachments.size()) { + throw new IllegalArgumentException("BAD_ATTACHMENTS"); + } + + Set seenIncoming = new HashSet<>(); + Set seenOutgoing = new HashSet<>(); + for (int i = 0; i < incoming.attachments.size(); i++) { + DmFileRef left = incoming.attachments.get(i); + DmFileRef right = outgoing.attachments.get(i); + if (left.getFileSize() != right.getFileSize()) { + throw new IllegalArgumentException("BAD_ATTACHMENTS"); + } + if (left.getFileHash() == null || right.getFileHash() == null) { + throw new IllegalArgumentException("BAD_ATTACHMENTS"); + } + if (left.getFileHash().length != 32 || right.getFileHash().length != 32) { + throw new IllegalArgumentException("BAD_ATTACHMENTS"); + } + String inDedup = Base64.getEncoder().encodeToString(left.getFileHash()); + String outDedup = Base64.getEncoder().encodeToString(right.getFileHash()); + if (!seenIncoming.add(inDedup) || !seenOutgoing.add(outDedup)) { + throw new IllegalArgumentException("DUPLICATE_ATTACHMENTS"); + } + if (!inDedup.equals(outDedup)) { + throw new IllegalArgumentException("BAD_ATTACHMENTS"); + } + } + + if (incoming.encryptedBodyBytes.length != outgoing.encryptedBodyBytes.length) { + throw new IllegalArgumentException("BAD_MESSAGE_LEN"); + } + for (int i = 0; i < incoming.encryptedBodyBytes.length; i++) { + if (incoming.encryptedBodyBytes[i] != outgoing.encryptedBodyBytes[i]) { + throw new IllegalArgumentException("BAD_ENCRYPTED_BODY"); + } + } + } + + static void ensureAllFilesExist(SignedMessageBlock block) throws Exception { + if (!block.isContentType()) return; + for (DmFileRef ref : block.attachments) { + if (!SignedMessagesV2DAO.getInstance().fileExists(ref.getFileHash(), ref.getFileSize())) { + throw new IllegalArgumentException("ATTACHMENT_NOT_FOUND"); + } } } @@ -68,6 +131,7 @@ final class SignedMessagesCore { entry.setTimeMs(block.timeMs); entry.setNonce(block.nonce); entry.setMessageType(block.messageType); + entry.setRevisionTimeMs(block.revisionTimeMs); entry.setRawBlock(block.rawPacket); entry.setCreatedAtMs(System.currentTimeMillis()); entry.setSourceApi(sourceApi); @@ -83,6 +147,17 @@ final class SignedMessagesCore { return entry; } + static List attachmentRefs(SignedMessageBlock block) { + return new ArrayList<>(block.attachments); + } + + static String previewTextForPush(SignedMessageBlock block) { + if (!block.isContentType() || block.encryptedBodyBytes == null || block.encryptedBodyBytes.length == 0) { + return ""; + } + return new String(block.encryptedBodyBytes, StandardCharsets.UTF_8); + } + static boolean saveIfAbsent(SignedMessageV2Entry entry) throws Exception { return SignedMessagesV2DAO.getInstance().insertIfAbsent(entry); } 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 76019e3..ee344a4 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 @@ -21,8 +21,13 @@ public final class SignedMessagesRealtime { private static final ObjectMapper MAPPER = new ObjectMapper(); private SignedMessagesRealtime() {} + static DeliveryCounters deliverToTargetSessions(SignedMessageV2Entry message, SignedMessageBlock block) throws Exception { + return deliverToTargetSessions(message, block, null); + } + static DeliveryCounters deliverToTargetSessions( SignedMessageV2Entry message, + SignedMessageBlock block, String excludeSessionId ) throws Exception { DeliveryCounters counters = new DeliveryCounters(); @@ -39,8 +44,11 @@ public final class SignedMessagesRealtime { counters.wsDelivered++; continue; } - if (message.getMessageType() == SignedMessageBlock.TYPE_INCOMING_TEXT) { - boolean pushed = pushNewMessageNotification(s, message); + if (message.getMessageType() == SignedMessageBlock.TYPE_INCOMING_TEXT + && block != null + && block.revisionTimeMs == 0 + && !block.isDeletedContent()) { + boolean pushed = pushNewMessageNotification(s, message, block); if (pushed) counters.pushDelivered++; } } @@ -89,13 +97,21 @@ public final class SignedMessagesRealtime { return WsEventSender.sendEvent(targetCtx, "SignedMessageArrived", message.getMessageKey(), payload); } - private static boolean pushNewMessageNotification(ActiveSessionEntry session, SignedMessageV2Entry message) { + private static boolean pushNewMessageNotification( + ActiveSessionEntry session, + SignedMessageV2Entry message, + SignedMessageBlock block + ) { try { if (session == null) return false; if (isBlank(session.getPushEndpoint()) || isBlank(session.getPushP256dhKey()) || isBlank(session.getPushAuthKey())) { return false; } - String text = "Вам пришло сообщение от " + message.getFromLogin() + ". Откройте для прочтения."; + String preview = SignedMessagesCore.previewTextForPush(block).replace('\n', ' ').trim(); + if (preview.length() > 80) preview = preview.substring(0, 80) + "..."; + String text = preview.isBlank() + ? "Вам пришло сообщение от " + message.getFromLogin() + ". Откройте для прочтения." + : preview; String payload = "{\"kind\":\"new_message\",\"fromLogin\":\"" + jsonEscape(message.getFromLogin()) + "\",\"text\":\"" + jsonEscape(text) + "\"}"; return WebPushSender.sendBase64Payload( session.getPushEndpoint(), diff --git a/SHiNE-server/src/main/java/server/files/DmFilesServlet.java b/SHiNE-server/src/main/java/server/files/DmFilesServlet.java new file mode 100644 index 0000000..c23c7cc --- /dev/null +++ b/SHiNE-server/src/main/java/server/files/DmFilesServlet.java @@ -0,0 +1,66 @@ +package server.files; + +import jakarta.servlet.http.HttpServlet; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import server.logic.ws_protocol.JSON.messages.DmFileStorage; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; + +public class DmFilesServlet extends HttpServlet { + @Override + protected void doOptions(HttpServletRequest req, HttpServletResponse resp) { + applyCors(resp); + resp.setStatus(HttpServletResponse.SC_NO_CONTENT); + } + + @Override + protected void doHead(HttpServletRequest req, HttpServletResponse resp) throws IOException { + handleRead(req, resp, true); + } + + @Override + protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws IOException { + handleRead(req, resp, false); + } + + private void handleRead(HttpServletRequest req, HttpServletResponse resp, boolean headOnly) throws IOException { + applyCors(resp); + String pathInfo = String.valueOf(req.getPathInfo() == null ? "" : req.getPathInfo()).trim(); + if (pathInfo.startsWith("/")) pathInfo = pathInfo.substring(1); + if (pathInfo.isBlank()) { + resp.sendError(HttpServletResponse.SC_BAD_REQUEST, "hash is required"); + return; + } + + try { + DmFileStorage.base64UrlToHash(pathInfo); + } catch (IllegalArgumentException ex) { + resp.sendError(HttpServletResponse.SC_BAD_REQUEST, "bad hash"); + return; + } + + Path target = DmFileStorage.resolvePathByHashB64Url(pathInfo); + if (!Files.exists(target) || !Files.isRegularFile(target)) { + resp.setStatus(HttpServletResponse.SC_NOT_FOUND); + return; + } + + resp.setStatus(HttpServletResponse.SC_OK); + resp.setContentType("application/octet-stream"); + resp.setHeader("Cache-Control", "public, max-age=31536000, immutable"); + resp.setHeader("Content-Disposition", "inline; filename=\"" + pathInfo + "\""); + long size = Files.size(target); + resp.setContentLengthLong(size); + if (headOnly) return; + Files.copy(target, resp.getOutputStream()); + } + + private void applyCors(HttpServletResponse resp) { + resp.setHeader("Access-Control-Allow-Origin", "*"); + resp.setHeader("Access-Control-Allow-Methods", "GET,HEAD,OPTIONS"); + resp.setHeader("Access-Control-Allow-Headers", "Content-Type"); + } +} diff --git a/SHiNE-server/src/main/java/server/files/DmUploadServlet.java b/SHiNE-server/src/main/java/server/files/DmUploadServlet.java new file mode 100644 index 0000000..06db6c7 --- /dev/null +++ b/SHiNE-server/src/main/java/server/files/DmUploadServlet.java @@ -0,0 +1,69 @@ +package server.files; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; +import jakarta.servlet.http.HttpServlet; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import server.logic.ws_protocol.JSON.messages.DmFileStorage; +import shine.db.dao.SignedMessagesV2DAO; + +import java.io.IOException; + +public class DmUploadServlet extends HttpServlet { + private static final ObjectMapper MAPPER = new ObjectMapper(); + + @Override + protected void doOptions(HttpServletRequest req, HttpServletResponse resp) { + applyCors(resp); + resp.setStatus(HttpServletResponse.SC_NO_CONTENT); + } + + @Override + protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws IOException { + applyCors(resp); + String hash = String.valueOf(req.getParameter("hash")).trim(); + String sizeRaw = String.valueOf(req.getParameter("size")).trim(); + if (hash.isEmpty() || sizeRaw.isEmpty()) { + resp.sendError(HttpServletResponse.SC_BAD_REQUEST, "hash and size are required"); + return; + } + + long expectedSize; + try { + expectedSize = Long.parseLong(sizeRaw); + if (expectedSize < 0) throw new NumberFormatException("negative"); + } catch (Exception ex) { + resp.sendError(HttpServletResponse.SC_BAD_REQUEST, "bad size"); + return; + } + + try { + DmFileStorage.StoreResult result = DmFileStorage.storeCiphertext(req.getInputStream(), hash, expectedSize); + SignedMessagesV2DAO.getInstance().registerFileIfAbsent( + DmFileStorage.base64UrlToHash(result.hashB64Url()), + result.sizeBytes() + ); + + ObjectNode payload = MAPPER.createObjectNode(); + payload.put("ok", true); + payload.put("hash", result.hashB64Url()); + payload.put("size", result.sizeBytes()); + payload.put("alreadyExists", result.alreadyExists()); + + resp.setStatus(HttpServletResponse.SC_OK); + resp.setContentType("application/json; charset=UTF-8"); + MAPPER.writeValue(resp.getOutputStream(), payload); + } catch (IllegalArgumentException ex) { + resp.sendError(HttpServletResponse.SC_BAD_REQUEST, ex.getMessage()); + } catch (Exception ex) { + resp.sendError(HttpServletResponse.SC_INTERNAL_SERVER_ERROR, "upload_failed"); + } + } + + private void applyCors(HttpServletResponse resp) { + resp.setHeader("Access-Control-Allow-Origin", "*"); + resp.setHeader("Access-Control-Allow-Methods", "POST,OPTIONS"); + resp.setHeader("Access-Control-Allow-Headers", "Content-Type"); + } +} diff --git a/SHiNE-server/src/main/java/server/ws/WsServer.java b/SHiNE-server/src/main/java/server/ws/WsServer.java index 31bf3df..0fdc7eb 100644 --- a/SHiNE-server/src/main/java/server/ws/WsServer.java +++ b/SHiNE-server/src/main/java/server/ws/WsServer.java @@ -1,11 +1,14 @@ package server.ws; import org.eclipse.jetty.server.Server; +import org.eclipse.jetty.servlet.ServletHolder; import org.eclipse.jetty.servlet.ServletContextHandler; import org.eclipse.jetty.websocket.server.config.JettyWebSocketServletContainerInitializer; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import server.debug.DebugApiConfigurator; +import server.files.DmFilesServlet; +import server.files.DmUploadServlet; import utils.config.AppConfig; import java.time.Duration; @@ -61,6 +64,8 @@ public final class WsServer { // HTTP debug API DebugApiConfigurator.register(context); + context.addServlet(new ServletHolder(new DmFilesServlet()), "/f/*"); + context.addServlet(new ServletHolder(new DmUploadServlet()), "/upload"); // Инициализация контейнера WebSocket JettyWebSocketServletContainerInitializer.configure(context, (servletContext, wsContainer) -> { @@ -75,4 +80,4 @@ public final class WsServer { log.info("✅ WS сервер запущен на ws://localhost:{}/ws", port); server.join(); } -} \ No newline at end of file +} diff --git a/SHiNE-server/src/main/resources/application.properties b/SHiNE-server/src/main/resources/application.properties index 7609457..a8e0776 100644 --- a/SHiNE-server/src/main/resources/application.properties +++ b/SHiNE-server/src/main/resources/application.properties @@ -1,5 +1,7 @@ server.1port=7070 db.path=data/shine.sqlite +dm.files.dir=f +dm.upload.maxBytes=104857600 # ------------------------------------------------------------ # Server public info diff --git a/VERSION.properties b/VERSION.properties index 2d67739..103f24e 100644 --- a/VERSION.properties +++ b/VERSION.properties @@ -1,2 +1,2 @@ -client.version=1.2.207 -server.version=1.2.196 +client.version=1.2.208 +server.version=1.2.197 diff --git a/shine-UI/js/app.js b/shine-UI/js/app.js index 003e8dd..f73bc5b 100644 --- a/shine-UI/js/app.js +++ b/shine-UI/js/app.js @@ -900,7 +900,7 @@ async function init() { const messageType = Number(parsed.messageType || 0); const chatId = messageType === 2 ? toLogin : fromLogin; const text = (messageType === 1 || messageType === 2) - ? new TextDecoder().decode(parsed.payloadBytes || new Uint8Array(0)) + ? String(parsed.text || '') : ''; let shouldRefreshToolbarUnread = false; @@ -916,15 +916,18 @@ async function init() { messageType, unread: isIncomingForCurrent, rawBlobB64: blobB64, + revisionTimeMs: Number(parsed.revisionTimeMs || 0), + attachments: Array.isArray(parsed.bodyAttachments) ? parsed.bodyAttachments : [], + deleted: Boolean(parsed.deleted), }); if (added) { addAppLogEntry({ level: 'info', source: 'signed-dm', message: isIncomingForCurrent - ? `Новое входящее сообщение от ${fromLogin}` + ? `Обновлено входящее сообщение от ${fromLogin}` : `Синхронизирована исходящая копия в чате ${chatId}`, - details: { messageKey, baseKey: parsed.baseKey, messageType }, + details: { messageKey, baseKey: parsed.baseKey, messageType, revisionTimeMs: parsed.revisionTimeMs || 0, deleted: !!parsed.deleted }, }); } if (added && isIncomingForCurrent) { diff --git a/shine-UI/js/pages/chat-view.js b/shine-UI/js/pages/chat-view.js index 3298775..abcabb3 100644 --- a/shine-UI/js/pages/chat-view.js +++ b/shine-UI/js/pages/chat-view.js @@ -3,6 +3,7 @@ import { directMessages } from '../mock-data.js'; import { addAppLogEntry, addChatMessage, + addSignedMessageToChat, addSystemChatMessage, addOutgoingPendingMessage, getChatMessages, @@ -165,6 +166,35 @@ function resolveDeliveryStatus(msg) { return '…'; } +function formatFileSize(bytes) { + const value = Number(bytes || 0); + if (!Number.isFinite(value) || value < 1024) return `${Math.max(0, Math.trunc(value))} B`; + if (value < 1024 * 1024) return `${(value / 1024).toFixed(1)} KB`; + return `${(value / (1024 * 1024)).toFixed(1)} MB`; +} + +function messagePlainText(msg) { + return String(msg?.text || '').trim(); +} + +async function downloadAttachment(attachment) { + const fileName = String(attachment?.fileName || 'file.bin'); + const mime = String(attachment?.mime || 'application/octet-stream'); + const plainBytes = await authService.downloadAndDecryptDmAttachment(attachment, state.entrySettings.shineServerHttp); + const blob = new Blob([plainBytes], { type: mime }); + const url = URL.createObjectURL(blob); + try { + const link = document.createElement('a'); + link.href = url; + link.download = fileName; + document.body.append(link); + link.click(); + link.remove(); + } finally { + setTimeout(() => URL.revokeObjectURL(url), 1000); + } +} + function scrollToLatestMessage(list) { if (!list) return; const apply = () => { @@ -198,9 +228,35 @@ function renderLog(list, chatId, { onOpenActions } = {}) { const bubbleKind = String(msg?.kind || '').trim(); bubble.className = `bubble ${msg.from}${bubbleKind ? ` ${bubbleKind}` : ''}`; - const textNode = document.createElement('div'); - textNode.className = 'bubble-text'; - textNode.textContent = msg.text || ''; + const plainText = messagePlainText(msg); + if (plainText) { + const textNode = document.createElement('div'); + textNode.className = 'bubble-text'; + textNode.textContent = plainText; + bubble.append(textNode); + } + + const attachments = Array.isArray(msg?.attachments) ? msg.attachments : []; + if (attachments.length) { + const attachmentsNode = document.createElement('div'); + attachmentsNode.className = 'stack'; + attachments.forEach((attachment) => { + const btn = document.createElement('button'); + btn.type = 'button'; + btn.className = 'secondary-btn'; + btn.textContent = `${attachment?.fileName || 'file'} • ${formatFileSize(attachment?.origSize || attachment?.encSize || 0)}`; + btn.addEventListener('click', async (event) => { + event.stopPropagation(); + try { + await downloadAttachment(attachment); + } catch (error) { + showToast(`Не удалось скачать файл: ${error?.message || 'unknown'}`, { kind: 'error', timeoutMs: 1600 }); + } + }); + attachmentsNode.append(btn); + }); + bubble.append(attachmentsNode); + } const metaNode = document.createElement('div'); metaNode.className = 'bubble-meta'; @@ -218,7 +274,7 @@ function renderLog(list, chatId, { onOpenActions } = {}) { metaNode.append(statusNode); } - bubble.append(textNode, metaNode); + bubble.append(metaNode); bubble.addEventListener('click', () => { if (typeof onOpenActions === 'function') onOpenActions(msg); }); @@ -332,17 +388,64 @@ export function render({ navigate, route }) { const form = document.createElement('form'); form.className = 'chat-input dm-chat-input'; form.innerHTML = ` - + +
+
+
`; + const fileInput = form.querySelector('#chat-file-input'); + const attachmentsPreview = form.querySelector('#chat-attachments-preview'); + let pendingFiles = []; + + const renderPendingFiles = () => { + if (!attachmentsPreview) return; + attachmentsPreview.innerHTML = ''; + pendingFiles.forEach((file, index) => { + const row = document.createElement('div'); + row.className = 'meta-muted'; + row.textContent = `${file.name} • ${formatFileSize(file.size)}`; + row.addEventListener('click', () => { + pendingFiles = pendingFiles.filter((_, current) => current !== index); + renderPendingFiles(); + }); + attachmentsPreview.append(row); + }); + }; + + const buildMessagePayloadText = (plainText, preparedAttachments) => { + const parts = []; + const text = String(plainText || '').trim(); + if (text) parts.push(text); + preparedAttachments.forEach((item) => { + parts.push(`<>`); + }); + return parts.join('\n'); + }; + + const ensureUploads = async (preparedAttachments) => { + for (const item of preparedAttachments) { + const exists = await authService.headDmFile(item.encHashB64u, state.entrySettings.shineServerHttp); + if (!exists) { + await authService.uploadDmFileCiphertext({ + encHashB64u: item.encHashB64u, + encSize: item.encSize, + ciphertextBytes: item.ciphertextBytes, + serverHttpBase: state.entrySettings.shineServerHttp, + }); + } + } + }; + const sendTextMessage = async (rawText) => { const text = String(rawText || '').trim(); - if (!text) return; - const tempId = addOutgoingPendingMessage(chatId, text); + if (!text && pendingFiles.length === 0) return; + const tempLabel = text || `Файлы: ${pendingFiles.length}`; + const tempId = addOutgoingPendingMessage(chatId, tempLabel); renderLog(log, chatId, { onOpenActions: (msg) => openMessageActionsModal({ messageText: msg?.text || '', @@ -357,16 +460,47 @@ export function render({ navigate, route }) { }); try { - const result = await authService.sendDirectMessage({ + const filesToSend = pendingFiles.slice(0, 12); + const preparedAttachments = []; + for (const file of filesToSend) { + preparedAttachments.push(await authService.prepareEncryptedDmAttachment(file)); + } + await ensureUploads(preparedAttachments); + const messagePayloadText = buildMessagePayloadText(text, preparedAttachments); + const result = await authService.sendDirectMessageWithAttachments({ login: state.session.login, toLogin: chatId, - text, + text: messagePayloadText, storagePwd: state.session.storagePwdInMemory, + attachments: preparedAttachments, }); + pendingFiles = []; + if (fileInput) fileInput.value = ''; + renderPendingFiles(); markOutgoingSent(tempId, { messageKey: result?.outgoingKey || '', baseKey: result?.baseKey || result?.localBaseKey || '', }); + if (result?.localOutgoingBlobB64) { + try { + const parsed = authService.parseSignedMessageBlob(result.localOutgoingBlobB64); + addSignedMessageToChat({ + chatId, + messageKey: result?.outgoingKey || parsed?.messageKey || '', + baseKey: result?.baseKey || parsed?.baseKey || '', + from: 'out', + text: parsed?.text || '', + messageType: Number(parsed?.messageType || 2), + unread: false, + rawBlobB64: result.localOutgoingBlobB64, + revisionTimeMs: Number(parsed?.revisionTimeMs || 0), + attachments: Array.isArray(parsed?.bodyAttachments) ? parsed.bodyAttachments : [], + deleted: Boolean(parsed?.deleted), + }); + } catch { + // ignore local parse failure; server backlog/realtime will reconcile later + } + } renderLog(log, chatId, { onOpenActions: (msg) => openMessageActionsModal({ messageText: msg?.text || '', @@ -417,6 +551,15 @@ export function render({ navigate, route }) { }; const input = form.elements.message; + form.querySelector('#chat-file-pick')?.addEventListener('click', () => fileInput?.click()); + fileInput?.addEventListener('change', () => { + const selected = Array.from(fileInput.files || []); + if (selected.length > 12) { + showToast('Можно приложить не больше 12 файлов', { kind: 'error', timeoutMs: 1400 }); + } + pendingFiles = selected.slice(0, 12); + renderPendingFiles(); + }); autoResizeComposer(input); input?.addEventListener('input', () => autoResizeComposer(input)); input?.addEventListener('focus', () => { @@ -441,7 +584,7 @@ export function render({ navigate, route }) { } event.preventDefault(); const text = String(input.value || '').trim(); - if (!text) return; + if (!text && pendingFiles.length === 0) return; input.value = ''; autoResizeComposer(input); await sendTextMessage(text); @@ -465,7 +608,7 @@ export function render({ navigate, route }) { form.addEventListener('submit', async (event) => { event.preventDefault(); const text = input.value.trim(); - if (!text) return; + if (!text && pendingFiles.length === 0) return; input.value = ''; autoResizeComposer(input); await sendTextMessage(text); diff --git a/shine-UI/js/services/auth-service.js b/shine-UI/js/services/auth-service.js index 5c453ce..5461c85 100644 --- a/shine-UI/js/services/auth-service.js +++ b/shine-UI/js/services/auth-service.js @@ -1,14 +1,19 @@ import { WsJsonClient } from './ws-client.js'; import { base64ToBytes, + base64UrlToBytes, bytesToBase64, + bytesToBase64Url, + decryptBytesAesGcm, deriveEd25519FromMasterSecret, deriveMasterSecretFromPassword, + encryptBytesAesGcm, exportEd25519PublicKeyB64, exportPkcs8B64, generateEd25519Pair, importPkcs8Ed25519, publicKeyB64FromPkcs8Ed25519, + randomBytes, randomBase64, sha256Bytes, signBytes, @@ -199,11 +204,16 @@ function uint8Bytes(value) { return new Uint8Array([Number(value) & 0xff]); } +const DM_PREFIX_V1 = utf8Bytes('SHiNE_DM'); const DM2_PREFIX = utf8Bytes('SHiNE_dm2'); const DM2_TYPE_INCOMING = 1; const DM2_TYPE_OUTGOING_COPY = 2; const DM2_TYPE_READ_INCOMING = 3; const DM2_TYPE_READ_OUTGOING_COPY = 4; +const DM_FORMAT_VERSION_MAJOR = 1; +const DM_FORMAT_VERSION_MINOR = 0; +const DM_MAX_ATTACHMENTS = 12; +const DM_MAX_ENCRYPTED_BODY_BYTES = 16384; function ensureAsciiBytes(value, field, min = 1, max = 60) { const text = String(value || '').trim(); @@ -238,6 +248,51 @@ function buildReadReceiptPayloadBytes({ refToLogin, refFromLogin, refTimeMs, ref ); } +function buildDmAttachmentsSectionBytes(attachments = []) { + const list = Array.isArray(attachments) ? attachments : []; + if (list.length > DM_MAX_ATTACHMENTS) { + throw new Error(`Вложений должно быть не больше ${DM_MAX_ATTACHMENTS}`); + } + const parts = [uint8Bytes(list.length)]; + list.forEach((item, index) => { + const hashB64u = String(item?.encHashB64u || '').trim(); + const hashBytes = base64UrlToBytes(hashB64u); + if (hashBytes.length !== 32) throw new Error(`Некорректный encHash у вложения #${index + 1}`); + const encSize = Number(item?.encSize ?? item?.encFileSize ?? 0); + if (!Number.isFinite(encSize) || encSize < 0) throw new Error(`Некорректный encSize у вложения #${index + 1}`); + parts.push(hashBytes, uint64Bytes(encSize)); + }); + return concatBytes(...parts); +} + +function parseDmTextAttachments(text) { + const raw = String(text || ''); + const regex = /<|]+)\|([^>|]+)\|(\d+)\|([^>|]+)\|([^>|]+)\|(\d+)\|([^>|]+)\|([^>|]+)>>/g; + const attachments = []; + let cleaned = ''; + let lastIndex = 0; + let match; + while ((match = regex.exec(raw)) !== null) { + cleaned += raw.slice(lastIndex, match.index); + lastIndex = match.index + match[0].length; + attachments.push({ + type: String(match[1] || 'file'), + fileName: String(match[2] || ''), + origSize: Number(match[3] || 0), + origHashB64u: String(match[4] || ''), + encHashB64u: String(match[5] || ''), + encSize: Number(match[6] || 0), + keyB64u: String(match[7] || ''), + nonceB64u: String(match[8] || ''), + }); + } + cleaned += raw.slice(lastIndex); + return { + text: cleaned.replace(/\n{3,}/g, '\n\n').trim(), + attachments, + }; +} + function parseSignedMessageBlockBytes(bytes) { if (!(bytes instanceof Uint8Array)) throw new Error('Expected Uint8Array'); let o = 0; @@ -274,6 +329,70 @@ function parseSignedMessageBlockBytes(bytes) { return text; }; + const startsWith = (prefix) => { + if (bytes.length < prefix.length) return false; + for (let i = 0; i < prefix.length; i += 1) { + if (bytes[i] !== prefix[i]) return false; + } + return true; + }; + + if (startsWith(DM_PREFIX_V1)) { + read(DM_PREFIX_V1.length); + const formatVersionMajor = readU8(); + const formatVersionMinor = readU8(); + const toLogin = readAscii(); + const fromLogin = readAscii(); + const timeMs = readU64(); + const nonce = readU32(); + const messageType = readU16(); + const revisionTimeMs = readU64(); + const attachmentsCount = readU8(); + if (attachmentsCount > DM_MAX_ATTACHMENTS) throw new Error('TOO_MANY_ATTACHMENTS'); + const attachments = []; + for (let i = 0; i < attachmentsCount; i += 1) { + const hashBytes = read(32); + const encSize = readU64(); + attachments.push({ + encHashBytes: hashBytes, + encHashB64u: bytesToBase64Url(hashBytes), + encSize, + }); + } + const encryptedBodyLen = readU32(); + const encryptedBodyBytes = read(encryptedBodyLen); + const signatureBytes = read(64); + if (o !== bytes.length) throw new Error('BAD_LEN'); + const signedBody = bytes.slice(0, bytes.length - 64); + const baseKey = dm2BaseKey({ toLogin, fromLogin, timeMs, nonce }); + const messageKey = dm2MessageKey({ toLogin, fromLogin, timeMs, nonce, messageType }); + const bodyText = new TextDecoder().decode(encryptedBodyBytes); + const parsedBody = parseDmTextAttachments(bodyText); + return { + toLogin, + fromLogin, + timeMs, + nonce, + messageType, + revisionTimeMs, + formatVersionMajor, + formatVersionMinor, + attachments, + encryptedBodyBytes, + encryptedBodyText: bodyText, + text: parsedBody.text, + bodyAttachments: parsedBody.attachments, + payloadBytes: encryptedBodyBytes, + signatureBytes, + signedBody, + rawBytes: bytes, + baseKey, + messageKey, + legacyFormat: false, + deleted: attachmentsCount === 0 && encryptedBodyLen === 0, + }; + } + const prefix = read(DM2_PREFIX.length); for (let i = 0; i < DM2_PREFIX.length; i += 1) { if (prefix[i] !== DM2_PREFIX[i]) throw new Error('BAD_PREFIX'); @@ -298,12 +417,20 @@ function parseSignedMessageBlockBytes(bytes) { timeMs, nonce, messageType, + revisionTimeMs: 0, + attachments: [], + encryptedBodyBytes: payloadBytes, + encryptedBodyText: new TextDecoder().decode(payloadBytes), + text: new TextDecoder().decode(payloadBytes), + bodyAttachments: [], payloadBytes, signatureBytes, signedBody, rawBytes: bytes, baseKey, messageKey, + legacyFormat: true, + deleted: false, }; } @@ -1846,6 +1973,51 @@ export class AuthService { return concatBytes(preimage, signature); } + async buildSignedDmV1Block({ + login, + toLogin, + storagePwd, + timeMs, + nonce, + messageType, + revisionTimeMs = 0, + attachments = [], + encryptedBodyBytes = new Uint8Array(0), + }) { + const cleanFromLogin = String(login || '').trim(); + const cleanToLogin = String(toLogin || '').trim(); + if (!cleanFromLogin || !cleanToLogin) throw new Error('Не передан login/toLogin'); + if (!storagePwd) throw new Error('Не передан storagePwd для подписи'); + if (!(encryptedBodyBytes instanceof Uint8Array) || encryptedBodyBytes.length > DM_MAX_ENCRYPTED_BODY_BYTES) { + throw new Error(`encryptedBody должен быть 0..${DM_MAX_ENCRYPTED_BODY_BYTES} байт`); + } + + const secrets = await loadEncryptedUserSecrets(cleanFromLogin, storagePwd); + const devicePriv = secrets?.deviceKey; + if (!devicePriv) throw new Error('Не найден приватный deviceKey'); + const privateKey = await importPkcs8Ed25519(devicePriv); + + const toBytes = ensureAsciiBytes(cleanToLogin, 'toLogin'); + const fromBytes = ensureAsciiBytes(cleanFromLogin, 'fromLogin'); + const attachmentsSection = buildDmAttachmentsSectionBytes(attachments); + const preimage = concatBytes( + DM_PREFIX_V1, + uint8Bytes(DM_FORMAT_VERSION_MAJOR), + uint8Bytes(DM_FORMAT_VERSION_MINOR), + uint8Bytes(toBytes.length), toBytes, + uint8Bytes(fromBytes.length), fromBytes, + uint64Bytes(timeMs), + uint32Bytes(nonce), + uint16Bytes(messageType), + uint64Bytes(revisionTimeMs), + attachmentsSection, + uint32Bytes(encryptedBodyBytes.length), + encryptedBodyBytes, + ); + const signature = await signBytes(privateKey, preimage); + return concatBytes(preimage, signature); + } + parseSignedMessageBlob(blobB64) { const bytes = base64ToBytes(String(blobB64 || '').trim()); return parseSignedMessageBlockBytes(bytes); @@ -1901,33 +2073,48 @@ export class AuthService { } async sendDirectMessage({ login, toLogin, text, storagePwd }) { + return this.sendDirectMessageWithAttachments({ login, toLogin, text, storagePwd, attachments: [] }); + } + + async sendDirectMessageWithAttachments({ + login, + toLogin, + text, + storagePwd, + attachments = [], + timeMs = Date.now(), + nonce = Math.floor(Math.random() * 0x100000000), + revisionTimeMs = 0, + }) { const cleanFromLogin = String(login || '').trim(); const cleanToLogin = String(toLogin || '').trim(); const cleanText = String(text || ''); - if (!cleanFromLogin || !cleanToLogin || !cleanText) throw new Error('Не передан login/toLogin/text'); + const normalizedAttachments = Array.isArray(attachments) ? attachments : []; + if (!cleanFromLogin || !cleanToLogin) throw new Error('Не передан login/toLogin'); + if (!cleanText && normalizedAttachments.length === 0) throw new Error('Пустое сообщение'); + const encryptedBodyBytes = utf8Bytes(cleanText); - const timeMs = Date.now(); - const nonce = Math.floor(Math.random() * 0x100000000); - const incomingPayload = utf8Bytes(cleanText); - const outgoingPayload = utf8Bytes(cleanText); - - const incomingBlock = await this.buildSignedDm2Block({ + const incomingBlock = await this.buildSignedDmV1Block({ login: cleanFromLogin, toLogin: cleanToLogin, storagePwd, timeMs, nonce, messageType: DM2_TYPE_INCOMING, - payloadBytes: incomingPayload, + revisionTimeMs, + attachments: normalizedAttachments, + encryptedBodyBytes, }); - const outgoingBlock = await this.buildSignedDm2Block({ + const outgoingBlock = await this.buildSignedDmV1Block({ login: cleanFromLogin, toLogin: cleanToLogin, storagePwd, timeMs, nonce, messageType: DM2_TYPE_OUTGOING_COPY, - payloadBytes: outgoingPayload, + revisionTimeMs, + attachments: normalizedAttachments, + encryptedBodyBytes, }); const payload = await this.sendMessagePair({ @@ -1977,6 +2164,90 @@ export class AuthService { return response.payload || {}; } + getHttpBaseUrl(serverHttpBase = '') { + const explicit = String(serverHttpBase || '').trim(); + if (explicit) return explicit.replace(/\/$/, ''); + try { + const parsed = new URL(this.serverUrl); + parsed.protocol = parsed.protocol === 'wss:' ? 'https:' : 'http:'; + parsed.pathname = ''; + parsed.search = ''; + parsed.hash = ''; + return parsed.toString().replace(/\/$/, ''); + } catch { + return ''; + } + } + + buildDmFileUrl(encHashB64u, serverHttpBase = '') { + const base = this.getHttpBaseUrl(serverHttpBase); + return `${base}/f/${encodeURIComponent(String(encHashB64u || '').trim())}`; + } + + async headDmFile(encHashB64u, serverHttpBase = '') { + const url = this.buildDmFileUrl(encHashB64u, serverHttpBase); + const response = await fetch(url, { method: 'HEAD' }); + return response.status === 200; + } + + async uploadDmFileCiphertext({ encHashB64u, encSize, ciphertextBytes, serverHttpBase = '' }) { + const base = this.getHttpBaseUrl(serverHttpBase); + const url = `${base}/upload?hash=${encodeURIComponent(String(encHashB64u || '').trim())}&size=${encodeURIComponent(String(encSize || 0))}`; + const response = await fetch(url, { + method: 'POST', + headers: { + 'Content-Type': 'application/octet-stream', + }, + body: ciphertextBytes, + }); + if (!response.ok) { + throw new Error(`upload_failed_${response.status}`); + } + return response.json(); + } + + async prepareEncryptedDmAttachment(file) { + if (!(file instanceof File)) throw new Error('Ожидался File'); + const fileName = String(file.name || 'file.bin'); + if (!fileName || /[|:>\n\r]/.test(fileName)) { + throw new Error('Имя файла содержит запрещённые символы для DM-протокола'); + } + const plainBytes = new Uint8Array(await file.arrayBuffer()); + const origHashBytes = await sha256Bytes(plainBytes); + const aesKeyBytes = randomBytes(32); + const ivBytes = randomBytes(12); + const cipherBytes = await encryptBytesAesGcm(plainBytes, aesKeyBytes, ivBytes); + const encHashBytes = await sha256Bytes(cipherBytes); + const type = String(file.type || '').startsWith('image/') + ? 'photo' + : (String(file.type || '').startsWith('video/') + ? 'video' + : (String(file.type || '').startsWith('audio/') ? 'audio' : 'file')); + return { + type, + mime: String(file.type || 'application/octet-stream'), + fileName, + origSize: plainBytes.length, + origHashB64u: bytesToBase64Url(origHashBytes), + encHashB64u: bytesToBase64Url(encHashBytes), + encSize: cipherBytes.length, + keyB64u: bytesToBase64Url(aesKeyBytes), + nonceB64u: bytesToBase64Url(ivBytes), + ciphertextBytes: cipherBytes, + }; + } + + async downloadAndDecryptDmAttachment(attachment, serverHttpBase = '') { + const encHashB64u = String(attachment?.encHashB64u || '').trim(); + if (!encHashB64u) throw new Error('Не указан encHashB64u'); + const response = await fetch(this.buildDmFileUrl(encHashB64u, serverHttpBase)); + if (!response.ok) throw new Error(`download_failed_${response.status}`); + const cipherBytes = new Uint8Array(await response.arrayBuffer()); + const keyBytes = base64UrlToBytes(String(attachment?.keyB64u || '').trim()); + const nonceBytes = base64UrlToBytes(String(attachment?.nonceB64u || '').trim()); + return decryptBytesAesGcm(cipherBytes, keyBytes, nonceBytes); + } + async callInviteBroadcast({ toLogin, callId, type = 100 }) { const response = await this.ws.request('CallInviteBroadcast', { toLogin, callId, type }); diff --git a/shine-UI/js/services/crypto-utils.js b/shine-UI/js/services/crypto-utils.js index 93e063a..b1b7687 100644 --- a/shine-UI/js/services/crypto-utils.js +++ b/shine-UI/js/services/crypto-utils.js @@ -25,6 +25,10 @@ function base64UrlToBase64(value) { return normalized + '='.repeat(padLen); } +function base64ToBase64Url(value) { + return String(value || '').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, ''); +} + const BASE58_ALPHABET = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz'; export function bytesToBase58(bytes) { @@ -93,6 +97,10 @@ export function bytesToBase64(bytes) { return btoa(binary); } +export function bytesToBase64Url(bytes) { + return base64ToBase64Url(bytesToBase64(bytes)); +} + export function base64ToBytes(base64) { const normalized = (base64 || '').trim(); const binary = atob(normalized); @@ -103,6 +111,10 @@ export function base64ToBytes(base64) { return bytes; } +export function base64UrlToBytes(value) { + return base64ToBytes(base64UrlToBase64(String(value || '').trim())); +} + export function utf8Bytes(value) { return encoder.encode(value); } @@ -260,6 +272,28 @@ export async function decryptJsonWithStoragePwd(envelope, storagePwd) { return JSON.parse(text); } +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) { + const key = await importAesKeyRaw(keyBytes, ['encrypt']); + const cipher = await getSubtleApi().encrypt({ name: 'AES-GCM', iv: ivBytes }, key, plainBytes); + return new Uint8Array(cipher); +} + +export async function decryptBytesAesGcm(cipherBytes, keyBytes, ivBytes) { + const key = await importAesKeyRaw(keyBytes, ['decrypt']); + const plain = await getSubtleApi().decrypt({ name: 'AES-GCM', iv: ivBytes }, key, cipherBytes); + return new Uint8Array(plain); +} + +export function randomBytes(byteLen = 32) { + const out = new Uint8Array(byteLen); + getCryptoApi().getRandomValues(out); + return out; +} + export async function generateEd25519Pair() { return getSubtleApi().generateKey({ name: 'Ed25519' }, true, ['sign', 'verify']); } diff --git a/shine-UI/js/services/message-store.js b/shine-UI/js/services/message-store.js index dadc025..2d5c672 100644 --- a/shine-UI/js/services/message-store.js +++ b/shine-UI/js/services/message-store.js @@ -41,6 +41,13 @@ export async function putStoredMessage(record) { }); } +export async function deleteStoredMessage(messageKey) { + if (!messageKey) return; + await withStore('readwrite', (store) => { + store.delete(messageKey); + }); +} + export async function listStoredMessages() { return withStore('readonly', (store) => new Promise((resolve, reject) => { const req = store.getAll(); diff --git a/shine-UI/js/state.js b/shine-UI/js/state.js index cd4de76..9d3796d 100644 --- a/shine-UI/js/state.js +++ b/shine-UI/js/state.js @@ -1,5 +1,5 @@ import { AuthService } from './services/auth-service.js'; -import { listStoredMessages, putStoredMessage, clearStoredMessages } from './services/message-store.js'; +import { listStoredMessages, putStoredMessage, deleteStoredMessage, clearStoredMessages } from './services/message-store.js'; import { SOLANA_ENDPOINT_DEFAULT } from './solana-programs.js'; import { DEFAULT_SHINE_SERVER_HTTP, @@ -378,15 +378,22 @@ function persistMessageRecord(chatId, row) { baseKey: String(row.baseKey || ''), messageType: Number(row.messageType || 0), rawBlobB64: String(row.rawBlobB64 || ''), + revisionTimeMs: Number(row.revisionTimeMs || 0), unread: Boolean(row.unread), firstTick: Boolean(row.firstTick), secondTick: Boolean(row.secondTick), readReceiptSent: Boolean(row.readReceiptSent), refBaseKey: String(row.refBaseKey || ''), + attachments: Array.isArray(row.attachments) ? row.attachments : [], ts: resolvedTs > 0 ? resolvedTs : Date.now(), }).catch(() => {}); } +function removeStoredMessageRecord(messageKey) { + if (!messageKey) return; + void deleteStoredMessage(messageKey).catch(() => {}); +} + export async function hydrateMessagesFromStore() { try { const rows = await listStoredMessages(); @@ -407,11 +414,13 @@ export async function hydrateMessagesFromStore() { baseKey: String(row.baseKey || ''), messageType: Number(row.messageType || 0), rawBlobB64: String(row.rawBlobB64 || ''), + revisionTimeMs: Number(row.revisionTimeMs || 0), unread: Boolean(row.unread), firstTick: Boolean(row.firstTick), secondTick: Boolean(row.secondTick), readReceiptSent: Boolean(row.readReceiptSent), refBaseKey: String(row.refBaseKey || ''), + attachments: Array.isArray(row.attachments) ? row.attachments : [], createdAtMs: Number(row.ts || 0), }); }); @@ -565,25 +574,45 @@ export function addSignedMessageToChat({ unread = false, rawBlobB64 = '', refBaseKey = '', + revisionTimeMs = 0, + attachments = [], + deleted = false, } = {}) { const id = String(messageKey || '').trim(); if (!chatId || !id) return false; - if (state.knownMessageKeys[id]) return false; - state.knownMessageKeys[id] = true; + const list = getChatMessages(chatId); + const existingIndex = list.findIndex((row) => String(row?.messageKey || '').trim() === id); + const existing = existingIndex >= 0 ? list[existingIndex] : null; - const row = { - from: from === 'out' ? 'out' : 'in', - text: String(text || ''), - messageKey: id, - baseKey: String(baseKey || ''), - messageType: Number(messageType || 0), - rawBlobB64: String(rawBlobB64 || ''), - unread: Boolean(unread), - refBaseKey: String(refBaseKey || ''), - firstTick: from === 'out', - secondTick: false, - }; - getChatMessages(chatId).push(row); + if (deleted) { + if (existingIndex >= 0) { + list.splice(existingIndex, 1); + removeStoredMessageRecord(id); + sortChatMessagesInPlace(chatId); + return true; + } + return false; + } + + state.knownMessageKeys[id] = true; + const row = existing || {}; + row.from = from === 'out' ? 'out' : 'in'; + row.text = String(text || ''); + row.messageKey = id; + row.baseKey = String(baseKey || ''); + row.messageType = Number(messageType || 0); + row.rawBlobB64 = String(rawBlobB64 || ''); + row.revisionTimeMs = Number(revisionTimeMs || 0); + row.attachments = Array.isArray(attachments) ? attachments : []; + row.unread = row.from === 'in' ? Boolean(unread) : false; + row.refBaseKey = String(refBaseKey || ''); + row.firstTick = row.from === 'out'; + row.secondTick = Boolean(existing?.secondTick); + row.readReceiptSent = Boolean(existing?.readReceiptSent); + + if (existingIndex < 0) { + list.push(row); + } sortChatMessagesInPlace(chatId); persistMessageRecord(chatId, row); return true;