# Личные сообщения (DM): как это устроено ## Коротко Личные сообщения в SHiNE теперь работают в двух слоях: - контентные сообщения `type=1/2` идут в новом бинарном формате `SHiNE_DM`; - read-receipt `type=3/4` пока остаются на legacy-формате `SHiNE_dm2`, чтобы не ломать текущую механику подтверждения прочтения. Одно логическое сообщение по-прежнему отправляется парой блоков: - `type=1` — входящее сообщение для получателя; - `type=2` — исходящая копия для отправителя. Ключ сообщения остаётся прежним: - `baseKey = from|to|timeMs|nonce` - `messageKey = baseKey|messageType` Теперь `timeMs + nonce` задаются один раз на всё логическое сообщение и не меняются при редактировании. Для новой версии того же письма используется то же `messageKey`, но большее `revisionTimeMs`. Сервер хранит только последнюю версию записи по этому `messageKey` через `upsert`. --- ## 1) Общая схема потока 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 { "op": "SendMessagePair", "requestId": "req-1", "payload": { "incomingBlobB64": "", "outgoingBlobB64": "" } } ``` Успешный ответ: ```json { "op": "SendMessagePair", "requestId": "req-1", "status": 200, "ok": true, "payload": { "baseKey": "from|to|time|nonce", "incomingKey": "from|to|time|nonce|1", "outgoingKey": "from|to|time|nonce|2", "deliveredWsSessions": 2, "deliveredWebPushSessions": 1 } } ``` ### `SignedMessageArrived` Событие в сессию всё ещё содержит: - `messageKey`, `baseKey` - `fromLogin`, `toLogin`, `targetLogin` - `messageType`, `timeMs`, `nonce` - `blobB64` - `backlog` Новая версия того же письма приходит с тем же `messageKey`, но с более новым `revisionTimeMs` внутри бинарного блока. ### `AckSessionDelivery` Формат не меняется. --- ## 7) HTTP endpoints для файлов ### `HEAD /f/` Проверяет наличие 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/services/crypto-utils.js` - `shine-UI/js/state.js` - `shine-UI/js/app.js` - `shine-UI/js/pages/chat-view.js` - 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`